Merge upstream/main to resolve conflicts

Both sides added adjacent LOGIN_* config entries (upstream:
loginShowHeading/loginShowSubtitle/logo sizing; this branch:
loginShowTotp/loginShowVersion) — resolution keeps both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LrR2CVfvcPWxr9ub299VwW
This commit is contained in:
Maarten Draijer
2026-07-22 02:54:55 +00:00
co-authored by Claude Fable 5
298 changed files with 25468 additions and 2440 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest';
import { sortDefaultFirst, reorderNonDefaultIds, type OrderableAccount } from '../account-utils';
const acct = (id: string, isDefault = false): OrderableAccount => ({ id, isDefault });
describe('sortDefaultFirst', () => {
it('pins the default account to the front, preserving the rest order', () => {
const accounts = [acct('a'), acct('b', true), acct('c')];
expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']);
});
it('is a no-op shape when the default is already first', () => {
const accounts = [acct('b', true), acct('a'), acct('c')];
expect(sortDefaultFirst(accounts).map((a) => a.id)).toEqual(['b', 'a', 'c']);
});
it('does not mutate the input array', () => {
const accounts = [acct('a'), acct('b', true)];
const snapshot = accounts.map((a) => a.id);
sortDefaultFirst(accounts);
expect(accounts.map((a) => a.id)).toEqual(snapshot);
});
});
describe('reorderNonDefaultIds', () => {
// default 'd' stays index 0; non-defaults are a, b, c
const accounts = [acct('d', true), acct('a'), acct('b'), acct('c')];
it('moves a non-default onto a later position, keeping default pinned', () => {
expect(reorderNonDefaultIds(accounts, 'a', 'c')).toEqual(['d', 'b', 'c', 'a']);
});
it('moves a non-default earlier', () => {
expect(reorderNonDefaultIds(accounts, 'c', 'a')).toEqual(['d', 'c', 'a', 'b']);
});
it('returns null for a no-op (same id)', () => {
expect(reorderNonDefaultIds(accounts, 'a', 'a')).toBeNull();
});
it('returns null when the default is dragged or targeted', () => {
expect(reorderNonDefaultIds(accounts, 'd', 'a')).toBeNull();
expect(reorderNonDefaultIds(accounts, 'a', 'd')).toBeNull();
});
});
+13
View File
@@ -302,6 +302,19 @@ describe('getPendingAlerts', () => {
expect(result).toHaveLength(0);
});
it('skips alerts for cancelled events', () => {
// iTIP CANCEL marks the attendee's copy with status "cancelled" instead
// of deleting it (#572) - its reminders must not fire.
const event = makeEvent({
status: 'cancelled',
alerts: { 'a1': makeAlert() },
});
const calendars = [makeCalendar()];
const now = fiveMinBefore + 1000;
const result = getPendingAlerts([event], calendars, new Set(), now);
expect(result).toHaveLength(0);
});
it('skips email action alerts', () => {
const event = makeEvent({
alerts: { 'a1': makeAlert({ action: 'email' }) },
+42 -3
View File
@@ -132,6 +132,42 @@ describe('isOrganizer', () => {
const event = makeEvent({ org: orgParticipant });
expect(isOrganizer(event, [])).toBe(false);
});
it('matches the event-level organizerCalendarAddress when no owner role is set', () => {
// Stalwart / imported self-organized events: the user's participant only
// carries `attendee`, the organizer lives in organizerCalendarAddress.
const event = makeEvent({
self: {
'@type': 'Participant',
name: 'Alice',
email: '',
roles: { attendee: true },
participationStatus: 'accepted',
sendTo: { imip: 'mailto:alice@example.com' },
kind: 'individual',
},
});
event.organizerCalendarAddress = 'mailto:alice@example.com';
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
});
it('matches the event-level organizerCalendarAddress case-insensitively', () => {
const event = makeEvent({ att1: attendeeParticipant });
event.organizerCalendarAddress = 'mailto:Alice@Example.com';
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
});
it('falls back to replyTo when organizerCalendarAddress is absent', () => {
const event = makeEvent({ att1: attendeeParticipant });
event.replyTo = { imip: 'mailto:alice@example.com' };
expect(isOrganizer(event, ['alice@example.com'])).toBe(true);
});
it('returns false when the event organizer is someone else', () => {
const event = makeEvent({ att1: attendeeParticipant });
event.organizerCalendarAddress = 'mailto:someoneelse@example.com';
expect(isOrganizer(event, ['alice@example.com'])).toBe(false);
});
});
describe('getUserParticipantId', () => {
@@ -282,10 +318,13 @@ describe('buildParticipantMap', () => {
expect(org).toBeDefined();
expect(org!.name).toBe('Alice');
expect(org!.email).toBe('alice@example.com');
expect(org!.roles).toEqual({ owner: true, attendee: true });
expect(org!.roles).toEqual({ owner: true });
expect(org!.participationStatus).toBe('accepted');
expect(org!.scheduleAgent).toBe('server');
expect(org!.sendTo).toEqual({ imip: 'mailto:alice@example.com' });
// sendTo is retired in draft-ietf-calext-jscalendarbis; the scheduling
// address is carried by calendarAddress instead.
expect(org!.sendTo).toBeUndefined();
expect(org!.calendarAddress).toBe('mailto:alice@example.com');
expect(org!.expectReply).toBe(false);
const att0 = entries.find(p => p.email === 'bob@example.com');
@@ -309,7 +348,7 @@ describe('buildParticipantMap', () => {
expect(Object.keys(map)).toHaveLength(1);
const org = Object.values(map)[0];
expect(org).toBeDefined();
expect(org.roles).toEqual({ owner: true, attendee: true });
expect(org.roles).toEqual({ owner: true });
});
it('sets @type to Participant for all entries', () => {
+50
View File
@@ -0,0 +1,50 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { clearCachedData } from '../clear-cached-data';
describe('clearCachedData', () => {
let reload: ReturnType<typeof vi.fn>;
let originalLocation: Location;
beforeEach(() => {
localStorage.clear();
reload = vi.fn();
originalLocation = window.location;
Object.defineProperty(window, 'location', {
configurable: true,
value: { ...originalLocation, reload },
});
});
afterEach(() => {
Object.defineProperty(window, 'location', { configurable: true, value: originalLocation });
});
it('clears re-fetchable caches but keeps accounts, sessions and prefs', () => {
localStorage.setItem('contact-storage', '1');
localStorage.setItem('calendar-storage', '1');
localStorage.setItem('identity-storage', '1');
localStorage.setItem('calendar-notification-storage', '1');
// Must survive — losing these is exactly the pain we're avoiding.
localStorage.setItem('account-registry', 'accounts');
localStorage.setItem('auth-storage', 'session');
localStorage.setItem('settings-storage', 'prefs');
localStorage.setItem('template-storage', 'my templates');
clearCachedData();
expect(localStorage.getItem('contact-storage')).toBeNull();
expect(localStorage.getItem('calendar-storage')).toBeNull();
expect(localStorage.getItem('identity-storage')).toBeNull();
expect(localStorage.getItem('calendar-notification-storage')).toBeNull();
expect(localStorage.getItem('account-registry')).toBe('accounts');
expect(localStorage.getItem('auth-storage')).toBe('session');
expect(localStorage.getItem('settings-storage')).toBe('prefs');
expect(localStorage.getItem('template-storage')).toBe('my templates');
});
it('reloads so data is re-fetched fresh', () => {
clearCachedData();
expect(reload).toHaveBeenCalledOnce();
});
});
+10
View File
@@ -111,6 +111,16 @@ describe('pickRequestHost', () => {
it('lower-cases the result', () => {
expect(pickRequestHost(mockHeaders({ host: 'EXAMPLE.com' }))).toBe('example.com');
});
it('handles a ReadonlyHeaders-shaped object with an internal `headers` field', () => {
// `await headers()` returns Next's ReadonlyHeaders, which exposes `.get`
// directly but also carries an internal `headers` property. Ensure we use
// its own `.get` rather than descending into `.headers` (#585).
const readonlyLike = Object.assign(mockHeaders({ host: 'ro.example.com' }), {
headers: { notCallable: true },
});
expect(pickRequestHost(readonlyLike as unknown as Headers)).toBe('ro.example.com');
});
});
describe('matchDomainBranding', () => {
+249 -1
View File
@@ -10,7 +10,84 @@ import {
parseRecipientList,
formatRecipientList,
splitPastedRecipients,
} from "../email-composer-utils";
waitForPendingUploads,
extractUserAuthoredText,
formatRecipientEntry,
expandRecipients,
getQuoteBodies,
} from '../email-composer-utils';
const FORWARDED_SEPARATOR = "---------- Forwarded message ----------";
describe("extractUserAuthoredText", () => {
const scan = (body: string, plainTextMode: boolean) =>
extractUserAuthoredText(body, {
plainTextMode,
forwardedSeparator: FORWARDED_SEPARATOR,
}).toLowerCase();
it("keeps user text and drops the quoted island on an HTML reply (#570)", () => {
const body =
"<p>Here is my reply.</p>" +
'<div>On Mon, Someone wrote:</div>' +
'<div data-quoted-html><p>Please find attached the invoice (anexo).</p></div>';
const result = scan(body, false);
expect(result).toContain("here is my reply");
expect(result).not.toContain("anexo");
expect(result).not.toContain("attached");
});
it("drops a <blockquote> quote when the original had no HTML part", () => {
const body =
"<p>Thanks!</p>" +
'<blockquote>segue em anexo o documento</blockquote>';
const result = scan(body, false);
expect(result).toContain("thanks");
expect(result).not.toContain("anexo");
});
it("drops the forwarded header and original on an HTML forward", () => {
const body =
"<p>FYI</p><br><br>" +
FORWARDED_SEPARATOR +
"<br>From: a@b.com<br>Subject: Invoice attached<br><br>" +
'<div data-quoted-html><p>em anexo</p></div>';
const result = scan(body, false);
expect(result).toContain("fyi");
expect(result).not.toContain("anexo");
expect(result).not.toContain("attached");
expect(result).not.toContain("forwarded message");
});
it("drops '>' quoted lines on a plain-text reply", () => {
const body = "My reply here.\n\nOn Mon, X wrote:\n> please find attached\n> anexo";
const result = scan(body, true);
expect(result).toContain("my reply here");
expect(result).not.toContain("attached");
expect(result).not.toContain("anexo");
});
it("drops the bare forwarded original on a plain-text forward", () => {
const body =
"See below.\n\n" +
FORWARDED_SEPARATOR +
"\nFrom: a@b.com\nSubject: hi\n\nem anexo o contrato";
const result = scan(body, true);
expect(result).toContain("see below");
expect(result).not.toContain("anexo");
});
it("still surfaces a keyword the user actually typed", () => {
const body =
"<p>See the attached file.</p>" +
'<div data-quoted-html><p>nothing here</p></div>';
expect(scan(body, false)).toContain("attached");
});
it("tolerates a missing forwarded separator", () => {
expect(scan("<p>plain reply</p>", false)).toContain("plain reply");
});
});
describe("plainTextToComposerBody", () => {
it("returns an empty string for empty input", () => {
@@ -274,3 +351,174 @@ describe("splitPastedRecipients", () => {
expect(splitPastedRecipients(" ")).toEqual({ valid: [], invalid: [] });
});
});
describe("waitForPendingUploads", () => {
const att = (over: Partial<{ uploading: boolean; error: boolean }> = {}) => ({
name: "file.pdf",
type: "application/pdf",
size: 100,
...over,
});
it("resolves 'completed' immediately when nothing is uploading", async () => {
const result = await waitForPendingUploads(
() => [att({}), att({})],
() => false,
1
);
expect(result).toBe("completed");
});
it("polls until in-flight uploads finish, then resolves 'completed'", async () => {
let list = [att({ uploading: true }), att({})];
setTimeout(() => {
list = [att({}), att({})];
}, 10);
const result = await waitForPendingUploads(() => list, () => false, 1);
expect(result).toBe("completed");
});
it("resolves 'failed' when an upload finishes with an error during the wait", async () => {
let list = [att({ uploading: true })];
setTimeout(() => {
list = [att({ error: true })];
}, 10);
const result = await waitForPendingUploads(() => list, () => false, 1);
expect(result).toBe("failed");
});
it("resolves 'failed' when another attachment is already errored once uploads finish", async () => {
let list = [att({ uploading: true }), att({ error: true })];
setTimeout(() => {
list = [att({}), att({ error: true })];
}, 10);
const result = await waitForPendingUploads(() => list, () => false, 1);
expect(result).toBe("failed");
});
it("resolves 'cancelled' when cancellation is signalled mid-wait", async () => {
let cancelled = false;
const list = [att({ uploading: true })];
setTimeout(() => {
cancelled = true;
}, 10);
const result = await waitForPendingUploads(
() => list,
() => cancelled,
1
);
expect(result).toBe("cancelled");
});
it("prefers 'cancelled' over 'failed' when the draft is closed while an errored upload is pending", async () => {
let cancelled = false;
let list = [att({ uploading: true })];
setTimeout(() => {
list = [att({ error: true, uploading: true })];
cancelled = true;
}, 10);
const result = await waitForPendingUploads(
() => list,
() => cancelled,
1
);
expect(result).toBe("cancelled");
});
});
describe('contact group recipients (RFC 5322 group syntax)', () => {
const group = {
name: 'Vertrieb',
email: '',
group: { members: [
{ name: 'Anna Alt', email: 'anna@example.com' },
{ email: 'bob@example.com' },
] },
};
it('formats a group chip as RFC 5322 group syntax', () => {
expect(formatRecipientEntry(group)).toBe('Vertrieb: Anna Alt <anna@example.com>, bob@example.com;');
});
it('round-trips a group through format -> parse', () => {
const parsed = parseRecipientList(formatRecipientList([group, { email: 'solo@example.com' }]));
expect(parsed).toHaveLength(2);
expect(parsed[0].group?.members).toEqual([
{ name: 'Anna Alt', email: 'anna@example.com' },
{ email: 'bob@example.com' },
]);
expect(parsed[0].name).toBe('Vertrieb');
expect(parsed[0].email).toBe('');
expect(parsed[1]).toEqual({ email: 'solo@example.com' });
});
it('quotes group names containing specials and round-trips them', () => {
const tricky = { name: 'Sales, EMEA', email: '', group: { members: [{ email: 'a@x.de' }] } };
const parsed = parseRecipientList(formatRecipientList([tricky]));
expect(parsed[0].name).toBe('Sales, EMEA');
expect(parsed[0].group?.members).toEqual([{ email: 'a@x.de' }]);
});
it('keeps commas inside a group while splitting a mixed list', () => {
const parsed = parseRecipientList('first@x.de, Team: a@x.de, b@x.de;, last@x.de');
expect(parsed.map(r => r.email || r.name)).toEqual(['first@x.de', 'Team', 'last@x.de']);
expect(parsed[1].group?.members).toHaveLength(2);
});
it('expandRecipients flattens groups and dedupes against individuals', () => {
const expanded = expandRecipients([
{ name: 'Anna Alt', email: 'ANNA@example.com' },
group,
{ email: 'bob@example.com' },
]);
expect(expanded.map(r => r.email)).toEqual(['ANNA@example.com', 'bob@example.com']);
});
it('leaves plain recipients untouched by expansion', () => {
expect(expandRecipients([{ name: 'X', email: 'x@y.z' }])).toEqual([{ name: 'X', email: 'x@y.z' }]);
});
});
describe("getQuoteBodies", () => {
const part = (partId: string, type: string) => ({ partId, blobId: "b", size: 1, type });
it("converts an HTML-only message's shared part into readable text (#649)", () => {
const { body, htmlBody } = getQuoteBodies({
textBody: [part("1", "text/html")],
htmlBody: [part("1", "text/html")],
bodyValues: { "1": { value: "<p>Hallo Jonas.</p><p>Zeile zwei<br>und drei</p>" } },
});
expect(body).toBe("Hallo Jonas.\n\nZeile zwei\nund drei");
expect(htmlBody).toContain("<p>Hallo Jonas.</p>");
});
it("drops htmlBody when it is really the text/plain part (#649)", () => {
const text = "Hallo Herr Test,\n\ndas ist eine Test-Email.\n\nBeste Grüße";
const { body, htmlBody } = getQuoteBodies({
textBody: [part("1", "text/plain")],
htmlBody: [part("1", "text/plain")],
bodyValues: { "1": { value: text } },
});
expect(body).toBe(text);
expect(htmlBody).toBeUndefined();
});
it("passes both parts through when the message has real alternatives", () => {
const { body, htmlBody } = getQuoteBodies({
textBody: [part("t", "text/plain")],
htmlBody: [part("h", "text/html")],
bodyValues: {
t: { value: "plain version" },
h: { value: "<p>html version</p>" },
},
});
expect(body).toBe("plain version");
expect(htmlBody).toBe("<p>html version</p>");
});
it("falls back to the preview when body values are missing", () => {
const { body, htmlBody } = getQuoteBodies({ preview: "preview text" });
expect(body).toBe("preview text");
expect(htmlBody).toBeUndefined();
});
});
+29
View File
@@ -83,6 +83,35 @@ describe('parseAuthenticationResults', () => {
const result = parseAuthenticationResults(header);
expect(result.spf?.domain).toBe('example.com');
});
it('does not let a HELO `none` downgrade a MAIL FROM `pass` (#650)', () => {
const header =
'mail.haxalot.com; spf=none (mail.haxalot.com: no SPF records found for postmaster@out-23.smtp.github.com) smtp.helo=out-23.smtp.github.com; spf=pass (mail.haxalot.com: domain of noreply@github.com designates 192.30.252.206 as permitted sender) smtp.mailfrom=noreply@github.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('pass');
expect(result.spf?.domain).toBe('noreply@github.com');
expect(result.spf?.all).toHaveLength(2);
});
it('does not let a HELO `neutral` downgrade a MAIL FROM `pass`', () => {
const header = 'spf=neutral smtp.helo=mail.example.com; spf=pass smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('pass');
});
it('still escalates a HELO hard fail over a MAIL FROM pass', () => {
const header = 'spf=fail smtp.helo=mail.spoof.com; spf=pass smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('fail');
expect(result.spf?.domain).toBe('mail.spoof.com');
});
it('keeps MAIL FROM `none` as the headline even when HELO passes', () => {
const header = 'spf=pass smtp.helo=mail.example.com; spf=none smtp.mailfrom=example.com';
const result = parseAuthenticationResults(header);
expect(result.spf?.result).toBe('none');
expect(result.spf?.domain).toBe('example.com');
});
});
describe('isAuthenticationSpoofed', () => {
@@ -0,0 +1,96 @@
import { describe, it, expect } from 'vitest';
import DOMPurify from 'dompurify';
import {
EMAIL_IFRAME_SANITIZE_CONFIG,
applyNewTabToAnchor,
plainTextToSafeHtml,
sanitizePlainTextRenderedHtml,
parseHtmlSafely,
} from '../email-sanitization';
/**
* Regression guard for "email links open in a new tab". The at-risk links are
* generated by linkification (not in the source) and DOMPurify was silently
* stripping their target/rel, so they opened in the same tab. Drives both real
* EmailViewer pipelines end-to-end — plaintext and HTML/iframe — so it can't
* regress unnoticed.
*/
/** Reproduce the EmailViewer iframe pipeline for an HTML body. */
function renderIframeHtml(html: string): Document {
DOMPurify.addHook('afterSanitizeAttributes', applyNewTabToAnchor);
let clean: string;
try {
clean = DOMPurify.sanitize(html, EMAIL_IFRAME_SANITIZE_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
const doc = parseHtmlSafely(clean);
// Post-render walk, exactly as handleIframeLoad does on the live iframe doc.
doc.querySelectorAll('a').forEach(applyNewTabToAnchor);
return doc;
}
/** Reproduce the EmailViewer plaintext pipeline (rendered into the main DOM). */
function renderPlaintext(text: string): Document {
return parseHtmlSafely(sanitizePlainTextRenderedHtml(plainTextToSafeHtml(text)));
}
const findLink = (doc: Document, hrefIncludes: string): HTMLAnchorElement | undefined =>
Array.from(doc.querySelectorAll('a')).find((a) => (a.getAttribute('href') || '').includes(hrefIncludes));
describe('email link new-tab behaviour (integration)', () => {
describe('plaintext body (links are generated, not in the source)', () => {
it('opens an http(s) URL in a new tab with noopener noreferrer', () => {
const doc = renderPlaintext('Please visit https://example.com/welcome today.');
const link = findLink(doc, 'example.com');
expect(link).toBeTruthy();
expect(link!.getAttribute('href')).toBe('https://example.com/welcome');
expect(link!.getAttribute('target')).toBe('_blank');
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
});
it('does not turn a bare email address into a new-tab link', () => {
const doc = renderPlaintext('Write to foo@bar.com for help.');
// plaintext linkification only targets http(s) URLs, never mailto.
expect(doc.querySelectorAll('a').length).toBe(0);
});
});
describe('HTML alternative that looks like plaintext (server-generated <a> tags)', () => {
it('opens http(s) anchors in a new tab and adds noopener noreferrer', () => {
const doc = renderIframeHtml('Hi<br><a href="http://example.org/page">http://example.org/page</a><br>Bye');
const link = findLink(doc, 'example.org');
expect(link!.getAttribute('target')).toBe('_blank');
expect(link!.getAttribute('rel')).toBe('noopener noreferrer');
});
it('does NOT add target=_blank to mailto links', () => {
const doc = renderIframeHtml('<a href="mailto:sales@example.com">sales@example.com</a>');
const link = findLink(doc, 'mailto:');
expect(link).toBeTruthy();
expect(link!.getAttribute('target')).toBeNull();
expect(link!.getAttribute('rel')).toBeNull();
});
it('does NOT add target=_blank to in-page #anchors', () => {
const doc = renderIframeHtml('<a href="#section">jump</a>');
const link = findLink(doc, '#section');
expect(link!.getAttribute('target')).toBeNull();
});
it('strips an author-supplied target=_blank from a mailto link', () => {
const doc = renderIframeHtml('<a href="mailto:x@y.com" target="_blank" rel="noopener noreferrer">x</a>');
const link = findLink(doc, 'mailto:');
expect(link!.getAttribute('target')).toBeNull();
});
it('handles a mixed body: http gets a new tab, mailto does not', () => {
const doc = renderIframeHtml(
'See <a href="https://docs.example.com">docs</a> or mail <a href="mailto:hi@example.com">us</a>.',
);
expect(findLink(doc, 'docs.example.com')!.getAttribute('target')).toBe('_blank');
expect(findLink(doc, 'mailto:')!.getAttribute('target')).toBeNull();
});
});
});
+178
View File
@@ -3,15 +3,21 @@ import DOMPurify from 'dompurify';
import {
sanitizeEmailHtml,
sanitizeSignatureHtml,
sanitizeSignatureHtmlForDisplay,
parseHtmlSafely,
hasRichFormatting,
plainTextToSafeHtml,
sanitizePlainTextRenderedHtml,
EMAIL_SANITIZE_CONFIG,
EMAIL_IFRAME_SANITIZE_CONFIG,
isExternalResourceUrl,
isHttpLinkHref,
applyNewTabToAnchor,
sanitizeI18nHtml,
decodeCssEscapes,
styleHasExternalUrl,
stripExternalCssUrls,
stripExternalStyleSheetCss,
blockExternalResourcesOnNode,
TRANSPARENT_BLOCKED_PIXEL,
} from '../email-sanitization';
@@ -357,6 +363,85 @@ describe('email-sanitization', () => {
});
});
describe('isHttpLinkHref (open-in-new-tab eligibility)', () => {
it('treats http(s) and protocol-relative links as new-tab links', () => {
expect(isHttpLinkHref('https://example.com/page')).toBe(true);
expect(isHttpLinkHref('http://example.com/page')).toBe(true);
expect(isHttpLinkHref('//example.com/page')).toBe(true);
expect(isHttpLinkHref('HTTPS://EXAMPLE.COM')).toBe(true);
});
it('sees through obfuscated schemes (leading/embedded whitespace)', () => {
expect(isHttpLinkHref('\n\nhttps://example.com')).toBe(true);
expect(isHttpLinkHref(' \t https://example.com')).toBe(true);
expect(isHttpLinkHref('h\nttps://example.com')).toBe(true);
});
it('excludes mailto and other non-web schemes (must NOT open a new tab)', () => {
expect(isHttpLinkHref('mailto:someone@example.com')).toBe(false);
expect(isHttpLinkHref('mailto:someone@example.com?subject=Hi')).toBe(false);
expect(isHttpLinkHref('tel:+15551234567')).toBe(false);
expect(isHttpLinkHref('sms:+15551234567')).toBe(false);
expect(isHttpLinkHref('cid:image001@example.com')).toBe(false);
expect(isHttpLinkHref('#section')).toBe(false);
expect(isHttpLinkHref('/relative/path')).toBe(false);
expect(isHttpLinkHref('')).toBe(false);
expect(isHttpLinkHref(null)).toBe(false);
expect(isHttpLinkHref(undefined)).toBe(false);
});
});
describe('applyNewTabToAnchor', () => {
const anchor = (html: string): HTMLAnchorElement =>
parseHtmlSafely(html).querySelector('a')!;
it('adds target/rel to http(s) links', () => {
const a = anchor('<a href="https://example.com">x</a>');
applyNewTabToAnchor(a);
expect(a.getAttribute('target')).toBe('_blank');
expect(a.getAttribute('rel')).toBe('noopener noreferrer');
});
it('strips target/rel from mailto links', () => {
const a = anchor('<a href="mailto:a@b.com" target="_blank" rel="noopener noreferrer">x</a>');
applyNewTabToAnchor(a);
expect(a.getAttribute('target')).toBeNull();
expect(a.getAttribute('rel')).toBeNull();
});
it('strips target from tel: and in-page #anchors', () => {
const tel = anchor('<a href="tel:+1555" target="_blank">x</a>');
applyNewTabToAnchor(tel);
expect(tel.getAttribute('target')).toBeNull();
const frag = anchor('<a href="#section" target="_blank">x</a>');
applyNewTabToAnchor(frag);
expect(frag.getAttribute('target')).toBeNull();
});
it('ignores non-anchor elements', () => {
const span = parseHtmlSafely('<span target="_blank">x</span>').querySelector('span')!;
applyNewTabToAnchor(span);
expect(span.getAttribute('target')).toBe('_blank');
});
});
describe('sanitizeI18nHtml', () => {
it('preserves an authored target="_blank" and hardens rel (regression: DOMPurify strips target)', () => {
const out = sanitizeI18nHtml(
'See the <a href="/docs/guides/account-security" class="underline" target="_blank">documentation</a>.',
);
expect(out).toContain('target="_blank"');
expect(out).toContain('rel="noopener noreferrer"');
expect(out).toContain('href="/docs/guides/account-security"');
});
it('leaves links without a target untouched (no spurious new tab)', () => {
const out = sanitizeI18nHtml('Go <a href="/settings">here</a>.');
expect(out).toContain('href="/settings"');
expect(out).not.toContain('target=');
});
});
describe('decodeCssEscapes', () => {
it('decodes hex escapes (cssEscape bypass)', () => {
expect(decodeCssEscapes('\\68ttp://x')).toBe('http://x');
@@ -394,6 +479,42 @@ describe('email-sanitization', () => {
});
});
describe('stripExternalStyleSheetCss (<style> block defence-in-depth, #457)', () => {
it('strips external url() inside a style rule', () => {
expect(stripExternalStyleSheetCss("#x{background:url('http://tracker.example/y')}"))
.toBe('#x{background:url()}');
});
it('strips the CSS-escaped url() keyword (\\75\\72\\6C()', () => {
expect(stripExternalStyleSheetCss("#x{background:\\75\\72\\6C('http://tracker.example/y')}"))
.toBe('#x{background:url()}');
});
it('removes a remote @import (bare-string form)', () => {
expect(stripExternalStyleSheetCss('@import "http://tracker.example/s.css";\n#x{color:red}'))
.toBe('\n#x{color:red}');
expect(stripExternalStyleSheetCss("@import '//tracker.example/s.css';")).toBe('');
});
it('neutralises a remote @import url() form', () => {
expect(stripExternalStyleSheetCss('@import url(http://tracker.example/s.css);'))
.toBe('@import url();');
});
it('leaves a stylesheet with no external refs unchanged (escapes intact)', () => {
const css = '#x{content:"\\2014";background:url(data:image/png;base64,AAAA)}';
expect(stripExternalStyleSheetCss(css)).toBe(css);
});
it('blockExternalResourcesOnNode strips a <style> block and reports blocked', () => {
const style = parseHtmlSafely(
'<body><style>#x{background:url(http://tracker.example/y)}</style></body>',
).body.firstElementChild!;
expect(blockExternalResourcesOnNode(style)).toBe(true);
expect(style.textContent).toBe('#x{background:url()}');
});
});
describe('blockExternalResourcesOnNode (anti-tracking vectors)', () => {
function el(html: string): Element {
return parseHtmlSafely(`<body>${html}</body>`).body.firstElementChild!;
@@ -580,4 +701,61 @@ describe('email-sanitization', () => {
expect(result).toContain('javascript:alert(1)');
});
});
describe('sanitizeSignatureHtmlForDisplay', () => {
// Signatures render into the main document (identity-form preview, composer
// block), not the sandboxed iframe, so a target-less anchor navigates the
// whole app away and takes the unsaved draft/signature with it.
it('forces target=_blank and rel on signature links', () => {
const clean = sanitizeSignatureHtmlForDisplay('<p><a href="https://example.com">Site</a></p>');
expect(clean).toContain('target="_blank"');
expect(clean).toContain('rel="noopener noreferrer"');
});
it('overrides a target the user supplied themselves', () => {
const clean = sanitizeSignatureHtmlForDisplay('<a href="https://example.com" target="_top">x</a>');
expect(clean).toContain('target="_blank"');
expect(clean).not.toContain('_top');
});
it('keeps the image restrictions of the storage sanitizer', () => {
const clean = sanitizeSignatureHtmlForDisplay(
'<img src="http://insecure.example.com/l.png"><img src="https://cdn.example.com/l.png">',
);
expect(clean).not.toContain('insecure.example.com');
expect(clean).toContain('https://cdn.example.com/l.png');
});
it('does not leak target into the stored or sent signature', () => {
// sanitizeSignatureHtml feeds both storage and the outgoing message body.
const stored = sanitizeSignatureHtml('<p><a href="https://example.com">Site</a></p>');
expect(stored).toContain('href="https://example.com"');
expect(stored).not.toContain('target=');
});
it('handles empty input', () => {
expect(sanitizeSignatureHtmlForDisplay('')).toBe('');
expect(sanitizeSignatureHtmlForDisplay(' ')).toBe('');
});
});
describe('sanitizePlainTextRenderedHtml', () => {
// This branch renders into the main document, not the sandboxed iframe, so
// an anchor that loses target="_blank" navigates the whole app away.
it('preserves target and rel on links emitted by plainTextToSafeHtml', () => {
const rendered = sanitizePlainTextRenderedHtml(
plainTextToSafeHtml('see https://github.com/honzup/webmail/pull/560'),
);
expect(rendered).toContain('target="_blank"');
expect(rendered).toContain('rel="noopener noreferrer"');
});
it('still strips dangerous schemes and tags', () => {
const rendered = sanitizePlainTextRenderedHtml(
'<a href="javascript:alert(1)" target="_blank">x</a><script>alert(1)</script>',
);
expect(rendered).not.toContain('javascript:');
expect(rendered).not.toContain('<script');
});
});
});
+342
View File
@@ -0,0 +1,342 @@
import { describe, it, expect } from 'vitest';
import { formatBadgeCount, renderBadgedFavicon } from '@/lib/favicon-badge';
const BASE_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1000pt" height="1000pt"><defs><clipPath id="_clip1"><rect width="1000" height="1000"/></clipPath></defs><g clip-path="url(#_clip1)"><rect width="1000" height="1000" fill="#123456"/></g></svg>`;
function decode(dataUrl: string): string {
return decodeURIComponent(dataUrl.replace('data:image/svg+xml,', ''));
}
/** The badge band: the last <rect> the renderer appends, identified by its fill. */
function band(svg: string): { x: number; y: number; w: number; h: number; rx: number } {
const match =
/<rect[^>]*\bx="(-?[\d.]+)"[^>]*\by="(-?[\d.]+)"[^>]*\bwidth="([\d.]+)"[^>]*\bheight="([\d.]+)"[^>]*\brx="([\d.]+)"[^>]*fill="#ffffff"/.exec(
svg,
);
expect(match).not.toBeNull();
const [, x, y, w, h, rx] = match!.map(Number);
return { x, y, w, h, rx };
}
function fontSize(svg: string): number {
return Number(/<text[^>]*font-size="([\d.]+)"/.exec(svg)![1]);
}
function viewBoxOf(svg: string): { minX: number; minY: number; width: number; height: number } {
const [minX, minY, width, height] = /viewBox="([^"]+)"/
.exec(svg)![1]
.trim()
.split(/[\s,]+/)
.map(Number);
return { minX, minY, width, height };
}
describe('formatBadgeCount', () => {
it('returns an empty string for zero and below', () => {
expect(formatBadgeCount(0)).toBe('');
expect(formatBadgeCount(-3)).toBe('');
});
it('returns the count verbatim from 1 to 99', () => {
expect(formatBadgeCount(1)).toBe('1');
expect(formatBadgeCount(9)).toBe('9');
expect(formatBadgeCount(47)).toBe('47');
expect(formatBadgeCount(99)).toBe('99');
});
it('caps at 99+ above 99', () => {
// Gmail caps at 20; matching it was tried and reverted. A lower cap means a
// typical inbox needs three glyphs almost always, and three glyphs do not
// fit at the full font size — so "99+" rendered permanently smaller than a
// real two-digit count would have.
expect(formatBadgeCount(100)).toBe('99+');
expect(formatBadgeCount(133)).toBe('99+');
expect(formatBadgeCount(1000)).toBe('99+');
});
it('returns an empty string for non-finite input', () => {
expect(formatBadgeCount(Number.NaN)).toBe('');
expect(formatBadgeCount(Number.POSITIVE_INFINITY)).toBe('');
});
});
describe('renderBadgedFavicon', () => {
it('returns null when the count is zero', () => {
expect(renderBadgedFavicon(BASE_SVG, 0)).toBeNull();
});
it('returns null when the source is not SVG', () => {
expect(renderBadgedFavicon('this is not svg', 3)).toBeNull();
});
it('returns null when the root element is not <svg>', () => {
expect(renderBadgedFavicon('<html><body/></html>', 3)).toBeNull();
});
it('returns null when the root has no viewBox', () => {
const noViewBox = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16"/>`;
expect(renderBadgedFavicon(noViewBox, 3)).toBeNull();
});
it('returns a percent-encoded svg data URL', () => {
const url = renderBadgedFavicon(BASE_SVG, 3);
expect(url).not.toBeNull();
expect(url!.startsWith('data:image/svg+xml,')).toBe(true);
});
it('draws a badge band and the count text', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('<rect');
expect(svg).toContain('>3<');
});
it('renders 99+ for large counts', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(svg).toContain('>99+<');
});
it('preserves the base artwork and its clipPath id', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('id="_clip1"');
expect(svg).toContain('#123456');
});
it('overrides pt-unit width and height with unitless 16 and keeps the viewBox', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('width="16"');
expect(svg).toContain('height="16"');
expect(svg).toContain('viewBox="0 0 1000 1000"');
expect(svg).not.toContain('1000pt');
});
it('draws a white band with black digits, so the count stays legible over any base icon', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<rect[^>]*fill="#ffffff"/);
expect(svg).toMatch(/<text[^>]*fill="#000000"/);
});
it('shrinks the font as the label grows so three glyphs still fit', () => {
const one = decode(renderBadgedFavicon(BASE_SVG, 3)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(fontSize(three)).toBeLessThan(fontSize(one));
});
it('returns null rather than throwing when the source contains a lone surrogate', () => {
const bad = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><title>abc\uD800def</title></svg>`;
expect(() => renderBadgedFavicon(bad, 3)).not.toThrow();
expect(renderBadgedFavicon(bad, 3)).toBeNull();
});
it('sizes the band to the label, and only "99+" fills the full icon width', () => {
// The box is only as wide as its digits need — "5" must not squat on as much
// white as "99+". It is never wider than the icon, and three glyphs, whose
// font is budgeted against the full span, grow to exactly fill it.
const w = (count: number) => band(decode(renderBadgedFavicon(BASE_SVG, count)!)).w;
expect(w(7)).toBeLessThan(w(47));
expect(w(47)).toBeLessThan(w(250));
expect(w(250)).toBeCloseTo(1000, 5);
});
it('matches the geometry measured from Gmail\'s 16px favicon', () => {
// Ground truth, measured pixel-by-pixel off Gmail's tab icon and scaled to a
// 0 0 1000 1000 viewBox: band 10/16 of the icon (0.625), flush to the bottom
// edge, corners rounded by 0.1h, width fitted to the label, anchored right.
// Gmail's own single-digit badge sits hard right in a box about a third of
// the icon wide, so the box grows leftwards from the corner.
// w = label.length * 0.6 * font + 2 * 0.04 * 1000, x = 1000 - w.
const expected: Record<string, { x: number; w: number; font: number }> = {
'5': { x: 554, w: 446, font: 610 }, // textW = 1 * 0.6 * 610 = 366
'15': { x: 188, w: 812, font: 610 }, // textW = 2 * 0.6 * 610 = 732
'250': { x: 0, w: 1000, font: 920 / 1.8 }, // "99+": font = (1000 - 80) / (3 * 0.6)
};
for (const [count, want] of Object.entries(expected)) {
const svg = decode(renderBadgedFavicon(BASE_SVG, Number(count))!);
const { x, y, w, h, rx } = band(svg);
expect(x).toBeCloseTo(want.x, 5);
expect(w).toBeCloseTo(want.w, 5);
expect(fontSize(svg)).toBeCloseTo(want.font, 5);
expect(y).toBeCloseTo(375, 5);
expect(h).toBeCloseTo(625, 5);
expect(rx).toBeCloseTo(62.5, 5);
}
});
it('anchors the band to the right edge, including on a negative-origin viewBox', () => {
// Corner-anchored, not centred: the box grows leftwards from the bottom-right
// corner, so its right edge sits on minX + span whatever the label. Centring
// was rejected — at a single digit it lands under the middle of the mark.
const cases: [string, number, number][] = [
// [base svg, minX, span]
[BASE_SVG, 0, 1000],
[`<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 24 24"><rect x="-4" y="-4" width="24" height="24" fill="#123456"/></svg>`, -4, 24],
];
for (const [svgSource, minX, span] of cases) {
for (const count of [7, 47, 250]) {
const { x, w } = band(decode(renderBadgedFavicon(svgSource, count)!));
expect(x + w).toBeCloseTo(minX + span, 5);
expect(x).toBeGreaterThanOrEqual(minX);
}
}
});
it('renders 1- and 2-digit labels at the max font size, and shrinks only for "99+"', () => {
// The font is budgeted against the full icon span, not against the fitted
// box, so one or two glyphs always land at FONT_MAX; only three force a
// shrink — and their box then grows to fill the icon.
const FONT_MAX = 0.61 * 1000;
const one = decode(renderBadgedFavicon(BASE_SVG, 7)!);
const two = decode(renderBadgedFavicon(BASE_SVG, 47)!);
const three = decode(renderBadgedFavicon(BASE_SVG, 250)!);
expect(fontSize(one)).toBeCloseTo(FONT_MAX, 5);
expect(fontSize(two)).toBeCloseTo(FONT_MAX, 5);
expect(fontSize(three)).toBeLessThan(FONT_MAX);
});
it('rounds the band corners slightly — neither an oval nor a hard square', () => {
// rx = h / 2 was the pill: at one digit it read as a circle, at two an oval,
// and "99+" was a smudge. rx = 0 is the other failure: Gmail's corners carry
// a visible ~1px round at 16px. Guard against a silent revert to either.
for (const count of [7, 47, 250]) {
const { h, rx } = band(decode(renderBadgedFavicon(BASE_SVG, count)!));
expect(rx).toBeCloseTo(0.1 * h, 5);
expect(rx).toBeGreaterThan(0);
expect(rx).toBeLessThan(h / 2);
}
});
it('draws the digits at font-weight 500, in both the attribute and the style', () => {
// 700 read visibly heavier than Gmail's equivalent badge.
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toMatch(/<text[^>]*font-weight="500"/);
expect(svg).toMatch(/<text[^>]*style="[^"]*font-weight:\s*500/);
});
it('keeps the badge band entirely inside the viewBox for 1, 2, and 3-glyph labels', () => {
// The band is flush to the bottom and, at three glyphs, to the left and
// right edges too — but it must never overflow any of them.
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { x, y, w, h } = band(svg);
expect(x).toBeGreaterThanOrEqual(0);
expect(y).toBeGreaterThanOrEqual(0);
expect(x + w).toBeLessThanOrEqual(1000);
expect(y + h).toBeLessThanOrEqual(1000);
}
});
// A previous version of this test used /\bx="([\d.]+)"/, which cannot match a
// negative number: dropping `minX +` from the anchoring passed it. Anchor
// against a viewBox whose origin is negative, where the band's own x is
// legitimately negative, so the offset is genuinely pinned.
it('anchors the band to the viewBox origin, including a negative origin', () => {
const NEGATIVE_ORIGIN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-40 -40 240 240"><rect x="-40" y="-40" width="240" height="240" fill="#123456"/></svg>`;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(NEGATIVE_ORIGIN, count)!);
const { x, y, w, h } = band(svg);
expect(x).toBeGreaterThanOrEqual(-40);
expect(y).toBeGreaterThanOrEqual(-40);
expect(x + w).toBeLessThanOrEqual(200);
expect(y + h).toBeLessThanOrEqual(200);
// Anchored to the bottom-right: in a viewBox running from -40 to 200, the
// band's bottom edge and its right edge both sit well past the midpoint.
expect(x + w).toBeGreaterThan(80);
expect(y + h).toBeGreaterThan(80);
}
});
it('fits the label inside the band, with padding, for every label length', () => {
// The core band invariant: textW + 2 * pad <= w, where the glyph advance and
// padding are the renderer's own published constants. PAD_FACTOR is a
// fraction of the icon span, not of the fitted box, so the padding is the
// same at every label length.
const GLYPH_ADV = 0.6;
const PAD_FACTOR = 0.04;
for (const count of [7, 47, 250]) {
const svg = decode(renderBadgedFavicon(BASE_SVG, count)!);
const { w } = band(svg);
const label = count > 99 ? '99+' : String(count);
const textW = label.length * GLYPH_ADV * fontSize(svg);
const pad = PAD_FACTOR * 1000;
expect(textW + 2 * pad).toBeLessThanOrEqual(w + 1e-6);
}
});
it('percent-encodes the payload, so a "#" in a fill cannot truncate the data URL', () => {
const url = renderBadgedFavicon(BASE_SVG, 3)!;
// encodeURI leaves "#" bare, which the browser reads as a fragment
// delimiter: everything after the first colour would be silently dropped.
expect(url).toContain('%23');
expect(url).not.toContain('#');
});
it('returns an empty label, and no badge, for a fractional count below one', () => {
expect(formatBadgeCount(0.5)).toBe('');
expect(renderBadgedFavicon(BASE_SVG, 0.5)).toBeNull();
});
it('returns null when the root svg has no SVG namespace', () => {
// Non-null but unrenderable: a data URL built from this would show nothing.
const noNs = `<svg viewBox="0 0 100 100"><rect width="100" height="100"/></svg>`;
expect(renderBadgedFavicon(noNs, 3)).toBeNull();
});
it('beats a stylesheet in the base SVG, keeping the badge white-on-black', () => {
// Presentation attributes lose to any CSS rule in the document. A branded
// base carrying `rect { fill: #db2d54 }` would otherwise paint the band red
// and the digits red — exactly what the white band exists to prevent.
const STYLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000"><style>rect{fill:#db2d54}text{fill:#db2d54}</style><rect width="1000" height="1000"/></svg>`;
const svg = decode(renderBadgedFavicon(STYLED, 3)!);
expect(svg).toMatch(/<rect[^>]*style="[^"]*fill:\s*#ffffff/);
expect(svg).toMatch(/<text[^>]*style="[^"]*fill:\s*#000000/);
});
it('strips scripts, foreignObject and event handlers from the base SVG', () => {
// The base may be an admin-uploaded file, which upstream serves under a
// sandboxing CSP precisely because SVG can carry script. Re-emitting it as a
// same-origin data: URL would un-fence it, so sanitise before serialising.
const HOSTILE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" onload="alert(1)"><script>alert(2)</script><foreignObject width="100" height="100"><body xmlns="http://www.w3.org/1999/xhtml">hi</body></foreignObject><rect width="100" height="100" onclick="alert(3)" ONMOUSEOVER="alert(4)" fill="#123456"/></svg>`;
const url = renderBadgedFavicon(HOSTILE, 3)!;
expect(url).not.toBeNull();
const svg = decode(url);
expect(svg).not.toContain('<script');
expect(svg).not.toContain('foreignObject');
expect(svg.toLowerCase()).not.toContain('onload');
expect(svg.toLowerCase()).not.toContain('onclick');
expect(svg.toLowerCase()).not.toContain('onmouseover');
expect(svg).not.toContain('alert');
// The legitimate artwork survives.
expect(svg).toContain('#123456');
});
it('normalises a non-square viewBox to a square, so the badge stays legible', () => {
// A 100x20 wordmark: span = min(w, h) = 20 previously produced a ~2px-tall
// smudge on a 16px icon. Squaring the viewBox first sizes the badge against
// the rendered box instead.
const WORDMARK = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 20"><rect width="100" height="20" fill="#123456"/></svg>`;
const svg = decode(renderBadgedFavicon(WORDMARK, 42)!);
const vb = viewBoxOf(svg);
expect(vb.width).toBe(100);
expect(vb.height).toBe(100);
expect(vb.minX).toBe(0);
expect(vb.minY).toBe(-40); // centred: (100 - 20) / 2 above and below
const { x, y, w, h } = band(svg);
// Sized against the square side (100), not the 20-unit short axis.
expect(h).toBeCloseTo(0.625 * 100, 5);
// Two glyphs at FONT_MAX (61) plus padding: 2 * 0.6 * 61 + 2 * 4 = 81.2,
// anchored to the right of the squared span.
expect(w).toBeCloseTo(81.2, 5);
expect(x + w).toBeCloseTo(100, 5);
// Still in bounds of the normalised viewBox.
expect(x).toBeGreaterThanOrEqual(vb.minX);
expect(y).toBeGreaterThanOrEqual(vb.minY);
expect(x + w).toBeLessThanOrEqual(vb.minX + vb.width);
expect(y + h).toBeLessThanOrEqual(vb.minY + vb.height);
});
it('leaves a square viewBox untouched', () => {
const svg = decode(renderBadgedFavicon(BASE_SVG, 3)!);
expect(svg).toContain('viewBox="0 0 1000 1000"');
});
});
@@ -397,4 +397,53 @@ describe('JMAPClient resilience', () => {
).rejects.toThrow('Failed to fetch blob: 404');
});
});
// #281 V3: every email fetch path must namespace mailboxIds for shared/
// delegated accounts (`${ownerId}:${id}`) so they line up with the store's
// namespaced shared-mailbox ids. searchEmails/advancedSearchEmails are the
// cross-view (All mail / Unread / Starred) browse paths and previously did not.
describe('shared-account mailboxId namespacing', () => {
function queryAndGet(email: Record<string, unknown>) {
return {
methodResponses: [
['Email/query', { total: 1, ids: ['e1'] }, '0'],
['Email/get', { list: [email] }, '1'],
],
};
}
it('advancedSearchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
const client = await createConnectedClient(); // primary acct-1
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
);
const { emails } = await client.advancedSearchEmails({ inMailbox: 'owner-x:x-inbox' }, 'owner-x');
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
expect(emails[0].mailboxIds['x-inbox']).toBeUndefined();
});
it('searchEmails namespaces bare owner mailboxIds for a foreign account', async () => {
const client = await createConnectedClient();
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { 'x-inbox': true } })),
);
const { emails } = await client.searchEmails('hello', undefined, 'owner-x');
expect(emails[0].mailboxIds).toEqual({ 'owner-x:x-inbox': true });
});
it('leaves own-account mailboxIds untouched (no foreign accountId)', async () => {
const client = await createConnectedClient(); // primary acct-1
fetchSpy.mockResolvedValueOnce(
mockFetchResponse(200, queryAndGet({ id: 'e1', receivedAt: '2026-01-01T00:00:00Z', mailboxIds: { inbox: true } })),
);
const { emails } = await client.advancedSearchEmails({ inMailbox: 'inbox' });
expect(emails[0].mailboxIds).toEqual({ inbox: true });
});
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// Session where the SERVER advertises FileNode (session.capabilities) but the
// per-account accountCapabilities can independently include or omit it. This
// models the #563 scenario: a Stalwart role that revokes jmap-file-node-*
// permissions drops the capability from the account while the server still
// advertises it globally.
function makeSession(accountCapabilities: Record<string, unknown>, isPersonal = true) {
return {
capabilities: {
'urn:ietf:params:jmap:core': {},
'urn:ietf:params:jmap:filenode': {},
},
accounts: {
'acct-1': { name: 'test', isPersonal, accountCapabilities },
},
primaryAccounts: { 'urn:ietf:params:jmap:mail': 'acct-1' },
apiUrl: 'https://mail.example.com/jmap/api',
downloadUrl: 'https://mail.example.com/jmap/download/{accountId}/{blobId}/{name}',
uploadUrl: 'https://mail.example.com/jmap/upload/{accountId}/',
eventSourceUrl: 'https://mail.example.com/jmap/eventsource',
};
}
function mockFetchResponse(status: number, body?: unknown): Response {
return new Response(body ? JSON.stringify(body) : null, {
status,
headers: { 'Content-Type': 'application/json' },
});
}
async function connect(accountCapabilities: Record<string, unknown>, isPersonal = true): Promise<JMAPClient> {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(mockFetchResponse(200, makeSession(accountCapabilities, isPersonal)));
const client = new JMAPClient('https://mail.example.com', 'user@test.com', 'pass123');
await client.connect();
fetchSpy.mockReset();
return client;
}
describe('JMAPClient.supportsFiles (#563 - account-scoped capability)', () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch');
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('returns true when the account advertises the filenode capability', async () => {
const client = await connect({ 'urn:ietf:params:jmap:filenode': {} });
expect(client.supportsFiles()).toBe(true);
});
it('returns false when the server advertises filenode but the account does not (#563)', async () => {
// The revoked-permission case: server-wide capability present, account omits it.
const client = await connect({ 'urn:ietf:params:jmap:mail': {} });
expect(client.supportsFiles()).toBe(false);
});
it('treats non-personal (shared/group) accounts as capable even without per-account advertisement', async () => {
const client = await connect({}, /* isPersonal */ false);
expect(client.supportsFiles()).toBe(true);
});
it('probeFileNodeSupport does not probe (no network call) when the account is explicitly denied', async () => {
const client = await connect({ 'urn:ietf:params:jmap:mail': {} });
fetchSpy.mockClear();
await expect(client.probeFileNodeSupport()).resolves.toBe(false);
// Explicit per-account denial must short-circuit before any FileNode/query probe.
expect(fetchSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,74 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { JMAPClient } from '../jmap/client';
// Regression coverage for the shared-account keyword write path (#281): keyword
// mutations (tags, $answered/$forwarded) on a unified-inbox message must target
// the email's owning account, not the reaching client's primary. Writing to the
// primary account silently no-ops server-side (JMAP returns notUpdated without
// throwing), so the keyword is lost on the next reload. toggleStar already
// threaded accountId through; updateEmailKeywords/setKeyword did not.
function createClient(): JMAPClient {
const client = new JMAPClient('https://jmap.example.com', 'user@example.com', 'pass');
Object.assign(client, {
apiUrl: 'https://jmap.example.com/api',
accountId: 'primary-account',
username: 'user@example.com',
});
return client;
}
interface JMAPMethodCall {
0: string;
1: Record<string, unknown>;
2: string;
}
function mockEmailSet() {
const captured: JMAPMethodCall[] = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockImplementation(async (_url, init) => {
const body = JSON.parse((init as { body: string }).body) as { methodCalls: JMAPMethodCall[] };
captured.push(...body.methodCalls);
return new Response(JSON.stringify({ methodResponses: [['Email/set', { updated: {} }, '0']] }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
});
return { captured, fetchSpy };
}
describe('JMAP keyword writes route to the email account (#281)', () => {
beforeEach(() => vi.restoreAllMocks());
afterEach(() => vi.restoreAllMocks());
it('updateEmailKeywords sends the explicit accountId', async () => {
const client = createClient();
const { captured } = mockEmailSet();
await client.updateEmailKeywords('email-x', { '$label:work': true }, 'shared-account');
expect(captured[0][0]).toBe('Email/set');
expect(captured[0][1].accountId).toBe('shared-account');
});
it('updateEmailKeywords falls back to the primary account when none is given', async () => {
const client = createClient();
const { captured } = mockEmailSet();
await client.updateEmailKeywords('email-x', { '$label:work': true });
expect(captured[0][1].accountId).toBe('primary-account');
});
it('setKeyword sends the explicit accountId', async () => {
const client = createClient();
const { captured } = mockEmailSet();
await client.setKeyword('email-x', '$answered', 'shared-account');
expect(captured[0][0]).toBe('Email/set');
expect(captured[0][1].accountId).toBe('shared-account');
});
it('setKeyword falls back to the primary account when none is given', async () => {
const client = createClient();
const { captured } = mockEmailSet();
await client.setKeyword('email-x', '$answered');
expect(captured[0][1].accountId).toBe('primary-account');
});
});
+74 -3
View File
@@ -55,7 +55,7 @@ interface CapturedRequest {
* Mailbox/get → Identity/get → Email/set + EmailSubmission/set.
* Returns the captured request bodies for assertions.
*/
function mockSendEmailFlow() {
function mockSendEmailFlow(draftsId = 'mb-drafts', sentId = 'mb-sent') {
const captured: CapturedRequest[] = [];
const fetchSpy = vi.spyOn(globalThis, 'fetch');
@@ -71,8 +71,8 @@ function mockSendEmailFlow() {
'Mailbox/get',
{
list: [
{ id: 'mb-drafts', name: 'Drafts', role: 'drafts' },
{ id: 'mb-sent', name: 'Sent', role: 'sent' },
{ id: draftsId, name: 'Drafts', role: 'drafts' },
{ id: sentId, name: 'Sent', role: 'sent' },
],
},
'0',
@@ -264,3 +264,74 @@ describe('JMAPClient.sendEmail threading headers', () => {
]));
});
});
describe('JMAPClient post-send mailbox filing', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
function sentFilingPatch(captured: CapturedRequest[]): Record<string, unknown> {
const submissionCall = captured[2].methodCalls.find(call => call[0] === 'EmailSubmission/set');
expect(submissionCall).toBeDefined();
const onSuccess = (submissionCall![1] as {
onSuccessUpdateEmail: Record<string, Record<string, unknown>>;
}).onSuccessUpdateEmail;
return Object.values(onSuccess)[0];
}
it('files the sent message via a full mailboxIds replacement, never mailboxIds/<id> pointers', async () => {
const client = createClient();
const captured = mockSendEmailFlow();
await client.sendEmail(
['recipient@example.com'], 'subject', 'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const patch = sentFilingPatch(captured);
// A `mailboxIds/<id>` JSON-pointer whose token is purely numeric (e.g. a
// Drafts folder whose JMAP id is "0") is rejected by Stalwart, silently
// stranding already-delivered mail in Drafts. The move must use a full
// `mailboxIds` replacement, which has no per-id pointer token.
expect(Object.keys(patch).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(patch.mailboxIds).toEqual({ 'mb-sent': true });
expect(patch['keywords/$draft']).toBeNull();
});
it('files correctly when the Drafts mailbox id is a purely numeric string (Stalwart numeric-id bug)', async () => {
const client = createClient();
// Drafts id "0", Sent id "e": the old pointer form emitted `mailboxIds/0`,
// which Stalwart rejects with invalidProperties "Invalid patch value".
const captured = mockSendEmailFlow('0', 'e');
await client.sendEmail(
['recipient@example.com'], 'subject', 'body',
undefined, undefined, 'identity-1', 'user@example.com',
);
const patch = sentFilingPatch(captured);
expect(Object.keys(patch).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(patch.mailboxIds).toEqual({ e: true });
});
it('restoreEmailToDraft places the message in Drafts only via a full mailboxIds replacement', async () => {
const client = createClient();
let capturedUpdate: Record<string, unknown> | undefined;
vi.spyOn(client as unknown as { request: JMAPClient['request'] }, 'request')
.mockImplementation(async (methodCalls) => {
const args = methodCalls[0][1] as { update?: Record<string, Record<string, unknown>> };
capturedUpdate = args.update?.['email-1'];
return { methodResponses: [['Email/set', { updated: { 'email-1': null } }, '0']] };
});
// Third arg (Sent mailbox id) is intentionally ignored — the message must
// end up in Drafts only, with no leftover Sent membership. Drafts id "0"
// also exercises the numeric-id path in the reverse direction.
await client.restoreEmailToDraft('email-1', '0', 'e');
expect(capturedUpdate).toBeDefined();
expect(Object.keys(capturedUpdate!).some(key => key.startsWith('mailboxIds/'))).toBe(false);
expect(capturedUpdate!.mailboxIds).toEqual({ '0': true });
expect(capturedUpdate!['keywords/$draft']).toBe(true);
});
});
+80
View File
@@ -0,0 +1,80 @@
import { describe, it, expect, afterEach } from 'vitest';
import { isEditableEventTarget } from '../keyboard';
// Regression tests for #654: global single-key shortcuts fired while the user
// was editing inside the QuotedHtml shadow-DOM island, because the shadow
// boundary retargets both document.activeElement and event.target to the
// plain-div host. isEditableEventTarget must rely on composedPath instead.
// Evaluate from a window-level listener DURING dispatch — composedPath() is
// only populated while the event is being dispatched, matching how the real
// shortcut handlers run.
function dispatchAndCheck(el: HTMLElement): { editable: boolean; target: EventTarget | null } {
let result: { editable: boolean; target: EventTarget | null } | null = null;
const listener = (e: Event) => {
result = { editable: isEditableEventTarget(e), target: e.target };
};
window.addEventListener('keydown', listener);
el.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, composed: true })
);
window.removeEventListener('keydown', listener);
if (!result) throw new Error('keydown never reached window');
return result;
}
describe('isEditableEventTarget', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('detects plain inputs and textareas', () => {
for (const tag of ['input', 'textarea', 'select'] as const) {
const el = document.createElement(tag);
document.body.appendChild(el);
expect(dispatchAndCheck(el).editable).toBe(true);
}
});
it('detects a contentEditable element', () => {
const el = document.createElement('div');
el.setAttribute('contenteditable', 'true');
document.body.appendChild(el);
expect(dispatchAndCheck(el).editable).toBe(true);
});
it('returns false for a non-editable element', () => {
const el = document.createElement('div');
document.body.appendChild(el);
expect(dispatchAndCheck(el).editable).toBe(false);
});
it('sees through a shadow boundary to an inner contentEditable (QuotedHtml island)', () => {
// Mirror the structure quoted-html.ts builds: plain-div host, open shadow
// root, inner contentEditable div.
const host = document.createElement('div');
host.className = 'quoted-html-island';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('div');
inner.setAttribute('contenteditable', 'true');
shadow.appendChild(inner);
const { editable, target } = dispatchAndCheck(inner);
// Sanity: the shadow boundary retargets the event — the outside listener
// sees the host, which is exactly why a target/activeElement check fails.
expect(target).toBe(host);
expect(editable).toBe(true);
});
it('still returns false for a non-editable shadow tree', () => {
const host = document.createElement('div');
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const inner = document.createElement('div');
shadow.appendChild(inner);
expect(dispatchAndCheck(inner).editable).toBe(false);
});
});
+83 -2
View File
@@ -46,7 +46,8 @@ describe('oauth/discovery', () => {
expect(result).toEqual(VALID_METADATA);
expect(fetch).toHaveBeenCalledTimes(1);
expect(fetch).toHaveBeenCalledWith(
'https://mail.example.com/.well-known/oauth-authorization-server'
'https://mail.example.com/.well-known/oauth-authorization-server',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
@@ -64,7 +65,8 @@ describe('oauth/discovery', () => {
expect(fetch).toHaveBeenCalledTimes(2);
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://fallback.example.com/.well-known/openid-configuration'
'https://fallback.example.com/.well-known/openid-configuration',
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
@@ -176,4 +178,83 @@ describe('oauth/discovery', () => {
expect(second).toEqual(VALID_METADATA);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('bounds each discovery fetch with an AbortSignal timeout (no hang on unresponsive IdP)', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const fetchMock = vi.fn().mockRejectedValue(
Object.assign(new Error('The operation timed out'), { name: 'TimeoutError' }),
);
vi.stubGlobal('fetch', fetchMock);
const result = await discoverOAuth('https://unresponsive.example.com', { validateEndpoint });
expect(result).toBeNull();
// Every discovery fetch must carry an AbortSignal so an unresponsive IdP is
// aborted (DISCOVERY_TIMEOUT_MS) instead of hanging the request - and, with
// it, the login page's SSO button.
expect(fetchMock.mock.calls.length).toBeGreaterThan(0);
for (const call of fetchMock.mock.calls) {
expect(call[1]).toEqual(expect.objectContaining({ signal: expect.any(AbortSignal) }));
}
});
it('retries once when the first attempt fails, then succeeds', async () => {
// Attempt 1: both well-known URLs fail. Attempt 2: first URL succeeds.
vi.stubGlobal('fetch', vi.fn()
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce({ ok: false, status: 503 })
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve(VALID_METADATA) }));
const result = await discoverOAuth('https://flaky.example.com', { validateEndpoint });
expect(result).toEqual(VALID_METADATA);
// 2 failures (attempt 1) + 1 success (attempt 2 retry).
expect(fetch).toHaveBeenCalledTimes(3);
});
it('serves stale cached metadata when a refresh fails (keeps the SSO button up)', async () => {
vi.useFakeTimers();
try {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// First call succeeds and caches the metadata.
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve(VALID_METADATA),
}));
const first = await discoverOAuth('https://stale.example.com', { validateEndpoint });
expect(first).toEqual(VALID_METADATA);
// Expire the cache (positive TTL is 10 min).
vi.advanceTimersByTime(10 * 60 * 1000 + 1);
// Refresh now fails on every URL/attempt: the stale-but-usable value must
// be returned instead of null so the SSO button keeps rendering.
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network down')));
const pending = discoverOAuth('https://stale.example.com', { validateEndpoint });
await vi.advanceTimersByTimeAsync(1000); // fire the retry backoff timer
const second = await pending;
expect(second).toEqual(VALID_METADATA);
expect(warnSpy).toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
it('negative-caches a total failure (no cached value) to avoid hammering the IdP', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {});
const fetchMock = vi.fn().mockRejectedValue(new Error('network down'));
vi.stubGlobal('fetch', fetchMock);
const first = await discoverOAuth('https://down.example.com', { validateEndpoint });
const callsAfterFirst = fetchMock.mock.calls.length;
const second = await discoverOAuth('https://down.example.com', { validateEndpoint });
expect(first).toBeNull();
expect(second).toBeNull();
// The immediate second call is short-circuited by the negative cache, so no
// additional fetches are made.
expect(fetchMock).toHaveBeenCalledTimes(callsAfterFirst);
});
});
+24 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import { findComposeIdentityId, findReplyIdentityId, resolveReplyFrom } from '../reply-identity';
import type { Identity } from '../jmap/types';
const identities: Identity[] = [
@@ -51,6 +51,29 @@ describe('findReplyIdentityId', () => {
});
});
describe('findComposeIdentityId', () => {
it('matches the identity of the active mailbox', () => {
expect(findComposeIdentityId(identities, 'harry@secondary.com')).toBe('secondary');
});
it('matches case-insensitively', () => {
expect(findComposeIdentityId(identities, 'HARRY@PRIMARY.COM')).toBe('primary');
});
it('strips +tag before matching', () => {
expect(findComposeIdentityId(identities, 'harry+news@secondary.com')).toBe('secondary');
});
it('returns null when the active mailbox has no matching identity', () => {
expect(findComposeIdentityId(identities, 'other@example.com')).toBeNull();
});
it('returns null when no active mailbox email is given', () => {
expect(findComposeIdentityId(identities, undefined)).toBeNull();
expect(findComposeIdentityId(identities, '')).toBeNull();
});
});
describe('resolveReplyFrom', () => {
it('returns the matching identity with no override when exact match', () => {
expect(resolveReplyFrom(identities, { to: [{ email: 'harry@secondary.com' }] }))
+136
View File
@@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { postJmap, rebaseApiUrl, fetchJmapSession, JmapRedirectError } from '@/lib/stalwart/jmap-api';
const realFetch = global.fetch;
const mockedFetch = vi.fn();
beforeEach(() => {
mockedFetch.mockReset();
global.fetch = mockedFetch as unknown as typeof fetch;
});
afterEach(() => {
global.fetch = realFetch;
});
function response(status: number, body = '{}', headers: Record<string, string> = {}): Response {
return new Response(status >= 300 && status < 400 ? null : body, { status, headers });
}
describe('postJmap', () => {
it('POSTs the body with auth header and manual redirect mode', async () => {
mockedFetch.mockResolvedValueOnce(response(200, '{"methodResponses":[]}'));
const res = await postJmap('https://mail.example.com/jmap/', 'Basic abc', '{"using":[]}');
expect(res.status).toBe(200);
const [url, init] = mockedFetch.mock.calls[0];
expect(url.toString()).toBe('https://mail.example.com/jmap/');
expect(init.method).toBe('POST');
expect(init.redirect).toBe('manual');
expect(init.body).toBe('{"using":[]}');
expect(init.headers['Authorization']).toBe('Basic abc');
});
it('re-POSTs (not GETs) across an https upgrade redirect', async () => {
mockedFetch
.mockResolvedValueOnce(response(301, '', { location: 'https://mail.example.com/jmap/' }))
.mockResolvedValueOnce(response(200));
const res = await postJmap('http://mail.example.com/jmap/', 'Basic abc', '{}');
expect(res.status).toBe(200);
expect(mockedFetch).toHaveBeenCalledTimes(2);
const [url, init] = mockedFetch.mock.calls[1];
expect(url.toString()).toBe('https://mail.example.com/jmap/');
expect(init.method).toBe('POST');
expect(init.body).toBe('{}');
});
it('follows same-host path redirects (trailing slash normalization)', async () => {
mockedFetch
.mockResolvedValueOnce(response(308, '', { location: '/jmap/' }))
.mockResolvedValueOnce(response(200));
const res = await postJmap('https://mail.example.com/jmap', 'Basic abc', '{}');
expect(res.status).toBe(200);
expect(mockedFetch.mock.calls[1][0].toString()).toBe('https://mail.example.com/jmap/');
});
it('refuses redirects to a different host', async () => {
mockedFetch.mockResolvedValueOnce(
response(302, '', { location: 'https://evil.example.net/jmap/' }),
);
await expect(postJmap('https://mail.example.com/jmap/', 'Basic abc', '{}'))
.rejects.toBeInstanceOf(JmapRedirectError);
expect(mockedFetch).toHaveBeenCalledTimes(1);
});
it('gives up after too many redirects', async () => {
mockedFetch.mockResolvedValue(
response(302, '', { location: 'https://mail.example.com/jmap/' }),
);
await expect(postJmap('https://mail.example.com/jmap/', 'Basic abc', '{}'))
.rejects.toThrow('Too many redirects');
});
it('returns non-redirect error responses as-is', async () => {
mockedFetch.mockResolvedValueOnce(response(401));
const res = await postJmap('https://mail.example.com/jmap/', 'Basic abc', '{}');
expect(res.status).toBe(401);
});
});
describe('rebaseApiUrl', () => {
it('keeps the advertised path but swaps to the reachable origin', () => {
const session = { apiUrl: 'https://public.example.org/prefix/jmap/', primaryAccounts: {} };
expect(rebaseApiUrl(session, 'https://internal.example.com'))
.toBe('https://internal.example.com/prefix/jmap/');
});
it('resolves relative apiUrl against serverUrl', () => {
const session = { apiUrl: '/jmap/', primaryAccounts: {} };
expect(rebaseApiUrl(session, 'https://mail.example.com'))
.toBe('https://mail.example.com/jmap/');
});
it('returns null when the session has no apiUrl', () => {
expect(rebaseApiUrl({ primaryAccounts: {} }, 'https://mail.example.com')).toBeNull();
expect(rebaseApiUrl(null, 'https://mail.example.com')).toBeNull();
});
});
describe('fetchJmapSession', () => {
it('prefers the canonical /jmap/session endpoint', async () => {
mockedFetch.mockResolvedValueOnce(
response(200, JSON.stringify({ apiUrl: '/jmap/', primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a' } })),
);
const session = await fetchJmapSession('https://mail.example.com', 'Basic abc');
expect(session?.apiUrl).toBe('/jmap/');
expect(mockedFetch.mock.calls[0][0]).toBe('https://mail.example.com/jmap/session');
});
it('falls back to /.well-known/jmap when the canonical path 404s', async () => {
mockedFetch
.mockResolvedValueOnce(response(404))
.mockResolvedValueOnce(
response(200, JSON.stringify({ apiUrl: '/api/jmap/', primaryAccounts: {} })),
);
const session = await fetchJmapSession('https://mail.example.com', 'Basic abc');
expect(session?.apiUrl).toBe('/api/jmap/');
expect(mockedFetch.mock.calls[1][0]).toBe('https://mail.example.com/.well-known/jmap');
});
it('returns null when no candidate yields a session', async () => {
mockedFetch.mockResolvedValue(response(404));
expect(await fetchJmapSession('https://mail.example.com', 'Basic abc')).toBeNull();
});
});
+38
View File
@@ -10,6 +10,7 @@ import {
filterTemplates,
exportTemplates,
importTemplates,
spliceTemplateAboveSignature,
} from '../template-utils';
import type { EmailTemplate } from '../template-types';
@@ -19,6 +20,7 @@ function makeTemplate(overrides: Partial<EmailTemplate> = {}): EmailTemplate {
name: 'Test Template',
subject: '',
body: '',
isHTML: false,
category: '',
isFavorite: false,
createdAt: '2026-01-01T00:00:00Z',
@@ -333,3 +335,39 @@ describe('filterTemplates', () => {
expect(filterTemplates(templates, 'xyz')).toHaveLength(0);
});
});
describe('spliceTemplateAboveSignature', () => {
const template = '<p>Template body</p>';
it('keeps the signature block below the template (separator marker)', () => {
const prev = '<p></p><p data-signature-block="separator">-- </p><div>My signature</div><p data-signature-block="end"></p>';
expect(spliceTemplateAboveSignature(prev, template)).toBe(
'<p>Template body</p><p data-signature-block="separator">-- </p><div>My signature</div><p data-signature-block="end"></p>'
);
});
it('keeps the signature block below the template (start marker, no separator)', () => {
const prev = '<p>old draft text</p><p data-signature-block="start"></p><div>My signature</div><p data-signature-block="end"></p>';
expect(spliceTemplateAboveSignature(prev, template)).toBe(
'<p>Template body</p><p data-signature-block="start"></p><div>My signature</div><p data-signature-block="end"></p>'
);
});
it('replaces the whole body when there is no signature block', () => {
expect(spliceTemplateAboveSignature('<p>old draft text</p>', template)).toBe(template);
});
it('keeps everything from the start marker onward when the end marker is missing', () => {
const prev = '<p>old</p><p data-signature-block="separator">-- </p><div>My signature</div>';
expect(spliceTemplateAboveSignature(prev, template)).toBe(
'<p>Template body</p><p data-signature-block="separator">-- </p><div>My signature</div>'
);
});
it('discards user edits above the signature', () => {
const prev = '<p>half-written draft</p><p data-signature-block="separator">-- </p><div>Sig</div><p data-signature-block="end"></p>';
const result = spliceTemplateAboveSignature(prev, template);
expect(result).not.toContain('half-written draft');
expect(result).toContain('Sig');
});
});
+37 -25
View File
@@ -116,37 +116,47 @@ describe('groupEmailsByThread', () => {
});
describe('sortThreadGroups', () => {
const makeGroup = (threadId: string, receivedAt: string, hasPinned = false): ThreadGroup => ({
threadId,
emails: [makeEmail({ receivedAt })],
latestEmail: makeEmail({ receivedAt }),
participantNames: ['A'],
hasUnread: false,
hasStarred: false,
hasPinned,
hasAttachment: false,
hasAnswered: false,
hasForwarded: false,
emailCount: 1,
});
it('sorts groups by latestEmail.receivedAt descending', () => {
const groups: ThreadGroup[] = [
{
threadId: 'old',
emails: [makeEmail({ receivedAt: '2024-01-01T00:00:00Z' })],
latestEmail: makeEmail({ receivedAt: '2024-01-01T00:00:00Z' }),
participantNames: ['A'],
hasUnread: false,
hasStarred: false,
hasAttachment: false,
hasAnswered: false,
hasForwarded: false,
emailCount: 1,
},
{
threadId: 'new',
emails: [makeEmail({ receivedAt: '2024-06-01T00:00:00Z' })],
latestEmail: makeEmail({ receivedAt: '2024-06-01T00:00:00Z' }),
participantNames: ['B'],
hasUnread: false,
hasStarred: false,
hasAttachment: false,
hasAnswered: false,
hasForwarded: false,
emailCount: 1,
},
const groups = [
makeGroup('old', '2024-01-01T00:00:00Z'),
makeGroup('new', '2024-06-01T00:00:00Z'),
];
const sorted = sortThreadGroups(groups);
expect(sorted[0].threadId).toBe('new');
expect(sorted[1].threadId).toBe('old');
});
it('keeps pinned threads on top regardless of date', () => {
const groups = [
makeGroup('newest', '2024-06-01T00:00:00Z'),
makeGroup('old-pinned', '2024-01-01T00:00:00Z', true),
makeGroup('mid', '2024-03-01T00:00:00Z'),
];
const sorted = sortThreadGroups(groups);
expect(sorted.map(g => g.threadId)).toEqual(['old-pinned', 'newest', 'mid']);
});
it('detects hasPinned from the $pinned keyword', () => {
const emails = [
makeEmail({ id: 'e1', keywords: { $seen: true } }),
makeEmail({ id: 'e2', keywords: { $seen: true, '$pinned': true } }),
];
expect(groupEmailsByThread(emails)[0].hasPinned).toBe(true);
});
});
describe('getThreadParticipants', () => {
@@ -188,6 +198,7 @@ describe('mergeThreadEmails', () => {
participantNames: ['Alice'],
hasUnread: false,
hasStarred: false,
hasPinned: false,
hasAttachment: false,
hasAnswered: false,
hasForwarded: false,
@@ -210,6 +221,7 @@ describe('mergeThreadEmails', () => {
participantNames: ['Alice'],
hasUnread: false,
hasStarred: false,
hasPinned: false,
hasAttachment: false,
hasAnswered: false,
hasForwarded: false,
@@ -6,6 +6,7 @@ import {
buildCrossFilter,
getCrossUnreadTotal,
fetchCrossViewEmails,
advancedSearchCrossViewEmails,
resolveSourceFolderName,
type UnifiedAccountClient,
} from '@/lib/unified-mailbox';
@@ -42,6 +43,26 @@ describe('getCrossIncludedMailboxes', () => {
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
expect(ids).toEqual(['inbox', 'projects']);
});
it('honors an explicit crossIncludedMailboxIds selection (folder picker)', () => {
const account = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined), mb('archive', 'archive')],
// user picked inbox + archive, excluded projects - overrides role exclusion
crossIncludedMailboxIds: ['inbox', 'archive'],
});
const ids = getCrossIncludedMailboxes(account).map((m) => m.id);
expect(ids).toEqual(['inbox', 'archive']);
});
it('an empty selection yields no folders', () => {
const account = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox'), mb('projects', undefined)],
crossIncludedMailboxIds: [],
});
expect(getCrossIncludedMailboxes(account)).toEqual([]);
});
});
describe('buildCrossFilter', () => {
@@ -86,6 +107,22 @@ describe('getCrossUnreadTotal', () => {
});
expect(getCrossUnreadTotal([a, b])).toBe(10);
});
it('counts only the selected folders when crossIncludedMailboxIds is set; shared accounts stay unrestricted', () => {
// personal account narrowed to inbox only (projects excluded by the picker)
const personal = makeAccount({
accountId: 'a',
mailboxes: [mb('inbox', 'inbox', 3), mb('projects', undefined, 4)],
crossIncludedMailboxIds: ['inbox'],
});
// shared account unrestricted -> role-exclusion default (inbox + custom)
const shared = makeAccount({
accountId: 'owner',
isShared: true,
mailboxes: [mb('ns:inbox', 'inbox', 5, 'orig-inbox'), mb('ns:team', undefined, 2, 'orig-team'), mb('ns:junk', 'junk', 9, 'orig-junk')],
});
expect(getCrossUnreadTotal([personal, shared])).toBe(3 + 5 + 2);
});
});
describe('resolveSourceFolderName', () => {
@@ -167,3 +204,28 @@ describe('fetchCrossViewEmails', () => {
expect(result.errors.get('bad')).toBe('boom');
});
});
describe('advancedSearchCrossViewEmails', () => {
it('ANDs the advanced filter onto the cross-view membership', async () => {
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
await advancedSearchCrossViewEmails([a], 'all', { hasKeyword: '$flagged' }, 50, 0);
const [filter] = advancedSearchEmails.mock.calls[0];
expect(filter).toEqual({
operator: 'AND',
conditions: [{ inMailbox: 'inbox' }, { hasKeyword: '$flagged' }],
});
});
it('uses only the membership filter when the extra filter is empty', async () => {
const advancedSearchEmails = vi.fn().mockResolvedValue({ emails: [], total: 0, hasMore: false });
const a = makeAccount({ accountId: 'a', mailboxes: [mb('inbox', 'inbox')] }, { advancedSearchEmails });
await advancedSearchCrossViewEmails([a], 'all', {}, 50, 0);
const [filter] = advancedSearchEmails.mock.calls[0];
expect(filter).toEqual({ inMailbox: 'inbox' });
});
});
+31
View File
@@ -5,6 +5,7 @@ import {
getEmailValidationError,
isValidUnsubscribeUrl,
parseUnsubscribeUrls,
parseMailtoUrl,
} from '../validation';
describe('validation', () => {
@@ -359,3 +360,33 @@ describe('validation', () => {
});
});
});
describe('parseMailtoUrl', () => {
it('parses address, subject and body', () => {
const r = parseMailtoUrl('mailto:list@example.com?subject=Unsubscribe%20123&body=Please%20remove');
expect(r).toEqual({ to: ['list@example.com'], subject: 'Unsubscribe 123', body: 'Please remove' });
});
it('keeps a literal plus (RFC 6068 uses percent-encoding only)', () => {
const r = parseMailtoUrl('mailto:owner+unsub@example.com?subject=a+b');
expect(r?.to).toEqual(['owner+unsub@example.com']);
expect(r?.subject).toBe('a+b');
});
it('supports multiple recipients and the to param', () => {
const r = parseMailtoUrl('mailto:a@example.com,b@example.com?to=c@example.com');
expect(r?.to).toEqual(['a@example.com', 'b@example.com', 'c@example.com']);
});
it('returns null without a valid recipient', () => {
expect(parseMailtoUrl('mailto:?subject=x')).toBeNull();
expect(parseMailtoUrl('mailto:not-an-address')).toBeNull();
expect(parseMailtoUrl('https://example.com/unsub')).toBeNull();
});
it('survives malformed percent-encoding', () => {
const r = parseMailtoUrl('mailto:list@example.com?subject=%E0%A4%A');
expect(r?.to).toEqual(['list@example.com']);
expect(r?.subject).toBe('%E0%A4%A');
});
});
+38
View File
@@ -102,3 +102,41 @@ export function isHttp2Available(): boolean {
export function getMaxAccounts(): number {
return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1;
}
/** Minimal shape needed to order accounts (structural — avoids importing AccountEntry). */
export interface OrderableAccount {
id: string;
isDefault: boolean;
}
/**
* Display order for the account switcher: the default account first, then the
* remaining accounts in their stored order. Pure — does not mutate the input.
*/
export function sortDefaultFirst<T extends OrderableAccount>(accounts: T[]): T[] {
const defaults = accounts.filter((a) => a.isDefault);
const rest = accounts.filter((a) => !a.isDefault);
return [...defaults, ...rest];
}
/**
* Compute the new full account-id order after dragging `dragId` onto `overId`.
* Default account(s) stay pinned to the front; only non-default accounts are
* reordered (`dragId` is inserted at `overId`'s position among them).
* Returns null when the move is a no-op or invalid (e.g. a default is involved).
*/
export function reorderNonDefaultIds(
accounts: OrderableAccount[],
dragId: string,
overId: string,
): string[] | null {
if (dragId === overId) return null;
const defaults = accounts.filter((a) => a.isDefault).map((a) => a.id);
const nonDefault = accounts.filter((a) => !a.isDefault).map((a) => a.id);
const from = nonDefault.indexOf(dragId);
const to = nonDefault.indexOf(overId);
if (from < 0 || to < 0) return null;
const [moved] = nonDefault.splice(from, 1);
nonDefault.splice(to, 0, moved);
return [...defaults, ...nonDefault];
}
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { migratePolicyUnifiedMailbox } from '../migrate';
// migratePolicyUnifiedMailbox reads ADMIN_CONFIG_DIR at call time (see paths.ts),
// so each test points it at a fresh temp dir.
let dir: string;
const policyPath = () => path.join(dir, 'policy.json');
const markerPath = () => path.join(dir, '.migrated-unified-mailbox');
const writePolicy = (features: Record<string, unknown>) =>
writeFile(policyPath(), JSON.stringify({ features, restrictions: {} }, null, 2), 'utf-8');
const readFeatures = async () =>
JSON.parse(await readFile(policyPath(), 'utf-8')).features as Record<string, unknown>;
beforeEach(async () => {
dir = await mkdtemp(path.join(tmpdir(), 'bw-policy-'));
process.env.ADMIN_CONFIG_DIR = dir;
});
afterEach(async () => {
delete process.env.ADMIN_CONFIG_DIR;
await rm(dir, { recursive: true, force: true });
});
describe('migratePolicyUnifiedMailbox', () => {
it('enables unifiedCrossAccountEnabled when a cross view was active', async () => {
await writePolicy({ crossUnreadViewEnabled: true });
await migratePolicyUnifiedMailbox();
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(true);
expect(existsSync(markerPath())).toBe(true);
});
it('does not enable it for a standalone All-Mail-only policy', async () => {
await writePolicy({ allMailViewEnabled: true, crossUnreadViewEnabled: false, crossStarredViewEnabled: false, crossAllViewEnabled: false });
await migratePolicyUnifiedMailbox();
expect((await readFeatures()).unifiedCrossAccountEnabled).toBeUndefined();
});
it('is a one-shot: a later admin disable survives a re-run', async () => {
await writePolicy({ crossAllViewEnabled: true });
await migratePolicyUnifiedMailbox();
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(true);
// Admin turns it back off; the marker is present, so re-running is a no-op.
await writePolicy({ crossAllViewEnabled: true, unifiedCrossAccountEnabled: false });
await migratePolicyUnifiedMailbox();
expect((await readFeatures()).unifiedCrossAccountEnabled).toBe(false);
});
it('no policy.json: writes the marker and does not throw', async () => {
await migratePolicyUnifiedMailbox();
expect(existsSync(markerPath())).toBe(true);
expect(existsSync(policyPath())).toBe(false);
});
});
+17 -4
View File
@@ -33,12 +33,12 @@ class ConfigManager {
this.adminConfig = await this.readJsonFile('config.json') || {};
const policy = await this.readJsonFile('policy.json');
if (policy) {
this.policyCache = {
this.policyCache = ConfigManager.normalizePolicy({
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
});
} else {
this.policyCache = { ...DEFAULT_POLICY };
}
@@ -166,15 +166,28 @@ class ConfigManager {
*/
async setPolicy(policy: SettingsPolicy): Promise<void> {
assertWritable('update settings policy');
this.policyCache = {
this.policyCache = ConfigManager.normalizePolicy({
...DEFAULT_POLICY,
...policy,
features: { ...DEFAULT_FEATURE_GATES, ...(policy.features || {}) },
themePolicy: { ...DEFAULT_THEME_POLICY, ...(policy.themePolicy || {}) },
};
});
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
}
/**
* Migrates deprecated feature gates forward. The standalone "All Mail" view
* (`allMailViewEnabled`) was folded into the unified "All mail" entry, so an
* admin who enabled it keeps that entry available via `crossAllViewEnabled`.
* Idempotent - safe to run on every load.
*/
private static normalizePolicy(policy: SettingsPolicy): SettingsPolicy {
if (policy.features.allMailViewEnabled) {
policy.features.crossAllViewEnabled = true;
}
return policy;
}
/**
* Reload config from disk (for manual file edits or multi-instance).
*/
+9 -1
View File
@@ -113,7 +113,15 @@ type HeadersLike = Headers | { get(name: string): string | null };
* host header is set.
*/
export function pickRequestHost(headersOrReq: NextRequest | HeadersLike): string | null {
const headers: HeadersLike = 'headers' in headersOrReq ? (headersOrReq as NextRequest).headers : headersOrReq;
// A Headers / ReadonlyHeaders exposes `.get` directly; a NextRequest carries
// its headers under `.headers`. Discriminate on the callable `.get` rather
// than the presence of a `headers` property, since ReadonlyHeaders (returned
// by `await headers()`) also has an internal `headers` field (#585).
const candidate = headersOrReq as { get?: unknown };
const headers: HeadersLike =
typeof candidate.get === 'function'
? (headersOrReq as HeadersLike)
: (headersOrReq as NextRequest).headers;
const raw = headers.get('x-forwarded-host') || headers.get('host');
if (!raw) return null;
const first = raw.split(',')[0]?.trim();
+60
View File
@@ -11,6 +11,7 @@ import {
import type { AdminConfigData, AdminStateData } from './types';
const MIGRATION_MARKER = '.migrated-v2';
const POLICY_UNIFIED_MARKER = '.migrated-unified-mailbox';
interface LegacyAdminData {
passwordHash: string;
@@ -59,6 +60,65 @@ export async function migrateLegacyAdminLayout(): Promise<void> {
}
}
/**
* One-shot policy migration for the Unified Mailbox rework. Before it, the
* cross views (crossUnread/crossStarred/crossAll) merged across every logged-in
* account, so an admin who had any of them enabled was already permitting
* cross-account aggregation. The new `unifiedCrossAccountEnabled` gate (default
* false) controls that capability, so enable it whenever a cross view was active
* - otherwise existing cross-account installs would silently lose the behaviour
* on upgrade (the per-user `unifiedCrossAccount` is AND-ed with this gate).
*
* Persisted + marker-guarded (not a per-load normalization) so a later admin
* decision to disable the gate survives restarts. Skipped on read-only config
* dirs - operators who locked their config must migrate manually (mirrors
* migrateLegacyAdminLayout). The deprecated `allMailViewEnabled` (a single-account
* view, never cross-account) deliberately does NOT trigger this.
*/
export async function migratePolicyUnifiedMailbox(): Promise<void> {
if (isConfigReadOnly()) return;
const markerPath = getConfigPath(POLICY_UNIFIED_MARKER);
if (existsSync(markerPath)) return;
try {
const policyPath = getConfigPath('policy.json');
if (existsSync(policyPath)) {
let parsed: Record<string, unknown> | null = null;
try {
parsed = JSON.parse(await readFile(policyPath, 'utf-8')) as Record<string, unknown>;
} catch {
logger.warn('policy.json is not valid JSON; skipping Unified Mailbox policy migration');
}
const features =
parsed && typeof parsed.features === 'object' && parsed.features
? (parsed.features as Record<string, unknown>)
: null;
if (features) {
const hadCrossAccount = !!(
features.crossUnreadViewEnabled ||
features.crossStarredViewEnabled ||
features.crossAllViewEnabled
);
if (hadCrossAccount && features.unifiedCrossAccountEnabled !== true) {
features.unifiedCrossAccountEnabled = true;
const tmp = policyPath + '.tmp';
await writeFile(tmp, JSON.stringify(parsed, null, 2), 'utf-8');
await rename(tmp, policyPath);
logger.info('Migrated policy: enabled unifiedCrossAccountEnabled (cross-account views were active)');
}
}
}
await ensureConfigDir();
await writeFile(markerPath, new Date().toISOString(), 'utf-8');
} catch (error) {
logger.warn('Unified Mailbox policy 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.
+12
View File
@@ -60,10 +60,12 @@ export interface FeatureGates {
hoverActionsConfigEnabled: boolean;
filesEnabled: boolean;
contactsEnabled: boolean;
/** @deprecated Folded into `crossAllViewEnabled`; normalized forward on policy load. */
allMailViewEnabled: boolean;
crossUnreadViewEnabled: boolean;
crossStarredViewEnabled: boolean;
crossAllViewEnabled: boolean;
unifiedCrossAccountEnabled: boolean;
}
export const DEFAULT_FEATURE_GATES: FeatureGates = {
@@ -89,6 +91,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = {
crossUnreadViewEnabled: false,
crossStarredViewEnabled: false,
crossAllViewEnabled: false,
unifiedCrossAccountEnabled: false,
};
export interface ThemePolicy {
@@ -166,6 +169,15 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
loginImprintUrl: { envVar: 'LOGIN_IMPRINT_URL', type: 'url', defaultValue: '' },
loginPrivacyPolicyUrl: { envVar: 'LOGIN_PRIVACY_POLICY_URL', type: 'url', defaultValue: '' },
loginWebsiteUrl: { envVar: 'LOGIN_WEBSITE_URL', type: 'url', defaultValue: '' },
// Login header customization. The logo box is otherwise a fixed 64×64
// (w-16/h-16), which fits a wide wordmark to ~13px tall; set a max height
// and/or width (any CSS length, e.g. "230px" or "3rem") to size it. The
// heading ({appName}) and subtitle can be hidden when the logo already
// reads as the brand (e.g. a wordmark) and they'd be redundant.
loginLogoMaxHeight: { envVar: 'LOGIN_LOGO_MAX_HEIGHT', type: 'string', defaultValue: '' },
loginLogoMaxWidth: { envVar: 'LOGIN_LOGO_MAX_WIDTH', type: 'string', defaultValue: '' },
loginShowHeading: { envVar: 'LOGIN_SHOW_HEADING', type: 'boolean', defaultValue: true },
loginShowSubtitle: { envVar: 'LOGIN_SHOW_SUBTITLE', type: 'boolean', defaultValue: true },
// Hide the manual "I have a 2FA code" toggle on the login form. Deployments
// that delegate auth to an external directory (LDAP/OIDC) where 2FA lives in
// the IdP have no server-side TOTP, so the toggle only leads to a failed
+2
View File
@@ -103,6 +103,8 @@ export function getPendingAlerts(
const pending: PendingAlert[] = [];
for (const event of events) {
if (event.status === 'cancelled') continue;
const alerts = getEffectiveAlerts(event, calendars);
if (!alerts) continue;
+38 -7
View File
@@ -35,12 +35,39 @@ function participantMatchesEmail(p: CalendarParticipant, lowerEmails: string[]):
return false;
}
/**
* Collects the event-level organizer calendar address(es).
* Stalwart conveys the organizer via `organizerCalendarAddress` / `replyTo`
* rather than a participant `roles.owner` flag, so self-organized events
* imported from another server have no owner participant to match against.
*/
function getEventOrganizerEmails(event: CalendarEvent): string[] {
const emails: string[] = [];
if (event.organizerCalendarAddress) {
emails.push(event.organizerCalendarAddress.replace(/^mailto:/i, '').toLowerCase());
}
if (event.replyTo) {
for (const addr of Object.values(event.replyTo)) {
emails.push(addr.replace(/^mailto:/i, '').toLowerCase());
}
}
return emails.filter(Boolean);
}
export function isOrganizer(event: CalendarEvent, userEmails: string[]): boolean {
if (!event.participants) return false;
if (userEmails.length === 0) return false;
const lower = userEmails.map(e => e.toLowerCase());
return Object.values(event.participants).some(p =>
p.roles?.owner && participantMatchesEmail(p, lower)
);
if (event.participants) {
const ownerMatch = Object.values(event.participants).some(p =>
p.roles?.owner && participantMatchesEmail(p, lower)
);
if (ownerMatch) return true;
}
// Fall back to the event-level organizer address (Stalwart / imported events
// mark the organizer here instead of via a participant `owner` role).
return getEventOrganizerEmails(event).some(email => lower.includes(email));
}
export function getUserParticipantId(event: CalendarEvent, userEmails: string[]): string | null {
@@ -107,15 +134,20 @@ export function buildParticipantMap(
const generateId = () => generateUUID();
// calendarAddress is the scheduling address in draft-ietf-calext-jscalendarbis
// (implemented by Stalwart); the RFC 8984 sendTo property is retired there and
// stored as an inert JSPROP, so it is intentionally not sent.
participants[generateId()] = {
'@type': 'Participant',
name: organizer.name,
email: organizer.email,
calendarAddress: `mailto:${organizer.email}`,
roles: { owner: true, attendee: true },
// owner only, NOT attendee: with roles.attendee set, Stalwart's server-side
// scheduling emits the organizer as an ATTENDEE line in addition to the
// ORGANIZER line, so the recipient sees the organizer listed twice.
roles: { owner: true },
participationStatus: 'accepted',
scheduleAgent: 'server',
sendTo: { imip: `mailto:${organizer.email}` },
expectReply: false,
kind: 'individual',
};
@@ -129,7 +161,6 @@ export function buildParticipantMap(
roles: { attendee: true },
participationStatus: 'needs-action',
scheduleAgent: 'server',
sendTo: { imip: `mailto:${a.email}` },
expectReply: true,
kind: 'individual',
};
+45
View File
@@ -0,0 +1,45 @@
import { evictAll } from '@/lib/account-state-manager';
// localStorage keys holding server-derived, re-fetchable caches. The "Refresh
// cached data" action clears these so a stale or wrong-account view can be
// fixed WITHOUT signing out (which would drop the whole account list).
//
// Deliberately excluded:
// - 'account-registry' / 'auth-storage' → keep accounts + sessions
// - 'settings-storage' / 'theme-storage' / 'locale-storage' → user prefs
// - 'template-storage' / 'smime-preferences' → user-created content
const CACHE_STORAGE_KEYS = [
'identity-storage',
'contact-storage',
'calendar-storage',
'calendar-notification-storage',
];
/**
* Clear cached, server-derived data (contacts, calendars, identities, and the
* in-memory per-account snapshots), then reload so everything is re-fetched
* fresh for the active account. Accounts and sessions are preserved — this is
* the non-destructive alternative to the browser's "clear site data", which
* also wipes the account list.
*/
export function clearCachedData(): void {
// Drop the in-memory per-account store snapshots so a reload can't restore
// stale cached state for any account.
try {
evictAll();
} catch {
/* snapshots are best-effort */
}
if (typeof window === 'undefined') return;
for (const key of CACHE_STORAGE_KEYS) {
try {
window.localStorage.removeItem(key);
} catch {
/* ignore storage access errors */
}
}
window.location.reload();
}
+52 -5
View File
@@ -151,17 +151,34 @@ export class DemoJMAPClient implements IJMAPClient {
// ── Emails ────────────────────────────────────────────────────
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
async getEmails(mailboxId?: string, _accountId?: string, limit: number = 50, position: number = 0, _hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }> {
let filtered = this.data.emails;
if (mailboxId) {
filtered = filtered.filter(e => e.mailboxIds[mailboxId]);
}
filtered.sort((a, b) => new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime());
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
filtered.sort((a, b) =>
pinRank(b) - pinRank(a) ||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = filtered.length;
const emails = filtered.slice(position, position + limit);
return { emails, hasMore: position + limit < total, total };
}
async getSomeEmails(emailsId: string[], _accountId?: string): Promise<Email[]> {
if (!emailsId || emailsId.length === 0) {
return [];
}
const filtered = this.data.emails.filter(e => emailsId.includes(e.id));
filtered.sort((a, b) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
return filtered;
}
async getEmailsInMailbox(mailboxId: string): Promise<Email[]> {
return this.data.emails.filter(e => e.mailboxIds[mailboxId]);
}
@@ -209,6 +226,23 @@ export class DemoJMAPClient implements IJMAPClient {
return { emails, hasMore: position + limit < total, total };
}
async searchSentRecipients(query: string, _sentMailboxId: string, _accountId?: string, _limit: number = 60): Promise<Array<{ name: string; email: string }>> {
const q = query.trim().toLowerCase();
if (!q) return [];
const byEmail = new Map<string, { name: string; email: string }>();
for (const email of this.data.emails) {
for (const r of [...(email.to || []), ...(email.cc || [])]) {
if (!r.email) continue;
const key = r.email.toLowerCase();
if (byEmail.has(key)) continue;
if (key.includes(q) || (r.name && r.name.toLowerCase().includes(q))) {
byEmail.set(key, { name: r.name || '', email: r.email });
}
}
}
return Array.from(byEmail.values());
}
// ── Email mutations ───────────────────────────────────────────
async markAsRead(emailId: string, read: boolean = true): Promise<void> {
@@ -1001,6 +1035,18 @@ export class DemoJMAPClient implements IJMAPClient {
async importRawEmail(): Promise<string> { return generateDemoId('email'); }
async submitEmail(): Promise<void> { /* no-op */ }
async submitRawEmail(blob: Blob,
identityId: string,
delayedUntil?: string,
_envelopeRecipients?: string[],): Promise<SendEmailResult> {
const emailId = generateDemoId('email');
let emailSubmissionId: string | undefined;
if (delayedUntil) {
emailSubmissionId = generateDemoId('submission');
this.scheduledSubmissions.set(emailSubmissionId, { id: emailSubmissionId, emailId, identityId, sendAt: delayedUntil, undoStatus: 'pending', isSmime: true });
}
return delayedUntil ? { scheduled: true, emailId, emailSubmissionId, sendAt: delayedUntil, isSmime: true } : { scheduled: false, emailId, isSmime: true };
}
async sendRawEmail(_blob?: Blob, identityId = 'demo-identity', _sentMailboxId?: string, _draftMailboxId?: string, delayedUntil?: string, _envelopeRecipients?: string[]): Promise<SendEmailResult> {
const emailId = generateDemoId('email');
const draftsMailbox = this.data.mailboxes.find(m => m.role === 'drafts');
@@ -1062,11 +1108,12 @@ export class DemoJMAPClient implements IJMAPClient {
return { scheduled: true, emailId, emailSubmissionId: replacement, sendAt: delayedUntil };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
// Mirrors JMAPClient.restoreEmailToDraft: the third parameter is ignored and
// the message ends up in Drafts only (full mailboxIds replacement).
async restoreEmailToDraft(emailId: string, draftMailboxId: string, _sentMailboxId?: string): Promise<void> {
const email = this.data.emails.find(e => e.id === emailId);
if (!email) return;
email.mailboxIds[draftMailboxId] = true;
if (sentMailboxId) delete email.mailboxIds[sentMailboxId];
email.mailboxIds = { [draftMailboxId]: true };
email.keywords.$draft = true;
this.recalcMailboxCounts();
}
+235 -4
View File
@@ -1,4 +1,8 @@
import { isValidEmail } from "@/lib/validation";
import { htmlToPlainText } from "@/lib/html-to-text";
import { emailHooks } from "@/lib/plugin-hooks";
import { Ellipsis, Lock, TriangleAlert } from "lucide-react";
import type { Email } from "@/lib/jmap/types";
const HTML_ESCAPE_MAP = {
"&": "&amp;",
@@ -14,6 +18,41 @@ function escapeHtml(value: string): string {
);
}
/**
* Picks the plain-text and HTML bodies of an original message for seeding a
* reply/forward quote.
*
* Per RFC 8621 § 4.1.4 a message with only one body variant exposes that
* single part in BOTH `textBody` and `htmlBody`. So for an HTML-only message
* `textBody[0]` is the raw text/html source, and for a plain-text-only
* message `htmlBody[0]` is the text/plain part. Quoting either verbatim
* breaks the reply (#649): raw HTML tags end up in a plain-text quote, and
* plain text rendered as HTML collapses all newlines. Route by each part's
* actual MIME type instead: HTML listed under textBody is converted to
* readable text, and plain text listed under htmlBody is dropped so the
* composer's text path (escape + <br>) renders it.
*/
export function getQuoteBodies(
email: Pick<Email, "textBody" | "htmlBody" | "bodyValues" | "preview">
): { body: string; htmlBody?: string } {
const textPart = email.textBody?.[0];
const htmlPart = email.htmlBody?.[0];
const textValue = textPart ? email.bodyValues?.[textPart.partId]?.value : undefined;
const htmlValue = htmlPart ? email.bodyValues?.[htmlPart.partId]?.value : undefined;
const textPartIsHtml = textPart?.type?.toLowerCase() === "text/html";
// A missing type is treated as HTML, matching the viewer's rendering path.
const htmlPartIsHtml = !htmlPart?.type || htmlPart.type.toLowerCase() === "text/html";
const body = textValue
? (textPartIsHtml ? htmlToPlainText(textValue, { paragraphSpacing: true }) : textValue)
: (email.preview || "");
return {
body,
htmlBody: htmlPartIsHtml ? htmlValue || undefined : undefined,
};
}
export function plainTextToComposerBody(text: string): string {
if (!text) return "";
@@ -54,8 +93,92 @@ export function rewriteCidImagesForEditor(html: string): string {
return touched ? doc.body.innerHTML : html;
}
/** A composer recipient. Display name is optional; email is required. */
export type Recipient = { name?: string; email: string };
/**
* Reduce a composer body to just the user-authored text for the attachment
* reminder's keyword scan, dropping the quoted original of a reply/forward.
*
* Scanning the whole body triggered false positives whenever the quoted message
* mentioned an attachment - common, since the original often did carry one, and
* the default keyword list is broad and multilingual (#570). We strip:
* - HTML mode: the QuotedHtml island ([data-quoted-html]) and any <blockquote>
* (the wrapper used when the original had no HTML part), then convert to text.
* - Plain-text mode: lines prefixed with ">" (the reply quote).
* - Both modes: everything from the "Forwarded message" separator onward, which
* also removes the forwarded From/Date/Subject header lines and the bare
* forwarded original (which carries no blockquote/island wrapper).
*
* `forwardedSeparator` is the localized quote_header.forwarded_separator string;
* pass it so the forward cut works in the active locale.
*/
export function extractUserAuthoredText(
body: string,
options: { plainTextMode: boolean; forwardedSeparator?: string }
): string {
const { plainTextMode, forwardedSeparator } = options;
let text: string;
if (plainTextMode) {
text = body
.split("\n")
.filter((line) => !/^\s*>/.test(line))
.join("\n");
} else {
const doc = new DOMParser().parseFromString(`<body>${body}</body>`, "text/html");
doc
.querySelectorAll("[data-quoted-html], blockquote")
.forEach((el) => el.remove());
text = htmlToPlainText(doc.body.innerHTML, { paragraphSpacing: true });
}
// Cut everything from the forwarded-message separator onward. htmlToPlainText
// collapses the separator's internal whitespace, so match with a
// whitespace-flexible, regex-escaped pattern rather than an exact string.
const trimmedSeparator = forwardedSeparator?.trim();
if (trimmedSeparator) {
const pattern = trimmedSeparator
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
.replace(/\s+/g, "\\s+");
const match = text.match(new RegExp(pattern));
if (match && match.index !== undefined) {
text = text.slice(0, match.index);
}
}
return text;
}
/**
* Used for hook to let plugins enrich recipient chips with colors and icons.
* The icon is a key into ICON_MAP, which maps to a lucide-react component.
*/
export const ICON_MAP = {
'lock': Lock,
'triangle-alert': TriangleAlert,
'ellipsis': Ellipsis,
};
type IconName = keyof typeof ICON_MAP;
/**
* A composer recipient. Display name is optional; email is required - except
* for contact-group chips, which carry their already-resolved members and an
* empty email. Group chips are expanded into their members when the message
* is sent or saved as a draft (see {@link expandRecipients}).
*/
export type Recipient = {
name?: string;
email: string;
group?: { members: Array<{ name?: string; email: string }> };
extra?: {
color?: "success" | "destructive" | "warning"; // optional color for display purposes. May be populated by plugins via the onRecipientChipsChange hook.
icon?: IconName; // optional icon for display purposes. May be populated by plugins via the onRecipientChipsChange hook.
enriched?: boolean; // optional flag to indicate if the recipient has been enriched by plugins via the onRecipientChipsChange hook.
};
};
/** Enriches recipient chips with colors and icons. */
export async function enrichChipsWithColorsAndIcons(chips: Recipient[]): Promise<Recipient[]> {
return await emailHooks.onRecipientChipsChange.transform(chips);
};
/**
* Splits a recipient string into individual entries on any character in
@@ -72,6 +195,7 @@ export function splitRecipients(value: string, separators = ','): string[] {
let current = '';
let inQuotes = false;
let inAngle = false;
let inGroup = false;
for (const ch of value) {
if (ch === '"') {
inQuotes = !inQuotes;
@@ -82,7 +206,17 @@ export function splitRecipients(value: string, separators = ','): string[] {
} else if (ch === '>' && !inQuotes) {
inAngle = false;
current += ch;
} else if (separators.includes(ch) && !inQuotes && !inAngle) {
} else if (ch === ':' && !inQuotes && !inAngle) {
// RFC 5322 group syntax ("Team: a@x, b@y;") - keep the whole group,
// separators inside it included, as a single entry. A colon inside a
// display name is always quoted (see NAME_NEEDS_QUOTING), so a bare
// colon reliably opens a group.
inGroup = true;
current += ch;
} else if (ch === ';' && inGroup && !inQuotes && !inAngle) {
inGroup = false;
current += ch;
} else if (separators.includes(ch) && !inQuotes && !inAngle && !inGroup) {
const trimmed = current.trim();
if (trimmed) result.push(trimmed);
current = '';
@@ -122,12 +256,41 @@ function unquoteName(name: string): string {
return trimmed;
}
/** Index of the first colon outside quotes/angle brackets, or -1. */
function findTopLevelColon(value: string): number {
let inQuotes = false;
let inAngle = false;
for (let i = 0; i < value.length; i++) {
const ch = value[i];
if (ch === '"') inQuotes = !inQuotes;
else if (ch === '<' && !inQuotes) inAngle = true;
else if (ch === '>' && !inQuotes) inAngle = false;
else if (ch === ':' && !inQuotes && !inAngle) return i;
}
return -1;
}
/**
* Parses a single recipient string (`Name <email>`, `"Quoted, Name" <email>`,
* or bare `email`) into a {@link Recipient}. The display name is unquoted.
* RFC 5322 group syntax (`Team: a@x, b@y;`) parses into a group chip - it is
* how contact groups round-trip through the composer's string boundaries.
*/
export function parseRecipient(s: string): Recipient {
const trimmed = s.trim();
if (trimmed.endsWith(';')) {
const colon = findTopLevelColon(trimmed);
if (colon !== -1) {
const members = splitRecipients(trimmed.slice(colon + 1, -1))
.map(parseRecipient)
.filter((m) => m.email && !m.group);
// Only accept the group form when it actually carries members - typed
// garbage like "Subject: hello;" stays a plain (invalid) recipient.
if (members.length > 0) {
return { name: unquoteName(trimmed.slice(0, colon)), email: '', group: { members } };
}
}
}
const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
if (angleMatch) {
return { name: unquoteName(angleMatch[1]), email: angleMatch[2].trim() };
@@ -140,9 +303,46 @@ export function parseRecipientList(value: string): Recipient[] {
return splitRecipients(value).map(parseRecipient);
}
/**
* Formats a single composer recipient, using RFC 5322 group syntax for
* contact-group chips so they survive the composer's string boundaries
* (draft data, dirty compare, the contacts-page hand-off).
*/
export function formatRecipientEntry(r: Recipient): string {
if (r.group) {
const name = r.name?.trim() || 'Group';
const quoted = NAME_NEEDS_QUOTING.test(name)
? `"${name.replace(/(["\\])/g, '\\$1')}"`
: name;
const members = r.group.members.map((m) => formatRecipient(m.name, m.email)).join(', ');
return `${quoted}: ${members};`;
}
return formatRecipient(r.name, r.email);
}
/** Serializes a recipient array into a comma-separated string. */
export function formatRecipientList(recipients: Recipient[]): string {
return recipients.map((r) => formatRecipient(r.name, r.email)).join(', ');
return recipients.map(formatRecipientEntry).join(', ');
}
/**
* Expands contact-group chips into their members for sending and
* draft-saving. Deduplicates case-insensitively by address across the whole
* list, keeping the first occurrence - an explicitly added individual wins
* over the same address arriving again via a group.
*/
export function expandRecipients(recipients: Recipient[]): Recipient[] {
const seen = new Set<string>();
const out: Recipient[] = [];
for (const r of recipients) {
for (const entry of r.group ? r.group.members : [r]) {
const key = entry.email.trim().toLowerCase();
if (!key || seen.has(key)) continue;
seen.add(key);
out.push({ name: entry.name, email: entry.email });
}
}
return out;
}
/**
@@ -235,3 +435,34 @@ export function replaceInlineImagePlaceholders(
});
return changed ? doc.body.innerHTML : html;
}
export type PendingUploadLike = {
uploading?: boolean;
error?: boolean;
};
export type PendingUploadWaitResult = "completed" | "cancelled" | "failed";
/**
* Wait for in-flight attachment uploads to settle before sending.
*
* Polls `getAttachments` until nothing is `uploading`, checking
* `isCancelled` between polls (composer closed / draft discarded).
* Resolves:
* - "cancelled" - cancellation was signalled while waiting
* - "failed" - uploads settled but at least one attachment errored;
* the caller must NOT auto-send (the user may not be
* looking at the composer to notice the failed chip)
* - "completed" - all uploads finished cleanly, safe to proceed
*/
export async function waitForPendingUploads(
getAttachments: () => readonly PendingUploadLike[],
isCancelled: () => boolean,
pollMs = 150
): Promise<PendingUploadWaitResult> {
while (getAttachments().some((att) => att.uploading)) {
if (isCancelled()) return "cancelled";
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
return getAttachments().some((att) => att.error) ? "failed" : "completed";
}
+16 -14
View File
@@ -49,8 +49,8 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
// Parse SPF. A single Authentication-Results header can carry more than one
// SPF result when the server evaluates multiple identities (HELO and MAIL
// FROM). Collect them all and surface the most severe as the headline so a
// hard MAIL FROM `fail` isn't softened to a HELO `temperror`.
// FROM). Collect them all so a hard fail on any identity isn't softened to
// an ambiguous state recorded for another one.
const spfRegex = /spf=(\w+)(?:\s+\([^)]*\))?(?:\s+smtp\.(mailfrom|helo)=([^\s;]+))?/g;
const spfResults: SpfEntry[] = [];
let spfM: RegExpExecArray | null;
@@ -63,17 +63,18 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
}
if (spfResults.length > 0) {
const severity = (r: string) => SPF_SEVERITY[r as SpfResult] ?? -1;
// Most severe wins; on a tie prefer the MAIL FROM identity (more meaningful
// than HELO) and otherwise keep the first occurrence.
const primary = spfResults.reduce((best, cur) => {
if (severity(cur.result) > severity(best.result)) return cur;
if (
severity(cur.result) === severity(best.result) &&
best.identity !== 'mailfrom' &&
cur.identity === 'mailfrom'
) return cur;
return best;
});
// MAIL FROM is the primary SPF identity. Another identity (HELO) may only
// escalate the headline to a genuine failure state — a HELO `none` or
// `neutral` must not downgrade a MAIL FROM `pass`, since most senders
// publish no SPF record for their EHLO hostname.
const isFailure = (r: string) => severity(r) >= SPF_SEVERITY.temperror;
let primary =
spfResults.find((e) => e.identity === 'mailfrom') ?? spfResults[0];
for (const cur of spfResults) {
if (isFailure(cur.result) && severity(cur.result) > severity(primary.result)) {
primary = cur;
}
}
results.spf = {
result: primary.result,
domain: primary.domain,
@@ -118,7 +119,8 @@ export function parseAuthenticationResults(header: string): AuthenticationResult
*/
export function parseSpamScore(header: string): { score: number; status: string } | null {
// Try X-Spam-Status format: "No, score=-0.25"
const statusMatch = header.match(/^(Yes|No),?\s+score=([-\d.]+)/i);
// And try X-Spam-Score format (Stalwart): "ham, score=-0.25"
const statusMatch = header.match(/^(Yes|No|spam|ham),?\s+score=([-\d.]+)/i);
if (statusMatch) {
return {
status: statusMatch[1].toLowerCase(),
+147 -7
View File
@@ -79,26 +79,61 @@ export const SIGNATURE_SANITIZE_CONFIG = {
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
};
/** Drop images whose src isn't https: or a base64 raster data: URI. */
function restrictSignatureImages(node: Element): void {
if (node.tagName !== 'IMG') return;
const src = node.getAttribute('src');
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
node.remove();
}
}
/**
* Sanitize HTML signature for storage and display.
* Sanitize an HTML signature for storage and for the outgoing message.
* img src is restricted to https: or base64-embedded raster data: URIs
* (png/jpeg/gif/webp). SVG is excluded because DOMPurify cannot inspect
* bytes inside a data: URI. Images with a disallowed src are removed
* entirely so they don't render as broken-image icons.
*
* Deliberately does NOT force target="_blank": what we store, and what the
* recipient receives, should stay as the user wrote it. Use
* `sanitizeSignatureHtmlForDisplay` for anything rendered in our own DOM.
* @param html - User-provided HTML signature
* @returns Sanitized signature (no scripts, no external resources)
*/
export function sanitizeSignatureHtml(html: string): string {
if (!html?.trim()) return '';
DOMPurify.addHook('afterSanitizeAttributes', restrictSignatureImages);
try {
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
}
const SIGNATURE_DISPLAY_CONFIG = {
...SIGNATURE_SANITIZE_CONFIG,
ALLOWED_ATTR: [...SIGNATURE_SANITIZE_CONFIG.ALLOWED_ATTR, 'target', 'rel'],
};
/**
* Sanitize an HTML signature for rendering inside our own DOM — the identity
* form's live preview and the composer's signature block. Both inject into the
* main document rather than the sandboxed iframe used for message bodies, so a
* link without target="_blank" navigates the whole app away, taking any unsent
* draft or unsaved signature with it. Force every anchor to open a new tab.
*/
export function sanitizeSignatureHtmlForDisplay(html: string): string {
if (!html?.trim()) return '';
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName !== 'IMG') return;
const src = node.getAttribute('src');
if (!src || !/^(?:https:\/\/|data:image\/(?:png|jpe?g|gif|webp);base64,)/i.test(src)) {
node.remove();
restrictSignatureImages(node);
if (node.tagName === 'A') {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
try {
return DOMPurify.sanitize(html, SIGNATURE_SANITIZE_CONFIG);
return DOMPurify.sanitize(html, SIGNATURE_DISPLAY_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
@@ -119,7 +154,24 @@ const I18N_SANITIZE_CONFIG = {
};
export function sanitizeI18nHtml(html: string): string {
return DOMPurify.sanitize(html, I18N_SANITIZE_CONFIG);
// A custom ALLOWED_URI_REGEXP makes DOMPurify strip target/rel from trusted
// translated links (e.g. settings.security.not_available's docs link); keep
// them, and force rel on _blank to prevent tab-nabbing when the catalog omits it.
DOMPurify.addHook('uponSanitizeAttribute', (_node, data) => {
if (data.attrName === 'target' || data.attrName === 'rel') {
data.forceKeepAttr = true;
}
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
node.setAttribute('rel', 'noopener noreferrer');
}
});
try {
return DOMPurify.sanitize(html, I18N_SANITIZE_CONFIG);
} finally {
DOMPurify.removeAllHooks();
}
}
/**
@@ -132,11 +184,19 @@ export function sanitizeI18nHtml(html: string): string {
const PLAIN_TEXT_RENDERED_CONFIG = {
ALLOWED_TAGS: ['a', 'br', 'p', 'div', 'span'],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
// DOMPurify URI-tests every attribute value not on its URI-safe list, so the
// strict ALLOWED_URI_REGEXP below would strip target="_blank" (and rel) —
// "_blank" is not a URI. This branch renders into the main document rather
// than the sandboxed iframe, so losing target turns every link into a
// whole-app navigation. Exempt the two from the URI check.
ADD_URI_SAFE_ATTR: ['target', 'rel'],
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|tel:|cid:|#)/i,
};
export function sanitizePlainTextRenderedHtml(html: string): string {
// target/rel survive the URI check via ADD_URI_SAFE_ATTR (#594); the plaintext
// linkifier only emits http(s), so no per-scheme handling is needed here.
return DOMPurify.sanitize(html, PLAIN_TEXT_RENDERED_CONFIG);
}
@@ -167,6 +227,36 @@ export function isExternalResourceUrl(value: string | null | undefined): boolean
}
/**
* True for external web links (http/https or protocol-relative `//host`) that
* should open in a new tab — unlike `mailto:`/`tel:`/`#fragments`, which navigate
* in place or hand off to the OS handler. Strips C0 controls first so obfuscated
* schemes (`"h\ttps://x"`) don't slip through.
*/
export function isHttpLinkHref(href: string | null | undefined): boolean {
if (!href) return false;
// eslint-disable-next-line no-control-regex
const normalized = href.replace(/[\u0000-\u0020]+/g, '');
return /^(?:https?:\/\/|\/\/)/i.test(normalized);
}
/**
* Give one `<a>` the new-tab treatment uniformly across the iframe render paths
* (the DOMPurify hook and the post-render DOM walk in email-viewer): http(s)
* links get target=_blank + rel; other schemes have them stripped so they don't
* spawn a blank tab. (The plaintext path relies on ADD_URI_SAFE_ATTR instead.)
*/
export function applyNewTabToAnchor(node: Element): void {
if (node.tagName !== 'A') return;
if (isHttpLinkHref(node.getAttribute('href'))) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
} else {
node.removeAttribute('target');
node.removeAttribute('rel');
}
}
/**
* Decode CSS escape sequences so escaped tracking URLs can be recognised.
* `\68ttp://x` and `\000068ttp://x` both decode to `http://x` (the `cssEscape`
@@ -202,6 +292,43 @@ export function stripExternalCssUrls(style: string): string {
);
}
/**
* Neutralise external references in a full stylesheet (a kept `<style>` block).
* The iframe sanitiser keeps `<style>`, so its CSS can auto-load remote
* resources (background `url()`, `@font-face`, `@import`) that the per-node
* attribute walk in `blockExternalResourcesOnNode` never sees. The strict
* iframe CSP already blocks those fetches at the network level; this strips the
* references from the CSS text itself as defence-in-depth.
*
* Escapes are decoded on the WHOLE block first because the "css escape" tracker
* escapes the `url` keyword itself (`\75\72\6C(` -> `url(`) - a literal `url(`
* match would miss it. Returns the original (escapes intact) when nothing
* external is present, so callers can detect a change by identity. (#457)
*/
export function stripExternalStyleSheetCss(css: string): string {
if (!css) return css;
const decoded = decodeCssEscapes(css);
if (!/url\(|@import/i.test(decoded)) return css;
let changed = false;
// External url(...) anywhere in the sheet (also covers `@import url(...)`).
let result = decoded.replace(CSS_URL_PATTERN, (full, _q, inner: string) => {
if (isExternalResourceUrl(inner)) {
changed = true;
return 'url()';
}
return full;
});
// Bare-string remote import: `@import "http://…"` / `@import '//…'`.
result = result.replace(
/@import\s+(['"])\s*(?:https?:)?\/\/[^'"]*\1[^;]*;?/gi,
() => {
changed = true;
return '';
},
);
return changed ? result : css;
}
/** True if a srcset attribute lists at least one external candidate URL. */
function srcsetHasExternalUrl(srcset: string): boolean {
return srcset
@@ -292,6 +419,19 @@ export function blockExternalResourcesOnNode(node: Element): boolean {
blocked = true;
}
// <style> block CSS: the iframe sanitiser keeps these, so url()/@font-face/
// @import inside them can auto-load remote resources the attribute walk above
// never sees. Strip external refs from the stylesheet text (the strict iframe
// CSP is the network backstop; this is defence-in-depth). (#457)
if (tag === 'STYLE') {
const css = node.textContent || '';
const cleaned = stripExternalStyleSheetCss(css);
if (cleaned !== css) {
node.textContent = cleaned;
blocked = true;
}
}
return blocked;
}
+206
View File
@@ -0,0 +1,206 @@
const SVG_NS = 'http://www.w3.org/2000/svg';
// A neutral white band with black digits, rather than the conventional red
// badge. The band guarantees contrast for the count whatever the base icon
// looks like, which matters because `faviconUrl` is admin-overridable and may
// be any artwork. A coloured badge cannot make that guarantee: Bulwark's own
// icon is rgb(219,45,84), so a red badge sat red-on-red.
const BADGE_FILL = '#ffffff';
const BADGE_TEXT_FILL = '#000000';
const BADGE_FONT = "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
// The badge is a Gmail-style band across the bottom of the icon, sized as a
// fraction of the icon's own coordinate space so it lands correctly whatever
// viewBox the base declares.
//
// The fractions below are not invented: they are measured, pixel-by-pixel, off
// Gmail's real 16x16 tab favicon, which is the badge users actually compare this
// one against. Gmail's band is 10 of 16 px tall (0.625 of the icon span), its
// digits have a cap height of 7 of 16 px (0.44, i.e. a font-size of ~0.61 span),
// it is flush — edge to edge, and to the bottom, with no inset margin — and its
// corners carry a slight round, about 1px at 16px, which is roughly 0.1 of the
// band height. Not square, and emphatically not h/2.
//
// The box is sized to the label and centred, as Gmail's is: "5" must not squat
// on as much white as "99+" does.
//
// What keeps a three-glyph label legible is not the width — it is the small
// corner radius, plus budgeting the font against the FULL span rather than
// against the fitted box. The rounded-end pill that preceded this failed for the
// first reason: round ends (rx = h/2) squander their horizontal extent on the
// curve, which is exactly the space three glyphs need, so at 16px "99+" was an
// illegible smudge — and at one digit the same pill read as a plain circle. Do
// not reinstate rx = h/2. Because the font is budgeted against the full span,
// "99+" shrinks to the size that would fit edge to edge, and its box then grows
// to fill the icon width anyway; "9" and "47" render at the cap in a box that
// hugs them.
const BAND_HEIGHT = 0.625; // band height, as a fraction of the icon span
const FONT_MAX = 0.61; // font-size cap, as a fraction of the icon span
const PAD_FACTOR = 0.04; // horizontal padding, as a fraction of the icon span, each side
const CORNER_FACTOR = 0.1; // corner radius, as a fraction of band height
const GLYPH_ADV = 0.6; // advance width per glyph, in em, for the sans badge font
// Counts above this render as "99+". Gmail caps at 20, and matching it was
// tried and reverted: the cap decides how often the label needs three glyphs,
// and three glyphs do not fit at the full font size. Capping at 20 meant a
// typical inbox showed "20+" at 84% of the cap size essentially always, where
// capping at 99 shows a real two-digit count at full size. Bigger digits and a
// number you can act on beat parity with Gmail's ceiling.
const BADGE_MAX = 99;
/**
* Formats an unread count for display in the badge.
* Returns an empty string when there is nothing to show.
*/
export function formatBadgeCount(count: number): string {
// `< 1`, not `<= 0`: a fractional count such as 0.5 would otherwise floor to
// 0 and draw a "0" badge, since String(0) is truthy.
if (!Number.isFinite(count) || count < 1) return '';
const whole = Math.floor(count);
return whole > BADGE_MAX ? `${BADGE_MAX}+` : String(whole);
}
/**
* Strips anything active from the base SVG.
*
* The base may be an admin-uploaded file, which the branding route deliberately
* serves under a sandboxing CSP because SVG can carry script (see
* app/api/admin/branding/[filename]/route.ts). Re-emitting it verbatim as a
* same-origin `data:` URL inside our own document would un-fence exactly what
* that CSP fences, so remove script, foreignObject and every on* handler first.
*/
function sanitiseSvg(doc: Document): void {
doc.querySelectorAll('script, foreignObject').forEach((el) => el.remove());
doc.querySelectorAll('*').forEach((el) => {
for (const attr of Array.from(el.attributes)) {
if (attr.name.toLowerCase().startsWith('on')) {
el.removeAttributeNS(attr.namespaceURI, attr.localName);
}
}
});
}
/**
* Composes an unread badge over an SVG favicon and returns it as a data URL.
*
* Returns null — meaning "leave the favicon alone" — when the count is zero,
* or when the source is not usable SVG. Never throws.
*/
export function renderBadgedFavicon(baseSvgSource: string, count: number): string | null {
const label = formatBadgeCount(count);
if (!label) return null;
try {
const doc = new DOMParser().parseFromString(baseSvgSource, 'image/svg+xml');
if (doc.querySelector('parsererror')) return null;
const root = doc.documentElement;
// The namespace, not just the tag name: an <svg> with no xmlns parses fine
// but renders as nothing, so it would yield a non-null, blank data URL.
if (!root || root.localName !== 'svg' || root.namespaceURI !== SVG_NS) return null;
const viewBox = root.getAttribute('viewBox');
if (!viewBox) return null;
const [rawMinX, rawMinY, rawWidth, rawHeight] = viewBox.trim().split(/[\s,]+/).map(Number);
if (
![rawMinX, rawMinY, rawWidth, rawHeight].every(Number.isFinite) ||
rawWidth <= 0 ||
rawHeight <= 0
) {
return null;
}
sanitiseSvg(doc);
// The base declares "1000pt"; point units in a favicon are unreliable.
// Unitless 16 with the viewBox retained lets the browser rasterise cleanly
// at any size it asks for.
root.setAttribute('width', '16');
root.setAttribute('height', '16');
// Normalise the viewBox to a square, centred on the original, before doing
// any badge maths. Sizing the badge off min(width, height) double-penalised
// a non-square base: a 100x20 wordmark produced a ~2px-tall smudge on a
// 16px icon. Squaring first sizes the badge against the box the icon is
// actually painted into. It is a no-op for a square viewBox (Bulwark's own
// is 0 0 1000 1000). Caveat: a base that pairs a non-square viewBox with
// preserveAspectRatio="none" will now letterbox rather than stretch — an
// acceptable, arguably better, trade for a favicon, which is always square.
const side = Math.max(rawWidth, rawHeight);
const minX = rawMinX - (side - rawWidth) / 2;
const minY = rawMinY - (side - rawHeight) / 2;
root.setAttribute('viewBox', `${minX} ${minY} ${side} ${side}`);
const span = side;
const h = BAND_HEIGHT * span;
const fontMax = FONT_MAX * span;
const pad = PAD_FACTOR * span;
// The font first, budgeted against the FULL span: the largest size that
// would still leave the padding intact if the box ran edge to edge. That is
// the cap for one or two glyphs and a modest shrink for "99+".
const font = Math.min(fontMax, (span - 2 * pad) / (label.length * GLYPH_ADV));
// The box then hugs the label — never wider than the icon, anchored to the
// bottom-right corner. A three-glyph label, whose font was budgeted against
// the whole span, fills that span exactly; shorter labels get a narrower
// box, leaving the left of the base mark uncovered so the artwork stays
// recognisable. Gmail's own badge does the same: measured off its 16px
// favicon, a single digit sits hard right in a box about a third of the
// icon wide. Centring was tried and rejected — at one digit the box lands
// under the middle of the mark and bites a hole out of it.
const textW = label.length * GLYPH_ADV * font;
const w = Math.min(span, textW + 2 * pad);
const x = minX + span - w;
const y = minY + span - h;
const rx = CORNER_FACTOR * h;
const bandRect = doc.createElementNS(SVG_NS, 'rect');
bandRect.setAttribute('x', String(x));
bandRect.setAttribute('y', String(y));
bandRect.setAttribute('width', String(w));
bandRect.setAttribute('height', String(h));
bandRect.setAttribute('rx', String(rx));
bandRect.setAttribute('ry', String(rx));
// Presentation attributes lose to any CSS rule in the same document, and a
// branded base is free to carry `<style>rect{fill:#db2d54}</style>` — which
// would paint the badge red-on-red, the exact failure the white band exists
// to prevent. A style attribute outranks a stylesheet rule, so set both: the
// attribute as the guarantee, the presentation attribute as the fallback.
bandRect.setAttribute('fill', BADGE_FILL);
bandRect.setAttribute('style', `fill:${BADGE_FILL}`);
const text = doc.createElementNS(SVG_NS, 'text');
text.setAttribute('x', String(x + w / 2));
text.setAttribute('y', String(y + h / 2));
text.setAttribute('text-anchor', 'middle');
text.setAttribute('dominant-baseline', 'central');
text.setAttribute('font-family', BADGE_FONT);
// 500, not 700: at true 16px a bold count read visibly heavier than the
// equivalent badge in Gmail's tab, which is the thing users compare it to.
text.setAttribute('font-weight', '500');
text.setAttribute('font-size', String(font));
text.setAttribute('fill', BADGE_TEXT_FILL);
text.setAttribute(
'style',
`fill:${BADGE_TEXT_FILL};font-family:${BADGE_FONT};font-weight:500;font-size:${font}px`,
);
text.textContent = label;
root.appendChild(bandRect);
root.appendChild(text);
const serialised = new XMLSerializer().serializeToString(doc);
// Percent-encoding rather than base64: btoa throws on any character outside
// Latin-1, which a branded SVG may well contain. encodeURIComponent itself
// throws on an unpaired surrogate, so this whole tail is guarded. It must be
// encodeURIComponent, not encodeURI: the latter leaves "#" bare, and a bare
// "#" in a colour truncates the data URL at the first fill.
return `data:image/svg+xml,${encodeURIComponent(serialised)}`;
} catch {
return null;
}
}
+169
View File
@@ -0,0 +1,169 @@
/**
* Jalali (Persian/Shamsi) calendar utilities.
*
* All internal date handling remains Gregorian (ISO 8601). The functions
* in this module convert Gregorian ↔ Jalali at the display layer only.
*
* Uses `jalaali-js` for the underlying calendar math.
*/
import * as jalaali from 'jalaali-js';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** A Jalali date represented as year, month (1-12), day (1-31). */
export interface JalaliDate {
/** Jalali year (e.g. 1405) */
jy: number;
/** Jalali month (1 = Farvardin, 12 = Esfand) */
jm: number;
/** Jalali day of month (1-31) */
jd: number;
}
// ---------------------------------------------------------------------------
// Gregorian ↔ Jalali conversion
// ---------------------------------------------------------------------------
/** Convert a Gregorian Date to its Jalali equivalent. */
export function toJalali(date: Date): JalaliDate {
const { jy, jm, jd } = jalaali.toJalaali(date);
return { jy, jm, jd };
}
/** Convert a Jalali date to a Gregorian Date. */
export function toGregorian(jy: number, jm: number, jd: number): Date {
const { gy, gm, gd } = jalaali.toGregorian(jy, jm, jd);
return new Date(gy, gm - 1, gd);
}
// ---------------------------------------------------------------------------
// Jalali month / day info
// ---------------------------------------------------------------------------
/** Full Persian month names (Farvardin … Esfand). */
export const JALALI_MONTHS: readonly string[] = [
'فروردین',
'اردیبهشت',
'خرداد',
'تیر',
'مرداد',
'شهریور',
'مهر',
'آبان',
'آذر',
'دی',
'بهمن',
'اسفند',
];
/** Number of days in a Jalali month (handles leap years). */
export function jalaliMonthLength(jy: number, jm: number): number {
return jalaali.jalaaliMonthLength(jy, jm);
}
/** Is the given Jalali year a leap year? */
export function isJalaliLeapYear(jy: number): boolean {
return jalaali.isLeapJalaaliYear(jy);
}
// ---------------------------------------------------------------------------
// Calendar grid helpers (analogous to date-fns startOfWeek / eachDayOfInterval)
// ---------------------------------------------------------------------------
/**
* Return the first day of the Jalali month (Gregorian Date) aligned to the
* week grid so the month view can be rendered. `weekStartsOn` follows the
* same convention as `date-fns`: 0=Sun, 1=Mon, …, 6=Sat.
*/
export function startOfJalaliMonth(
jy: number,
jm: number,
weekStartsOn: number = 6,
): Date {
const firstDay = toGregorian(jy, jm, 1);
const dayOfWeek = firstDay.getDay(); // 0=Sun … 6=Sat
const offset = (dayOfWeek - weekStartsOn + 7) % 7;
const result = new Date(firstDay);
result.setDate(result.getDate() - offset);
return result;
}
/**
* Return the last day of the Jalali month (Gregorian Date) aligned to the
* week grid.
*/
export function endOfJalaliMonth(
jy: number,
jm: number,
weekStartsOn: number = 6,
): Date {
const lastDay = toGregorian(jy, jm, jalaliMonthLength(jy, jm));
const dayOfWeek = lastDay.getDay();
const offset = (weekStartsOn - dayOfWeek + 6) % 7;
const result = new Date(lastDay);
result.setDate(result.getDate() + offset);
return result;
}
/**
* Build a flat array of Gregorian Dates covering the entire calendar grid
* for a Jalali month (from the week-aligned start to the week-aligned end).
*/
export function eachDayOfJalaliMonth(
jy: number,
jm: number,
weekStartsOn: number = 6,
): Date[] {
const start = startOfJalaliMonth(jy, jm, weekStartsOn);
const end = endOfJalaliMonth(jy, jm, weekStartsOn);
const days: Date[] = [];
const cursor = new Date(start);
while (cursor <= end) {
days.push(new Date(cursor));
cursor.setDate(cursor.getDate() + 1);
}
return days;
}
// ---------------------------------------------------------------------------
// Locale-aware day header order
// ---------------------------------------------------------------------------
/**
* Return the array of day-abbreviation translation keys in the correct order
* for the given `firstDayOfWeek` (0=Sun … 6=Sat).
*
* Usage:
* const dayHeaders = getDayHeaderKeys(firstDayOfWeek);
* dayHeaders.map((key) => t(`calendar.days.${key}`))
*/
export function getDayHeaderKeys(
firstDayOfWeek: number,
): readonly string[] {
const ALL: readonly string[] = [
'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat',
] as const;
return [...ALL.slice(firstDayOfWeek), ...ALL.slice(0, firstDayOfWeek)];
}
// ---------------------------------------------------------------------------
// Locale detection helper
// ---------------------------------------------------------------------------
/**
* Should the UI render dates using the Jalali calendar?
*
* Currently this is keyed off the `fa` locale. Administrators who want a
* different locale with Jalali dates can extend this logic later.
*/
export function shouldUseJalaliCalendar(locale: string): boolean {
return locale === 'fa';
}
/** Default `firstDayOfWeek` for a given locale. */
export function defaultFirstDayOfWeek(locale: string): number {
if (locale === 'fa') return 6; // Saturday
return 1; // Monday (ISO convention)
}
+19 -7
View File
@@ -40,7 +40,7 @@ export interface IJMAPClient {
supportsContacts(): boolean;
supportsCalendars(): boolean;
supportsSieve(): boolean;
supportsFiles(): boolean;
supportsFiles(accountId?: string): boolean;
// ── Push / state ──────────────────────────────────────────────
setupPushNotifications(): boolean;
@@ -78,9 +78,12 @@ export interface IJMAPClient {
deleteMailbox(mailboxId: string): Promise<void>;
// ── Emails ────────────────────────────────────────────────────
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
// `pinnedFirst` sorts emails carrying the $pinned keyword to the top
// (server-side hasKeyword sort comparator, RFC 8621), then receivedAt desc.
getEmails(mailboxId?: string, accountId?: string, limit?: number, position?: number, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
getEmailsInMailbox(mailboxId: string): Promise<Email[]>;
getEmail(emailId: string, accountId?: string): Promise<Email | null>;
getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]>
getTagCounts(tagIds: string[]): Promise<Record<string, { total: number; unread: number }>>;
searchEmails(query: string, mailboxId?: string, accountId?: string, limit?: number, position?: number): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
advancedSearchEmails(
@@ -89,13 +92,20 @@ export interface IJMAPClient {
limit?: number,
position?: number,
): Promise<{ emails: Email[]; hasMore: boolean; total: number }>;
/**
* Lean recipient search for compose autocomplete ("search the server" action):
* finds messages in `sentMailboxId` whose to/cc matches `query` and returns
* only the matching addresses (fetches just the `to`/`cc` properties - no
* bodies or attachments), deduped.
*/
searchSentRecipients(query: string, sentMailboxId: string, accountId?: string, limit?: number): Promise<Array<{ name: string; email: string }>>;
// ── Email mutations ───────────────────────────────────────────
markAsRead(emailId: string, read?: boolean, accountId?: string): Promise<void>;
batchMarkAsRead(emailIds: string[], read?: boolean, accountId?: string): Promise<void>;
toggleStar(emailId: string, starred: boolean, accountId?: string): Promise<void>;
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void>;
setKeyword(emailId: string, keyword: string): Promise<void>;
updateEmailKeywords(emailId: string, keywords: Record<string, boolean>, accountId?: string): Promise<void>;
setKeyword(emailId: string, keyword: string, accountId?: string): Promise<void>;
migrateKeyword(oldKeyword: string, newKeyword: string): Promise<number>;
deleteEmail(emailId: string, accountId?: string): Promise<void>;
moveToTrash(emailId: string, trashMailboxId: string, accountId?: string, markAsRead?: boolean): Promise<void>;
@@ -176,9 +186,11 @@ export interface IJMAPClient {
}): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
submitRawEmail(blob: Blob, identityId: string, delayedUntil?: string, envelopeRecipients?: string[]): Promise<SendEmailResult>;
getScheduledEmails(limit?: number, position?: number): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }>;
cancelEmailSubmission(submissionId: string): Promise<void>;
rescheduleEmailSubmission(submissionId: string, emailId: string, identityId: string, delayedUntil: string): Promise<SendEmailResult>;
/** `sentMailboxId` is accepted for backwards compatibility but ignored: the message is placed in Drafts only. */
restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void>;
sendImipReply(opts: {
@@ -213,9 +225,9 @@ export interface IJMAPClient {
): Promise<{ blobId: string; size: number; type: string }>;
getBlobDownloadUrl(blobId: string, name?: string, type?: string, accountId?: string): string;
fetchBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<Blob>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string): Promise<void>;
fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string>;
fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer>;
downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void>;
// ── Identities ────────────────────────────────────────────────
getIdentities(): Promise<Identity[]>;
+411 -59
View File
@@ -15,6 +15,41 @@ function parseRecipientString(s: string): { name?: string; email: string } {
return { email: trimmed };
}
/**
* Build the `mailboxIds` portion of an `Email/set` PatchObject as a full-property
* replacement — `{ mailboxIds: { <id>: true, ... } }` — instead of per-id
* `mailboxIds/<id>` JSON-Pointer patches.
*
* Two reasons:
* 1. It states the actual intent of a post-send / undo-send move: the message
* should belong to *exactly* the given mailbox(es).
* 2. It avoids per-id JSON-Pointer tokens entirely. Stalwart (observed on
* 0.15.5) rejects an `Email/set` PatchObject whose pointer token is a
* purely-numeric string — e.g. `mailboxIds/0` for a mailbox whose JMAP id is
* "0" — with `invalidProperties: "Invalid patch value"` (it treats the digits
* as a JSON-Pointer array index even though `mailboxIds` is a JSON object;
* cf. RFC 6901 §4, and RFC 8620 §1.2's warning against interop-hostile ids).
* That silently stranded already-delivered mail in Drafts for accounts whose
* Drafts/Sent mailbox id happened to be all digits (a full member of `0`,
* `1`, … `9`, `10`, … was verified rejected; ids containing a letter work).
* Stalwart fixed the parsing in 0.16.5 (stalwartlabs/stalwart@175f34ea,
* jmap-tools 0.1.5), but earlier deployments remain in the wild — and not
* emitting interop-hostile pointer tokens is the safer shape regardless.
*
* This is a *replacement*: it drops any other mailbox membership the message
* had, so callers must know the complete target set. Do NOT also place a
* `mailboxIds/<id>` pointer key in the same PatchObject — a pointer whose prefix
* is another key in the object is illegal (RFC 8620 §5.3).
*/
function mailboxIdsReplacement(
mailboxId: string,
...moreMailboxIds: string[]
): { mailboxIds: Record<string, true> } {
const mailboxIds: Record<string, true> = { [mailboxId]: true };
for (const id of moreMailboxIds) mailboxIds[id] = true;
return { mailboxIds };
}
export class RateLimitError extends Error {
retryAfterMs: number;
constructor(retryAfterMs: number) {
@@ -26,6 +61,9 @@ export class RateLimitError extends Error {
// JMAP protocol types - these are intentionally flexible due to server variations
interface JMAPSession {
// The authenticated login (JMAP spec Session.username) — server-confirmed,
// unlike the client-side constructor username or the sending identity.
username?: string;
apiUrl: string;
downloadUrl: string;
uploadUrl?: string;
@@ -45,6 +83,7 @@ interface JMAPAccount {
interface JMAPQuota {
resourceType?: string;
scope?: string;
types?: string[];
used?: number;
hardLimit?: number;
limit?: number;
@@ -431,6 +470,17 @@ function stripMessageIdBrackets(id: string): string {
return id.trim().replace(/^<+/, '').replace(/>+$/, '').trim();
}
// Generate a Message-ID for outgoing mail (bare msg-id, no angle brackets, per
// RFC 8621 §4.1.2.3). Without one the server synthesizes it from its OS
// hostname, which leaks internal names (e.g. @ip-10-0-12-97.ec2.internal) into
// headers — an anti-spam signal and an information disclosure. Use the sender's
// domain instead, matching what receivers expect a Message-ID to look like.
function generateMessageId(fromEmail: string): string {
const at = fromEmail.lastIndexOf('@');
const domain = at > 0 ? fromEmail.slice(at + 1) : 'localhost';
return `${Date.now().toString(36)}.${crypto.randomUUID()}@${domain}`;
}
/**
* Build a CalendarEvent/query filter restricting results to the given
* calendars. Stalwart implements the singular `inCalendar` condition (one
@@ -506,6 +556,14 @@ export class JMAPClient implements IJMAPClient {
private session: JMAPSession | null = null;
private lastPingTime: number = 0;
private pingInterval: NodeJS.Timeout | null = null;
// Set by disconnect() so async callbacks that were already in flight
// (keep-alive ping, SSE error handlers) cannot revive timers or
// reconnect after an intentional sign-out (#588).
private intentionallyDisconnected = false;
// Consecutive keep-alive failures; failed pings skip upcoming ticks
// (30s -> 1m -> 2m -> ~5m) instead of hammering a down server (#588).
private pingFailureCount = 0;
private pingSkipRemaining = 0;
private accounts: Record<string, JMAPAccount> = {};
private eventSource: EventSource | null = null;
private stateChangeCallback: ((change: StateChange) => void) | null = null;
@@ -541,6 +599,43 @@ export class JMAPClient implements IJMAPClient {
this.authHeader = `Bearer ${token}`;
}
async getSomeEmails(emailsId: string[], accountId?: string): Promise<Email[]> {
try {
const targetAccountId = accountId || this.accountId;
if (!emailsId || emailsId.length === 0) {
return [];
}
const response = await this.request([
["Email/get", {
accountId: targetAccountId,
ids: emailsId,
properties: [...EMAIL_LIST_PROPERTIES],
}, "0"],
]);
const getResponse = response.methodResponses?.[0]?.[1];
if (response.methodResponses?.[0]?.[0] === "Email/get" && getResponse) {
const emails = (getResponse.list || []) as Email[];
emails.sort((a: Email, b: Email) =>
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return emails;
}
return [];
} catch (error) {
console.error('Failed to get specific emails:', error);
return [];
}
}
/** Upgrade an existing basic-auth client to bearer-token auth (e.g. after TOTP token exchange). */
upgradeToBearer(accessToken: string, onRefresh?: () => Promise<string | null>): void {
this.authMode = 'bearer';
@@ -688,6 +783,7 @@ export class JMAPClient implements IJMAPClient {
}
async connect(): Promise<void> {
this.intentionallyDisconnected = false;
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
try {
@@ -757,19 +853,33 @@ export class JMAPClient implements IJMAPClient {
this.stopKeepAlive();
this.pingInterval = setInterval(async () => {
if (this.intentionallyDisconnected) return;
// Skip ping while rate-limited to avoid compounding auth failures
if (this.isRateLimited()) return;
// Back off while the server is down: each consecutive failure skips
// more ticks (30s -> 1m -> 2m -> ~5m) instead of retrying flat-out.
if (this.pingSkipRemaining > 0) {
this.pingSkipRemaining--;
return;
}
try {
await this.ping();
this.pingFailureCount = 0;
this.connectionChangeCallback?.(true);
} catch (error) {
if (error instanceof RateLimitError) {
return;
}
// A sign-out while the ping was in flight - stay down.
if (this.intentionallyDisconnected) return;
this.pingFailureCount++;
this.pingSkipRemaining = Math.min(2 ** this.pingFailureCount, 10) - 1;
console.error('Keep-alive ping failed:', error);
this.connectionChangeCallback?.(false);
try {
await this.reconnect();
this.pingFailureCount = 0;
this.pingSkipRemaining = 0;
this.connectionChangeCallback?.(true);
} catch (reconnectError) {
console.error('Reconnection failed:', reconnectError);
@@ -802,10 +912,12 @@ export class JMAPClient implements IJMAPClient {
}
async reconnect(): Promise<void> {
if (this.intentionallyDisconnected) return;
await this.connect();
}
disconnect(): void {
this.intentionallyDisconnected = true;
this.stopKeepAlive();
this.closePushNotifications();
if (this.rateLimitTimeout) {
@@ -878,16 +990,24 @@ export class JMAPClient implements IJMAPClient {
}
async getQuota(): Promise<{ used: number; total: number } | null> {
if (!this.supportsQuota()) return null;
try {
const response = await this.request([
["Quota/get", {
accountId: this.accountId,
}, "0"]
]);
], ["urn:ietf:params:jmap:core", "urn:ietf:params:jmap:quota"]);
if (response.methodResponses?.[0]?.[0] === "Quota/get") {
const quotas = (response.methodResponses[0][1].list || []) as JMAPQuota[];
const mailQuota = quotas.find((q) => q.resourceType === "mail" || q.scope === "mail");
const coversMail = (q: JMAPQuota) =>
!q.types?.length || q.types.some((t) => t === "Email" || t === "Mail");
// storage quotas use resourceType "octets" (e.g. Stalwart, with
// scope "account"); fall back to the pre-RFC "mail" shape for older servers.
const mailQuota =
quotas.find((q) => q.resourceType === "octets" && coversMail(q)) ||
quotas.find((q) => q.resourceType === "mail" || q.scope === "mail");
if (mailQuota) {
return {
@@ -1053,7 +1173,7 @@ export class JMAPClient implements IJMAPClient {
}
}
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
async getEmails(mailboxId?: string, accountId?: string, limit: number = 50, position: number = 0, hasKeyword?: string, pinnedFirst?: boolean): Promise<{ emails: Email[], hasMore: boolean, total: number }> {
try {
const targetAccountId = accountId || this.accountId;
const filter: { inMailbox?: string; hasKeyword?: string } = {};
@@ -1063,12 +1183,20 @@ export class JMAPClient implements IJMAPClient {
if (hasKeyword) {
filter.hasKeyword = hasKeyword;
}
// Pinned-first uses the hasKeyword sort comparator (RFC 8621 §4.4.2);
// every page of a view must use the same sort or pagination tears.
const sort = pinnedFirst
? [
{ property: "hasKeyword", keyword: "$pinned", isAscending: false },
{ property: "receivedAt", isAscending: false },
]
: [{ property: "receivedAt", isAscending: false }];
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter,
sort: [{ property: "receivedAt", isAscending: false }],
sort,
limit,
position,
calculateTotal: true,
@@ -1087,7 +1215,10 @@ export class JMAPClient implements IJMAPClient {
const emails = (getResponse.list || []) as Email[];
// Sort client-side as safety net - some servers may not honour
// the query sort for large mailboxes without additional filters.
// Must mirror the query sort, or it would undo the pinned-first order.
const pinRank = (e: Email) => (pinnedFirst && e.keywords?.['$pinned'] ? 1 : 0);
emails.sort((a: Email, b: Email) =>
pinRank(b) - pinRank(a) ||
new Date(b.receivedAt).getTime() - new Date(a.receivedAt).getTime()
);
const total = queryResponse?.total || 0;
@@ -1243,10 +1374,10 @@ export class JMAPClient implements IJMAPClient {
email.authenticationResults = parseAuthenticationResults(value);
}
for (const headerName of ['X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score']) {
for (const headerName of ['X-Spam-Score', 'X-Spam-Status', 'X-Spam-Result', 'X-Rspamd-Score']) {
if (!headersRecord[headerName]) continue;
const value = Array.isArray(headersRecord[headerName]) ? headersRecord[headerName][0] : headersRecord[headerName];
const spamResult = parseSpamScore(value as string);
const spamResult = parseSpamScore((value as string).trim());
if (spamResult) {
email.spamScore = spamResult.score;
email.spamStatus = spamResult.status;
@@ -1301,10 +1432,10 @@ export class JMAPClient implements IJMAPClient {
]);
}
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>): Promise<void> {
async updateEmailKeywords(emailId: string, keywords: Record<string, boolean>, accountId?: string): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
update: {
[emailId]: {
keywords,
@@ -1314,10 +1445,10 @@ export class JMAPClient implements IJMAPClient {
]);
}
async setKeyword(emailId: string, keyword: string): Promise<void> {
async setKeyword(emailId: string, keyword: string, accountId?: string): Promise<void> {
await this.request([
["Email/set", {
accountId: this.accountId,
accountId: accountId || this.accountId,
update: {
[emailId]: {
[`keywords/${keyword}`]: true,
@@ -1840,6 +1971,13 @@ export class JMAPClient implements IJMAPClient {
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
// Mirror getEmails: emails fetched from a delegated/shared account carry
// bare owner mailbox ids; namespace them to `${ownerId}:${id}` so they line
// up with the namespaced ids the store holds for shared mailboxes. (#281 V3)
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return { emails, hasMore, total };
} catch (error) {
console.error('Search failed:', error);
@@ -1880,6 +2018,13 @@ export class JMAPClient implements IJMAPClient {
const total = queryResponse?.total || 0;
const hasMore = computeHasMore(position, emails.length, total, limit);
// Namespace shared/delegated-account mailbox ids (see searchEmails). The
// cross-account views (All mail / Unread / Starred) browse via this method,
// so without it shared emails would carry bare owner ids there. (#281 V3)
if (accountId && accountId !== this.accountId) {
namespaceMailboxIds(emails, accountId);
}
return { emails, hasMore, total };
} catch (error) {
console.error('Advanced search failed:', error);
@@ -1887,6 +2032,53 @@ export class JMAPClient implements IJMAPClient {
}
}
async searchSentRecipients(query: string, sentMailboxId: string, accountId?: string, limit: number = 60): Promise<Array<{ name: string; email: string }>> {
const q = query.trim();
if (!q || !sentMailboxId) return [];
try {
const targetAccountId = accountId || this.accountId;
const response = await this.request([
["Email/query", {
accountId: targetAccountId,
filter: {
operator: "AND",
conditions: [
{ inMailbox: sentMailboxId },
{ operator: "OR", conditions: [{ to: q }, { cc: q }] },
],
},
sort: [{ property: "receivedAt", isAscending: false }],
limit,
}, "0"],
// Fetch ONLY the recipient fields - no subject/preview/body/attachments.
["Email/get", {
accountId: targetAccountId,
"#ids": { resultOf: "0", name: "Email/query", path: "/ids" },
properties: ["to", "cc"],
}, "1"],
]);
const emails = (response.methodResponses?.[1]?.[1]?.list || []) as Email[];
const lower = q.toLowerCase();
const byEmail = new Map<string, { name: string; email: string }>();
for (const email of emails) {
for (const r of [...(email.to || []), ...(email.cc || [])]) {
if (!r.email) continue;
const key = r.email.toLowerCase().trim();
if (!key || byEmail.has(key)) continue;
// The query matched *some* recipient of the message; keep only the
// addresses that actually match, not every co-recipient.
if (key.includes(lower) || (r.name && r.name.toLowerCase().includes(lower))) {
byEmail.set(key, { name: (r.name || "").trim(), email: r.email });
}
}
}
return Array.from(byEmail.values());
} catch (error) {
console.error('Recipient search failed:', error);
return [];
}
}
async getThread(threadId: string, accountId?: string): Promise<Thread | null> {
try {
const targetAccountId = accountId || this.accountId;
@@ -2338,6 +2530,7 @@ export class JMAPClient implements IJMAPClient {
cc: cc?.length ? cc.map(parseRecipientString) : undefined,
bcc: bcc?.length ? bcc.map(parseRecipientString) : undefined,
subject,
messageId: [generateMessageId(fromEmail || this.username)],
inReplyTo: normalizedInReplyTo?.length ? normalizedInReplyTo : undefined,
references: normalizedReferences?.length ? normalizedReferences : undefined,
keywords: { "$seen": true, "$draft": true },
@@ -2381,8 +2574,7 @@ export class JMAPClient implements IJMAPClient {
// issues with servers that encrypt on append (e.g. Stalwart). See #188.
const onSuccessUpdateEmail = {
"#1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
"keywords/$draft": null,
},
};
@@ -2656,8 +2848,7 @@ export class JMAPClient implements IJMAPClient {
create: { "sub-1": { emailId: `#${emailId}`, identityId: finalIdentityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
"keywords/$draft": null,
},
},
@@ -2842,8 +3033,7 @@ export class JMAPClient implements IJMAPClient {
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
"keywords/$draft": null,
},
},
@@ -2997,8 +3187,7 @@ export class JMAPClient implements IJMAPClient {
create: { "sub-1": { emailId: `#${emailId}`, identityId } },
onSuccessUpdateEmail: {
"#sub-1": {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
"keywords/$draft": null,
},
},
@@ -3276,8 +3465,8 @@ export class JMAPClient implements IJMAPClient {
return response.blob();
}
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string): Promise<string> {
const blob = await this.fetchBlob(blobId, name, type);
async fetchBlobAsObjectUrl(blobId: string, name?: string, type?: string, accountId?: string): Promise<string> {
const blob = await this.fetchBlob(blobId, name, type, accountId);
return URL.createObjectURL(blob);
}
@@ -3415,6 +3604,14 @@ export class JMAPClient implements IJMAPClient {
return this.username || this.session?.accounts?.[this.accountId]?.name || '';
}
// Server-confirmed authenticated login from the JMAP Session object. Use
// this (not getUsername(), which echoes the constructor arg, nor the
// sending identity) to verify a slot's token resolved to the expected
// account.
getSessionUsername(): string | undefined {
return this.session?.username;
}
supportsEmailSubmission(): boolean {
return this.hasCapability("urn:ietf:params:jmap:submission");
}
@@ -5142,14 +5339,30 @@ export class JMAPClient implements IJMAPClient {
// ─── JMAP FileNode methods (draft-ietf-jmap-filenode) ───
supportsFiles(): boolean {
return this.hasCapability("urn:ietf:params:jmap:filenode");
supportsFiles(accountId?: string): boolean {
// Gate on the ACCOUNT capability, not the server-wide session capability.
// A server can advertise urn:ietf:params:jmap:filenode while a specific
// account has its jmap-file-node-* permissions revoked, in which case the
// capability is absent from that account's accountCapabilities and every
// FileNode action fails with an authorization error (#563). Mirror
// getFilesCapableAccountIds(): non-personal (shared/group) accounts don't
// always advertise per-account, so treat those as capable.
const id = accountId || this.accountId;
const account = this.accounts[id];
if (!account) return false;
return !!account.accountCapabilities?.["urn:ietf:params:jmap:filenode"] || !account.isPersonal;
}
async probeFileNodeSupport(): Promise<boolean> {
// Some servers support FileNode without advertising a specific capability.
// Try a minimal FileNode/query to detect support at runtime.
if (this.supportsFiles()) return true;
// If the server advertises FileNode server-wide but this account's
// accountCapabilities omits it, that's an explicit per-account denial (#563)
// - don't probe (the probe would only confirm the revoked account can't use
// it, or worse mislead). Only fall through for servers that don't advertise
// the capability at all.
if (this.hasCapability("urn:ietf:params:jmap:filenode")) return false;
if (!this.apiUrl) return false;
try {
const accountId = this.getFilesAccountId();
@@ -5540,8 +5753,8 @@ export class JMAPClient implements IJMAPClient {
return created as FileNode;
}
async downloadBlob(blobId: string, name?: string, type?: string): Promise<void> {
const blob = await this.fetchBlob(blobId, name, type);
async downloadBlob(blobId: string, name?: string, type?: string, accountId?: string): Promise<void> {
const blob = await this.fetchBlob(blobId, name, type, accountId);
const blobUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -5554,6 +5767,7 @@ export class JMAPClient implements IJMAPClient {
}
private pollingInterval: NodeJS.Timeout | null = null;
private secondaryPollInterval: NodeJS.Timeout | null = null;
private pollingStates: { [key: string]: string } = {};
private sseAbortController: AbortController | null = null;
private sseReconnectTimeout: NodeJS.Timeout | null = null;
@@ -5571,6 +5785,10 @@ export class JMAPClient implements IJMAPClient {
};
private static readonly POLLING_INTERVAL = 3_000;
// Shared/secondary accounts get no SSE push (Stalwart pushes the primary
// account only), so poll them on a slow cadence alongside SSE to keep their
// folder + unified/All-Mail counters from going stale between focus events.
private static readonly SECONDARY_POLL_INTERVAL = 20_000;
private static readonly SSE_RECONNECT_DELAY = 3_000;
private static readonly SSE_PING_TIMEOUT = 90_000; // 3x the 30s ping interval
@@ -5578,13 +5796,34 @@ export class JMAPClient implements IJMAPClient {
const eventSourceUrl = this.getEventSourceUrl();
if (eventSourceUrl) {
this.connectSSE(eventSourceUrl);
// SSE covers the primary account only; keep shared accounts fresh too.
this.startSecondaryAccountPoll();
} else {
// The fallback poll already covers every session account.
this.startPollingFallback();
}
this.setupBrowserEventListeners();
return true;
}
/**
* Slow poll of the session's shared/secondary accounts, run in parallel with
* SSE (which never reports them). Skipped when there are no shared accounts,
* and paused while the tab is hidden (visibilitychange forces a check on
* return). Reuses checkForStateChanges, which already reports per-account.
*/
private startSecondaryAccountPoll(): void {
if (this.secondaryPollInterval) return;
const hasSecondary = this.pollAccountIds().some((id) => id !== this.accountId);
if (!hasSecondary) return;
// Prime the per-account baseline so the first tick doesn't false-fire.
void this.fetchCurrentStates();
this.secondaryPollInterval = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) return;
void this.checkForStateChanges();
}, JMAPClient.SECONDARY_POLL_INTERVAL);
}
private connectSSE(templateUrl: string): void {
if (this.isRateLimited()) {
this.scheduleSSEReconnect();
@@ -5675,6 +5914,7 @@ export class JMAPClient implements IJMAPClient {
}
private scheduleSSEReconnect(): void {
if (this.intentionallyDisconnected) return;
const eventSourceUrl = this.getEventSourceUrl();
if (!eventSourceUrl) {
this.fallbackToPolling();
@@ -5700,6 +5940,7 @@ export class JMAPClient implements IJMAPClient {
}
private startPollingFallback(): void {
if (this.intentionallyDisconnected) return;
if (this.isRateLimited()) {
return;
}
@@ -5709,12 +5950,30 @@ export class JMAPClient implements IJMAPClient {
}, JMAPClient.POLLING_INTERVAL);
}
/**
* Accounts whose Mailbox/Email state the poll should track. Stalwart's SSE
* only pushes StateChange for the primary account, never for delegated/shared
* (secondary) accounts, so their folder counters — and the unified/All-Mail
* badges that aggregate them — would otherwise never refresh from a background
* change. Polling every session account (primary + shared) closes that gap on
* the visibility/interval reconcile path. Mailbox/Email get callIds are tagged
* with the accountId (`mbx:<id>` / `eml:<id>`) so each account is compared
* independently. (#shared-counter-push)
*/
private pollAccountIds(): string[] {
const ids = Object.keys(this.accounts || {});
return ids.length > 0 ? ids : [this.accountId];
}
private buildStatePollingRequest(): { using: string[]; methodCalls: JMAPMethodCall[] } {
const using = ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:mail'];
const methodCalls: JMAPMethodCall[] = [
['Mailbox/get', { accountId: this.accountId, ids: null, properties: ['id'] }, 'a'],
['Email/get', { accountId: this.accountId, ids: [], properties: ['id'] }, 'b'],
];
const methodCalls: JMAPMethodCall[] = [];
for (const acctId of this.pollAccountIds()) {
methodCalls.push(
['Mailbox/get', { accountId: acctId, ids: null, properties: ['id'] }, `mbx:${acctId}`],
['Email/get', { accountId: acctId, ids: [], properties: ['id'] }, `eml:${acctId}`],
);
}
if (this.supportsCalendars()) {
using.push('urn:ietf:params:jmap:calendars');
@@ -5735,6 +5994,16 @@ export class JMAPClient implements IJMAPClient {
return { using, methodCalls };
}
/** Map a polled method response back to its (accountId, stateKey). */
private resolvePolledState(method: string, callId: unknown): { accountId: string; stateKey: string } | null {
if (typeof callId === 'string') {
if (callId.startsWith('mbx:')) return { accountId: callId.slice(4), stateKey: 'Mailbox' };
if (callId.startsWith('eml:')) return { accountId: callId.slice(4), stateKey: 'Email' };
}
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
return stateKey ? { accountId: this.accountId, stateKey } : null;
}
private async fetchCurrentStates(): Promise<void> {
if (this.isRateLimited()) {
return;
@@ -5749,10 +6018,10 @@ export class JMAPClient implements IJMAPClient {
if (response.ok) {
const data = await response.json();
for (const [method, result] of data.methodResponses) {
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
if (stateKey && result.state) {
this.pollingStates[stateKey] = result.state;
for (const [method, result, callId] of data.methodResponses) {
const resolved = this.resolvePolledState(method, callId);
if (resolved && result?.state) {
this.pollingStates[`${resolved.accountId}:${resolved.stateKey}`] = result.state;
}
}
}
@@ -5775,25 +6044,24 @@ export class JMAPClient implements IJMAPClient {
if (response.ok) {
const data = await response.json();
const changes: { [key: string]: string } = {};
let hasChanges = false;
// Build a per-account changed map so a background change in a shared
// (secondary) account is reported under its own accountId — which
// handleStateChange treats as "some mailbox changed" and refetches the
// full (own + delegated) mailbox list from.
const changedByAccount: Record<string, Record<string, string>> = {};
for (const [method, result] of data.methodResponses) {
const stateKey = JMAPClient.STATE_TYPE_MAP[method];
if (stateKey && result.state) {
if (this.pollingStates[stateKey] && this.pollingStates[stateKey] !== result.state) {
changes[stateKey] = result.state;
hasChanges = true;
}
this.pollingStates[stateKey] = result.state;
for (const [method, result, callId] of data.methodResponses) {
const resolved = this.resolvePolledState(method, callId);
if (!resolved || !result?.state) continue;
const key = `${resolved.accountId}:${resolved.stateKey}`;
if (this.pollingStates[key] && this.pollingStates[key] !== result.state) {
(changedByAccount[resolved.accountId] ??= {})[resolved.stateKey] = result.state;
}
this.pollingStates[key] = result.state;
}
if (hasChanges && this.stateChangeCallback) {
this.stateChangeCallback({
'@type': 'StateChange',
changed: { [this.accountId]: changes },
});
if (Object.keys(changedByAccount).length > 0 && this.stateChangeCallback) {
this.stateChangeCallback({ '@type': 'StateChange', changed: changedByAccount });
}
}
} catch {
@@ -5806,6 +6074,10 @@ export class JMAPClient implements IJMAPClient {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
if (this.secondaryPollInterval) {
clearInterval(this.secondaryPollInterval);
this.secondaryPollInterval = null;
}
if (this.sseAbortController) {
this.sseAbortController.abort();
this.sseAbortController = null;
@@ -5972,8 +6244,8 @@ export class JMAPClient implements IJMAPClient {
// ── S/MIME raw-email helpers ─────────────────────────────────────
/** Fetch blob content as an ArrayBuffer (for S/MIME byte processing). */
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type);
async fetchBlobArrayBuffer(blobId: string, name?: string, type?: string, accountId?: string): Promise<ArrayBuffer> {
const url = this.getBlobDownloadUrl(blobId, name, type, accountId);
const response = await this.authenticatedFetch(url, {});
if (!response.ok) {
throw new Error(`Failed to fetch blob: ${response.status}`);
@@ -6044,7 +6316,7 @@ export class JMAPClient implements IJMAPClient {
}
/**
* Import a raw S/MIME message, move it to the Sent mailbox, and submit it.
* Import a raw S/MIME PGP/MIME message, move it to the Sent mailbox, and submit it.
* Encapsulates the full import → update → submit flow.
*/
async sendRawEmail(
@@ -6090,8 +6362,7 @@ export class JMAPClient implements IJMAPClient {
...(draftMailboxId ? {
onSuccessUpdateEmail: {
'#raw-submit': {
[`mailboxIds/${draftMailboxId}`]: null,
[`mailboxIds/${sentMailboxId}`]: true,
...mailboxIdsReplacement(sentMailboxId),
'keywords/$draft': null,
},
},
@@ -6133,6 +6404,86 @@ export class JMAPClient implements IJMAPClient {
: { scheduled: false, emailId, emailSubmissionId, isSmime: true };
}
/**
* Submit a raw email blob to the network via JMAP EmailSubmission without auto-archiving to Sent.
*/
async submitRawEmail(
blob: Blob,
identityId: string,
delayedUntil?: string,
envelopeRecipients?: string[],
): Promise<SendEmailResult> {
const mailboxes = await this.getMailboxes();
const draftsMailbox = mailboxes.find(mb => mb.role === 'drafts');
if (!draftsMailbox) {
throw new Error('Drafts mailbox not found');
}
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const file = new File([blob], 'message.eml', { type: 'message/rfc822' });
const { blobId } = await this.uploadBlob(file);
const identities = await this.getIdentities();
const identity = identities.find(item => item.id === identityId);
const envelope = createDelayedSubmissionEnvelope(identity?.email || this.username, holdForSeconds, envelopeRecipients);
//Temporarily import the raw email into Drafts to satisfy JMAP's requirement that an EmailSubmission references an existing Email.
// The Email will be destroyed after submission.
const methodCalls: [string, Record<string, unknown>, string][] = [
['Email/import', {
accountId: this.accountId,
emails: {
'temp-submit': {
blobId,
mailboxIds: { [draftsMailbox.id]: true },
keywords: { '$draft': true },
},
},
}, '0'],
['EmailSubmission/set', {
accountId: this.getSubmissionAccountId(),
create: {
'raw-submit': {
emailId: '#temp-submit',
identityId,
...(envelope ? { envelope } : {}),
},
},
//destroy the temporary email after submission to avoid leaving a draft behind.
onSuccessDestroyEmail: ['#raw-submit'],
}, '1'],
];
const response = await this.request(methodCalls);
let emailSubmissionId: string | undefined;
let serverSendAt: string | undefined;
for (const [methodName, result] of response.methodResponses ?? []) {
if (methodName.endsWith('/error')) {
throw new Error((result as { description?: string }).description || `Failed: ${(result as { type?: string }).type}`);
}
const r = result as { notCreated?: Record<string, { description?: string; type?: string }> };
if (r.notCreated) {
const firstErr = Object.values(r.notCreated)[0];
throw new Error(firstErr?.description || firstErr?.type || 'Failed to submit raw email');
}
if (methodName === 'EmailSubmission/set') {
const created = (result as { created?: Record<string, { id?: string; sendAt?: string }> }).created?.['raw-submit'];
emailSubmissionId = created?.id;
serverSendAt = created?.sendAt;
}
}
if (delayedUntil && emailSubmissionId && !serverSendAt) {
serverSendAt = await this.getEmailSubmissionSendAt(emailSubmissionId);
}
return delayedUntil
? { scheduled: true, emailSubmissionId, sendAt: serverSendAt, isSmime: true }
: { scheduled: false, emailSubmissionId, isSmime: true };
}
async getScheduledEmails(limit = 50, position = 0): Promise<{ emails: ScheduledEmail[]; hasMore: boolean; total: number; nextPosition: number }> {
if (!this.hasDelayedSend()) {
return { emails: [], hasMore: false, total: 0, nextPosition: position };
@@ -6258,8 +6609,7 @@ export class JMAPClient implements IJMAPClient {
...(draftsMailbox && sentMailbox ? {
onSuccessUpdateEmail: {
'#replacement': {
[`mailboxIds/${draftsMailbox.id}`]: null,
[`mailboxIds/${sentMailbox.id}`]: true,
...mailboxIdsReplacement(sentMailbox.id),
'keywords/$draft': null,
},
},
@@ -6291,15 +6641,17 @@ export class JMAPClient implements IJMAPClient {
return { scheduled: true, emailId, emailSubmissionId: replacementId, sendAt: finalSendAt };
}
async restoreEmailToDraft(emailId: string, draftMailboxId: string, sentMailboxId?: string): Promise<void> {
// The third parameter is intentionally unused: this restores an undo-send /
// canceled-scheduled message to be a draft, so it should live in Drafts *only*.
// A full mailboxIds replacement (rather than mailboxIds/<id> pointer patches)
// both drops the Sent copy without needing its id and stays safe for numeric
// mailbox ids — see mailboxIdsReplacement().
async restoreEmailToDraft(emailId: string, draftMailboxId: string, _sentMailboxId?: string): Promise<void> {
const update: Record<string, unknown> = {
[`mailboxIds/${draftMailboxId}`]: true,
...mailboxIdsReplacement(draftMailboxId),
'keywords/$draft': true,
'keywords/$seen': true,
};
if (sentMailboxId) {
update[`mailboxIds/${sentMailboxId}`] = null;
}
const response = await this.request([
['Email/set', {
accountId: this.accountId,
+7 -13
View File
@@ -217,6 +217,7 @@ export interface ThreadGroup {
participantNames: string[];// Unique participant names
hasUnread: boolean; // Any unread emails in thread
hasStarred: boolean; // Any starred emails in thread
hasPinned: boolean; // Any pinned emails in thread ($pinned keyword)
hasAttachment: boolean; // Any email has attachment
hasAnswered: boolean; // Any email has been replied to
hasForwarded: boolean; // Any email has been forwarded
@@ -891,19 +892,12 @@ export function isUnifiedMailboxId(id: string): boolean {
}
/**
* Virtual mailbox id for the gated "All Mail" view: every folder of a single
* account merged into one date-sorted list. Distinct from the unified mailbox
* ids above, which merge one role across multiple accounts. Which folders are
* included is a per-user setting (see `allMailFolderIds`).
*/
export const ALL_MAIL_MAILBOX_ID = '__all_mail__';
/**
* Cross-account "All …" views shown in the unified ("All accounts") section.
* Each merges messages across EVERY account (including shared/group folders),
* spanning all folders except junk/spam, sent, archive, trash and drafts, in
* one date-sorted list. Distinct from the per-role unified ids (one role across
* accounts) and from ALL_MAIL_MAILBOX_ID (all folders of a single account).
* Cross views shown in the unified ("Unified Mailbox") section: All mail /
* Unread / Starred. Each merges messages across the account boundary (the active
* account + its shared folders by default, or every logged-in account when the
* cross-account sub-option is on), narrowed by the user's folder selection (see
* `allMailFolderIds`). Distinct from the per-role unified ids (one role across
* accounts).
*/
export const CROSS_UNREAD = '__cross_unread__';
export const CROSS_STARRED = '__cross_starred__';
+26
View File
@@ -0,0 +1,26 @@
// Shared helper for global key listeners to decide whether a keyboard event
// originated from a typing context (input, textarea, select, contentEditable).
//
// Checking `document.activeElement` / `event.target` is NOT enough: inside a
// shadow root both are retargeted to the host element, so the QuotedHtml
// island's inner contentEditable (components/email/quoted-html.ts) looks like
// a plain <div> from outside. Single-key mailbox shortcuts then fire while the
// user is editing quoted text — up to and including deleting the open email on
// Backspace (#654). `composedPath()` sees through the shadow boundary and
// starts at the real inner target, so it is the reliable signal.
export function isEditableEventTarget(event: Event): boolean {
const path =
typeof event.composedPath === "function"
? event.composedPath()
: [event.target];
return path.some((node) => {
if (!(node instanceof HTMLElement)) return false;
const tag = node.tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "select") return true;
if (node.isContentEditable) return true;
// jsdom (tests) doesn't implement isContentEditable; browsers reflect the
// property into the attribute, so this also covers "plaintext-only".
const attr = node.getAttribute("contenteditable");
return attr === "" || attr === "true" || attr === "plaintext-only";
});
}
+19 -4
View File
@@ -1,4 +1,5 @@
import { debug } from '@/lib/debug';
import { withBasePath } from '@/lib/browser-navigation';
export type NotificationSoundChoice = 'default' | 'cheerful' | 'involved' | 'swift' | 'relax';
@@ -20,15 +21,29 @@ function playBeep() {
oscillator.frequency.value = 800;
oscillator.type = 'sine';
gainNode.gain.value = 0.1;
oscillator.start();
oscillator.stop(audioContext.currentTime + 0.15);
// Longer, enveloped tone. A 150 ms blip was easy to miss on Bluetooth
// outputs, whose audio path can take 100-200 ms to wake up and route - by
// the time sound reached the headphones the blip was already over. The
// fade in/out also avoids click artifacts.
const now = audioContext.currentTime;
const duration = 0.45;
const peak = 0.12;
gainNode.gain.setValueAtTime(0.0001, now);
gainNode.gain.exponentialRampToValueAtTime(peak, now + 0.04);
gainNode.gain.setValueAtTime(peak, now + duration - 0.08);
gainNode.gain.exponentialRampToValueAtTime(0.0001, now + duration);
oscillator.start(now);
oscillator.stop(now + duration + 0.02);
oscillator.onended = () => audioContext.close();
}
function playFile(file: string) {
const audio = new Audio(file);
// Prefix with the deployment base path (e.g. /webmail); a raw "/notification/
// x.mp3" 404s under a subpath, which made playFile fall back to the beep for
// every choice.
const audio = new Audio(withBasePath(file));
audio.volume = 0.3;
audio.play().catch((e) => {
debug.log('push', 'Could not play audio file, falling back to beep:', e);
+85 -19
View File
@@ -21,6 +21,26 @@ const CACHE_TTL_MS = 10 * 60 * 1000;
const CACHE_MAX_ENTRIES = 64;
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
// --- Discovery hardening ---------------------------------------------------
// The login page's "Sign in with SSO" button is gated on OIDC discovery
// succeeding. An un-timed, un-retried fetch that dropped its cached value on
// failure let a single transient blip to the IdP silently hide the button.
// A per-fetch timeout, one retry, and serving stale-but-usable metadata on
// failure keep the button up through a transient blip.
const DISCOVERY_TIMEOUT_MS = 4000;
const DISCOVERY_RETRIES = 1;
const DISCOVERY_RETRY_DELAY_MS = 300;
// When discovery fails, remember the outcome briefly so repeated login-page
// loads during an outage don't hammer the IdP. Also throttles re-discovery
// while serving stale metadata. Kept short so recovery is fast.
const DISCOVERY_FAILURE_TTL_MS = 15 * 1000;
// Records recent failures for serverUrls that have no cached metadata to serve.
const negativeCache = new Map<string, number>();
const sleep = (ms: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, ms));
function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
// Bound the cache so callers that can supply arbitrary serverUrl values
// (e.g. unauthenticated routes that fall back to user input) cannot
@@ -33,6 +53,16 @@ function rememberMetadata(serverUrl: string, metadata: OAuthMetadata): void {
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
}
function rememberFailure(serverUrl: string): void {
// Bound like metadataCache: a user-supplied serverUrl must not grow this map
// without limit.
if (negativeCache.size >= CACHE_MAX_ENTRIES) {
const oldest = negativeCache.keys().next().value;
if (oldest !== undefined) negativeCache.delete(oldest);
}
negativeCache.set(serverUrl, Date.now() + DISCOVERY_FAILURE_TTL_MS);
}
// Endpoints come from an attacker-controllable JSON document when callers pass
// a user-supplied serverUrl (e.g. /api/auth/totp-token-exchange under
// allowCustomJmapEndpoint). Without a validator, a malicious metadata document
@@ -52,24 +82,18 @@ async function endpointsArePublic(
return true;
}
export async function discoverOAuth(
serverUrl: string,
options?: DiscoverOAuthOptions,
// One pass over the well-known documents. Returns usable metadata, or null
// (pushing diagnostics into `errors`) when neither URL yields a public,
// complete document. Each fetch is bounded by a timeout so an unresponsive IdP
// can never hang the request (and, with it, the login page's SSO button).
async function attemptDiscovery(
urls: string[],
validate: EndpointValidator | undefined,
errors: string[],
): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
if (cached) metadataCache.delete(serverUrl);
const urls = [
`${serverUrl}/.well-known/oauth-authorization-server`,
`${serverUrl}/.well-known/openid-configuration`,
];
const errors: string[] = [];
for (const url of urls) {
try {
const response = await fetch(url);
const response = await fetch(url, { signal: AbortSignal.timeout(DISCOVERY_TIMEOUT_MS) });
if (!response.ok) {
errors.push(`${url} returned HTTP ${response.status}`);
continue;
@@ -82,20 +106,18 @@ export async function discoverOAuth(
data.token_endpoint,
data.revocation_endpoint,
data.end_session_endpoint,
], options?.validateEndpoint);
], validate);
if (!allPublic) {
errors.push(`${url} returned non-public or invalid endpoint URL`);
continue;
}
const metadata: OAuthMetadata = {
return {
issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint,
token_endpoint: data.token_endpoint,
revocation_endpoint: data.revocation_endpoint,
end_session_endpoint: data.end_session_endpoint,
};
rememberMetadata(serverUrl, metadata);
return metadata;
}
errors.push(`${url} response missing required endpoints`);
} catch (err) {
@@ -103,7 +125,51 @@ export async function discoverOAuth(
continue;
}
}
return null;
}
export async function discoverOAuth(
serverUrl: string,
options?: DiscoverOAuthOptions,
): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
// A stale entry is deliberately retained (not deleted) so it can be served
// as a fallback below if the refresh fails - this keeps the SSO button up
// through a transient IdP blip.
// Nothing to serve and we failed recently: skip hammering the IdP.
if (!cached) {
const retryAfter = negativeCache.get(serverUrl);
if (retryAfter !== undefined && retryAfter > Date.now()) return null;
}
const urls = [
`${serverUrl}/.well-known/oauth-authorization-server`,
`${serverUrl}/.well-known/openid-configuration`,
];
const errors: string[] = [];
for (let attempt = 0; attempt <= DISCOVERY_RETRIES; attempt++) {
if (attempt > 0) await sleep(DISCOVERY_RETRY_DELAY_MS);
const metadata = await attemptDiscovery(urls, options?.validateEndpoint, errors);
if (metadata) {
rememberMetadata(serverUrl, metadata);
negativeCache.delete(serverUrl);
return metadata;
}
}
// Every attempt failed. Prefer stale-but-usable metadata over nothing so the
// login page keeps rendering the SSO button during the outage; throttle the
// next re-discovery so we don't retry on every request.
if (cached) {
cached.expiresAt = Date.now() + DISCOVERY_FAILURE_TTL_MS;
console.warn(`[OAuth] Discovery refresh failed for ${serverUrl}; serving stale metadata: ${errors.join('; ')}`);
return cached.metadata;
}
rememberFailure(serverUrl);
console.error(`[OAuth] Discovery failed for ${serverUrl}: ${errors.join('; ')}`);
return null;
}
+21
View File
@@ -183,7 +183,14 @@ export const emailHooks = {
onComposerOpen: new HookBus(),
onBeforeEmailSend: new HookBus(),
onAfterEmailSend: new HookBus(),
// Transform hook - fires before the draft is auto-saved to the server.
// Receive fields passed to client.createDraft and may mutate fields in place.
// Return false to cancel the auto-save or a the fields.
onBeforeDraftAutoSave: new HookBus(),
onDraftAutoSave: new HookBus(),
// Transform hook - fires before a draft is created from an email in draft mailbox.
// Receive a Email object and may mutate fields in place.
onBeforeEditDraft: new HookBus(),
onBeforeEmailDelete: new HookBus(),
onAfterEmailDelete: new HookBus(),
onBeforeEmailMove: new HookBus(),
@@ -232,6 +239,12 @@ export const emailHooks = {
// attachment. Handler receives AttachmentInfo (size/type/name only - the
// raw file is not exposed). Return false to refuse the upload.
onBeforeAttachmentUpload: new HookBus(),
// Intercept hook fired before a file is uploaded to JMAP server.
// It is fired after onBeforeAttachmentUpload.
// Handler receive the {file: File, blobId: 'undefined'} object.
// If it uploaded, it must return the object with true blobId.
// Here, the raw file sended is exposed and can be modified or replaced.
onBeforeBlobUpload: new HookBus(),
// Observer fired after an attachment has been uploaded and its blobId is
// available. Handler receives AttachmentInfo with `blobId` populated.
onAfterAttachmentUpload: new HookBus(),
@@ -259,6 +272,14 @@ export const emailHooks = {
// normally. This is the send-takeover hook used by the S/MIME plugin to
// replace the former native sign+encrypt+sendRaw pipeline.
onComposeSend: new HookBus(),
// Transform hook - receive Email[] or ScheduledEmail[] just after there are fetched to
// lets plugin edit emails before they are shown in row. Used to populate preview
// field for encryption plugins.
onEmailsFetched: new HookBus(),
// Transform hook - lets plugins edit the recipient chips in composer fields.
// They can add, remove, or modify chips (e.g. rewrite addresses, add colors/icons).
// Take Recipient[] as argument.
onRecipientChipsChange: new HookBus(),
};
// §7.2 Calendar Hooks
+2
View File
@@ -60,6 +60,7 @@ const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
'crypto:full': { title: 'Full cryptographic access (high risk)', body: 'Runs with full cryptographic access in a privileged, same-origin context. It can read your message bodies and private keys, store key material, and sign/encrypt on your behalf. Only enable plugins you fully trust — this is comparable to a full-access browser extension.' },
'email:raw-send': { title: 'Send raw messages', body: 'Submit fully-formed (e.g. signed or encrypted) messages on your behalf.' },
'email:blob-read': { title: 'Read raw message content', body: 'Fetch the raw bytes of your messages and attachments (needed to decrypt and verify them).' },
'email:blob-write': { title: 'Alterate raw message content', body: 'get the raw file content before it is uploaded to alterate it just before is is sended to server. (needed to encrypt)' },
'email:render-takeover': { title: 'Replace rendered email content', body: 'Replace the displayed content of an opened message (e.g. to show decrypted text and a signature-verification badge).' },
'calendar:read': { title: 'Read your calendar', body: 'Access events, calendars, RSVPs, and reminders.' },
'calendar:write': { title: 'Modify your calendar', body: 'Create, edit, or delete events.' },
@@ -85,6 +86,7 @@ const PERMISSION_LABELS: Record<string, { title: string; body: string }> = {
'http:post': { title: 'Call same-origin APIs', body: 'Make authenticated requests to the webmail backend on your behalf.' },
'http:fetch': { title: 'Talk to external services', body: 'Make uncredentialled requests to the third-party origins listed in the manifest.' },
'admin:config': { title: 'Read/write admin config', body: 'Access this plugin\'s admin-supplied configuration values.' },
'ui:download-file': { title: 'Download files', body: 'Download custom files generated by the plugin.' },
};
export function describePermission(perm: string): { title: string; body: string } {
+295 -2
View File
@@ -8,7 +8,9 @@ import { toast as appToast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { apiFetch } from '../browser-navigation';
import { awaitDialog } from './host-dialog';
import { awaitDialog, awaitPrompt, type PromptField } from './host-dialog';
import { fileStorage } from '../plugin-storage';
import { generateUUID } from '../utils';
/**
* Methods only callable from the privileged (same-origin) tier. These expose
@@ -19,6 +21,11 @@ import { awaitDialog } from './host-dialog';
const PRIVILEGED_ONLY_METHODS = new Set<string>([
'jmap.fetchBlob',
'jmap.sendRaw',
'jmap.submitRaw',
'jmap.importRaw',
'upfiles.get',
'webauthn.getOrCreate',
'upfiles.set',
]);
const PERM_PER_METHOD: Record<string, Permission | null> = {
@@ -38,6 +45,14 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
// jmap (privileged-tier only; see PRIVILEGED_ONLY_METHODS)
'jmap.fetchBlob': 'email:blob-read',
'jmap.sendRaw': 'email:raw-send',
'jmap.submitRaw': 'email:raw-send',
'jmap.importRaw': 'email:raw-send',
// uploaded files (privileged-tier only) :
// Used only to get a file before it is uploaded to alterate it.
// To just read, use jmap.fetchBlob.
'upfiles.get' : 'email:blob-write',
'upfiles.save' : 'email:blob-write',
'webauthn.getOrCreate': 'crypto:full',
// admin
'admin.getConfig': 'admin:config',
'admin.getAllConfig': 'admin:config',
@@ -46,7 +61,10 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
// ui - any plugin can ask the host to render a modal or open a URL.
'ui.confirm': null,
'ui.alert': null,
'ui.prompt': null,
'ui.rerenderEmail': null,
'ui.openExternalUrl': null,
'ui.downloadFile': 'ui:download-file'
};
function hasPermission(plugin: InstalledPlugin, perm: Permission): boolean {
@@ -233,6 +251,11 @@ async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: st
return new Uint8Array(buf);
}
interface JmapSubmitRawOptions {
delayedUntil?: string;
envelopeRecipients?: string[];
}
/**
* Submit a fully-formed raw RFC822 message (e.g. one a plugin has signed and/or
* encrypted) via the host's raw-send path, which also files it into Sent. The
@@ -241,7 +264,7 @@ async function doJmapFetchBlob(blobId: string, opts?: { name?: string; type?: st
async function doJmapSendRaw(
rawBytes: ArrayBuffer | ArrayBufferView,
identityId: string,
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
opts?: JmapSubmitRawOptions,
): Promise<unknown> {
if (typeof identityId !== 'string' || !identityId) throw new Error('jmap.sendRaw: identityId required');
const { client } = useAuthStore.getState();
@@ -263,6 +286,230 @@ async function doJmapSendRaw(
);
}
/**
* submit a fully-formed raw RFC822 message without putting it in sent box.
*/
async function doJmapSubmitRaw(
rawBytes: ArrayBuffer | ArrayBufferView,
identityId: string,
opts?: JmapSubmitRawOptions,
): Promise<unknown> {
if (typeof identityId !== 'string' || !identityId) {
throw new Error('jmap.submitRaw: identityId required');
}
const { client } = useAuthStore.getState();
if (!client) {
throw new Error('jmap.submitRaw: no active session');
}
const view = rawBytes instanceof ArrayBuffer
? new Uint8Array(rawBytes)
: new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength);
const copy = new Uint8Array(view.byteLength);
copy.set(view);
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
return client.submitRawEmail(
blob,
identityId,
opts?.delayedUntil,
opts?.envelopeRecipients,
);
}
interface JmapImportRawOptions {
keywords?: Record<string, boolean>;
accountId?: string;
}
/**
* Import a fully-formed raw RFC822 message into the user's mailbox.
*/
async function doJmapImportRaw(
rawBytes: ArrayBuffer | ArrayBufferView,
mailboxRoles: string[],
opts?: JmapImportRawOptions,
): Promise<string> {
const { client } = useAuthStore.getState();
if (!client) {
throw new Error('jmap.importRaw: no active session');
}
let mailboxIds: Record<string, boolean> = {};
const mailboxes = await client.getMailboxes();
for (const role of mailboxRoles) {
const mailbox = mailboxes.find(mb => mb.role === role);
if (!mailbox) {
throw new Error(`Mailbox with role "${role}" not found`);
}
mailboxIds[mailbox.id] = true;
}
if (Object.keys(mailboxIds).length === 0) {
throw new Error('No valid mailboxes found for the specified roles');
}
const view = rawBytes instanceof ArrayBuffer
? new Uint8Array(rawBytes)
: new Uint8Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength);
const copy = new Uint8Array(view.byteLength);
copy.set(view);
const blob = new Blob([copy.buffer], { type: 'message/rfc822' });
return client.importRawEmail(
blob,
mailboxIds,
opts?.keywords,
opts?.accountId,
);
}
// ─── WebAuthn (privileged tier) ─────────────────────────────────────────────
// This salt acts as a constant context identifier for key derivation.
// While hardcoded, security is maintained because the WebAuthn PRF extension
// mixes this salt with the device's unique, hardware-bound private key.
// Changing this string will result in a completely different derived secret.
const PRF_SALT = new TextEncoder().encode("bulwark-plugins-v1");
/**
* Retrieves or creates a WebAuthn passkey and extracts its PRF secret.
* This secret is typically used as a local master encryption key.
*/
async function doGetOrCreatePRF(
masterCredentialIdBytes: number[] | undefined,
name?: string,
displayName?: string
): Promise<{ credentialId: number[]; prfSecret: number[] } | string> {
// ─── CASE 1: Credential already exists (Authentication) ──────────────────
if (masterCredentialIdBytes && masterCredentialIdBytes.length > 0) {
const credentialId = new Uint8Array(masterCredentialIdBytes).buffer;
// Request an assertion (login) while evaluating the PRF salt
const assertion = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
allowCredentials: [{ type: "public-key", id: credentialId }],
userVerification: "required", // Required to ensure user presence & intent (biometrics/PIN)
extensions: { prf: { eval: { first: PRF_SALT } } } as any
}
}) as PublicKeyCredential;
// Extract the derived symmetric key from the authenticator's output
const outputs = assertion.getClientExtensionResults();
const prfSecret = (outputs as any).prf?.results?.first;
if (!prfSecret) return 'Cannot get PRF secret from existing credential.';
return {
credentialId: masterCredentialIdBytes,
prfSecret: Array.from(new Uint8Array(prfSecret))
};
}
// ─── CASE 2: No masterCredentialIdBytes passed, create a new key (Registration) ──────────
else if (name && displayName) {
// Create the new passkey credential
const credential = await navigator.credentials.create({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
rp: { name: "Bulwark Webmail", id: window.location.hostname },
user: {
id: crypto.getRandomValues(new Uint8Array(16)),
name: name,
displayName: displayName
},
// Supported cryptographic algorithms
pubKeyCredParams: [
{ type: "public-key" as const, alg: -7 }, // ES256 (Recommended)
{ type: "public-key" as const, alg: -257 } // RS256 (Compatibility fallback)
],
authenticatorSelection: {
authenticatorAttachment: "platform", // Forces the use of hardware/OS-bound passkeys (TouchID, Windows Hello, etc.)
userVerification: "required"
},
extensions: { prf: {} } as any // Request PRF extension support from the authenticator
}
}) as PublicKeyCredential;
const outputs = credential.getClientExtensionResults();
// Ensure the authenticator successfully enabled and supports the PRF extension
const isPrfEnabled = (outputs as any).prf?.enabled;
if (!isPrfEnabled) {
return 'The authenticator does not support or has rejected the PRF extension.';
}
// Note: Since many authenticators do not return the PRF evaluation results
// directly during creation, we immediately run an assertion (get) to fetch the initial secret.
const assertion = await navigator.credentials.get({
publicKey: {
challenge: crypto.getRandomValues(new Uint8Array(32)),
allowCredentials: [{
type: "public-key",
id: credential.rawId
}],
userVerification: "required",
extensions: {
prf: { eval: { first: PRF_SALT } }
} as any
}
}) as PublicKeyCredential;
const assertionOutputs = assertion.getClientExtensionResults();
const prfSecret = (assertionOutputs as any).prf?.results?.first;
if (!prfSecret) {
return 'Cannot get PRF secret from existing credential.';
}
return {
credentialId: Array.from(new Uint8Array(credential.rawId)),
prfSecret: Array.from(new Uint8Array(prfSecret))
};
}
// ─── CASE 3: Insufficient parameters provided ───────────────────────────
else {
throw new Error("Provide name and display name if you want to create a new PRF.");
}
}
// ─── Uploaded files in IndexedDB (privileged tier) ──────────────────────────
async function getFile(fileID:string): Promise<File | null> {
return await fileStorage.getFile(fileID)
}
async function saveFile(formerFileID:string, file: File): Promise<string> {
const fileId = generateUUID();
await fileStorage.saveFile(fileId, file);
await fileStorage.deleteFile(formerFileID);
return fileId;
}
// ─── Download files generated by the plugin. This is not user's files or attachments ──────────────────────────
async function downloadFile(args: { content: string; filename: string; contentType?: string }): Promise<void> {
const { content, filename, contentType = 'application/json' } = args;
try {
const url = URL.createObjectURL(new Blob([content], { type: contentType }));
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
throw new Error(`Failed to download file: ${error}`);
}
}
// ─── admin config (same as before) ────────────────────────────
async function adminGetAll(pluginId: string): Promise<Record<string, unknown>> {
@@ -335,6 +582,19 @@ export async function dispatchApiCall(
args[1] as string,
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
);
case 'jmap.submitRaw': return doJmapSubmitRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string,
args[2] as { delayedUntil?: string; envelopeRecipients?: string[] } | undefined,
);
case 'jmap.importRaw': return doJmapImportRaw(
args[0] as ArrayBuffer | ArrayBufferView,
args[1] as string[],
args[2] as { keywords?: Record<string, boolean>; accountId?: string } | undefined,
);
case 'upfiles.get' : return getFile(args[0] as string);
case 'upfiles.save' : return saveFile(args[0] as string, args[1] as File);
case 'webauthn.getOrCreate': return doGetOrCreatePRF(args[0] as number[] | undefined, args[1] as string | undefined, args[2] as string | undefined);
case 'admin.getConfig': return adminGet(plugin.id, args[0] as string);
case 'admin.getAllConfig': return adminGetAll(plugin.id);
@@ -364,6 +624,35 @@ export async function dispatchApiCall(
});
return undefined;
}
case 'ui.prompt': {
const opts = (args[0] ?? {}) as { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; fields?: PromptField[] };
const fields: PromptField[] = Array.isArray(opts.fields)
? opts.fields.map((f) => ({
name: String(f.name),
label: String(f.label),
type: f.type === 'password' ? 'password' : 'text',
placeholder: typeof f.placeholder === 'string' ? f.placeholder : undefined,
required: !!f.required,
}))
: [];
return awaitPrompt({
pluginId: plugin.id,
kind: 'prompt',
title: String(opts.title ?? plugin.name ?? 'Enter details'),
message: String(opts.message ?? ''),
confirmLabel: typeof opts.confirmLabel === 'string' ? opts.confirmLabel : undefined,
cancelLabel: typeof opts.cancelLabel === 'string' ? opts.cancelLabel : undefined,
fields,
});
}
case 'ui.rerenderEmail': {
// Re-run the onRenderEmailBody hook for the currently open message. Used
// by crypto plugins after they change decryption state (e.g. an S/MIME key
// was just unlocked) so the body re-decrypts without a full reload — which
// would wipe the in-memory session keys.
window.dispatchEvent(new CustomEvent('plugin:rerender-email'));
return undefined;
}
case 'ui.openExternalUrl': {
const url = String(args[0] ?? '');
// Only http(s) - the sandbox should not be able to navigate the host
@@ -378,6 +667,10 @@ export async function dispatchApiCall(
window.open(parsed.toString(), '_blank', 'noopener,noreferrer');
return undefined;
}
case 'ui.downloadFile': {
const opts = args[0] as { content: string; filename: string; contentType?: string };
return downloadFile(opts);
}
default:
throw new Error(`Unhandled method "${method}"`);
+49 -13
View File
@@ -1,9 +1,28 @@
// Process-wide queue for plugin-requested host dialogs (confirm / alert).
// The sandboxed plugin posts a `ui.confirm` API request; the host enqueues a
// dialog here and resolves the awaited Promise after the user clicks. The
// `PluginDialogHost` component subscribes and renders one dialog at a time.
// Process-wide queue for plugin-requested host dialogs (confirm / alert /
// prompt). The sandboxed plugin posts a `ui.confirm`/`ui.prompt` API request;
// the host enqueues a dialog here and resolves the awaited Promise after the
// user acts. The `PluginDialogHost` component subscribes and renders one dialog
// at a time. Prompts collect one or more (optionally masked) text fields so a
// plugin never has to fall back to the sandbox-blocked `window.prompt`.
export type DialogKind = 'confirm' | 'alert';
export type DialogKind = 'confirm' | 'alert' | 'prompt';
export interface PromptField {
/** Key the field's value is returned under. */
name: string;
label: string;
/** `password` masks the input; defaults to `text`. */
type?: 'text' | 'password';
placeholder?: string;
/** Submit is blocked until every required field is non-empty. */
required?: boolean;
}
/**
* confirm/alert resolve to a boolean; prompt resolves to a namevalue map on
* submit, or `null` when cancelled.
*/
export type DialogResult = boolean | Record<string, string> | null;
export interface DialogRequest {
id: string;
@@ -15,8 +34,10 @@ export interface DialogRequest {
cancelLabel?: string;
/** When true, confirm button uses destructive styling. */
danger?: boolean;
/** Called when the dialog closes. `ok` is true only for confirm-accept. */
resolve: (ok: boolean) => void;
/** Fields to collect, for `kind === 'prompt'`. */
fields?: PromptField[];
/** Called when the dialog closes with its typed result (see DialogResult). */
resolve: (result: DialogResult) => void;
}
const queue: DialogRequest[] = [];
@@ -43,13 +64,18 @@ export function head(): DialogRequest | null {
return queue[0] ?? null;
}
export function resolveHead(ok: boolean): void {
export function resolveHead(result: DialogResult): void {
const entry = queue.shift();
if (!entry) return;
try { entry.resolve(ok); } catch { /* ignore */ }
try { entry.resolve(result); } catch { /* ignore */ }
notify();
}
/** The "cancelled" result for a given dialog kind (null for prompt, else false). */
function cancelledResult(kind: DialogKind): DialogResult {
return kind === 'prompt' ? null : false;
}
/** Cancel every pending dialog for a plugin (called on unload). */
export function cancelForPlugin(pluginId: string): void {
let changed = false;
@@ -57,7 +83,7 @@ export function cancelForPlugin(pluginId: string): void {
if (queue[i].pluginId === pluginId) {
const entry = queue[i];
queue.splice(i, 1);
try { entry.resolve(false); } catch { /* ignore */ }
try { entry.resolve(cancelledResult(entry.kind)); } catch { /* ignore */ }
changed = true;
}
}
@@ -70,11 +96,21 @@ export function subscribe(listener: () => void): () => void {
}
/**
* Internal helper used by host-api to convert an `enqueueDialog` call into a
* Promise the plugin-side `await` can land on.
* Internal helper used by host-api to convert a confirm/alert `enqueueDialog`
* call into a boolean Promise the plugin-side `await` can land on.
*/
export function awaitDialog(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<boolean> {
return new Promise<boolean>((resolve) => {
enqueueDialog({ ...req, resolve });
enqueueDialog({ ...req, resolve: (r) => resolve(r === true) });
});
}
/**
* Prompt variant of `awaitDialog`: resolves to the collected namevalue map on
* submit, or `null` when the user cancels/dismisses.
*/
export function awaitPrompt(req: Omit<DialogRequest, 'id' | 'resolve'>): Promise<Record<string, string> | null> {
return new Promise((resolve) => {
enqueueDialog({ ...req, resolve: (r) => resolve(r && typeof r === 'object' ? r : null) });
});
}
+3 -1
View File
@@ -241,10 +241,12 @@ export const SANDBOX_PRIVILEGED_PATH = '/plugin-sandbox-privileged';
export const API_METHODS = [
'storage.get', 'storage.set', 'storage.remove', 'storage.keys',
'http.post', 'http.fetch',
'webauthn.getOrCreate',
'jmap.fetchBlob', 'jmap.sendRaw',
'upfiles.get', 'upfiles.save',
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
'toast.success', 'toast.error', 'toast.info', 'toast.warning',
'ui.confirm', 'ui.alert', 'ui.openExternalUrl',
'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl', 'ui.downloadFile'
] as const;
export type ApiMethod = (typeof API_METHODS)[number];
+41
View File
@@ -165,6 +165,9 @@ function buildPluginApi(manifest: PluginManifest) {
version: manifest.version,
settings: { ...manifest.settings },
},
webauthn: {
getOrCreate: (masterCredentialIdBytes?: number[], name?: string, displayName?: string) => callApi('webauthn.getOrCreate', [masterCredentialIdBytes, name, displayName], 0)
},
storage: {
get: (key: string) => callApi('storage.get', [key]),
set: (key: string, value: unknown) => callApi('storage.set', [key, value]),
@@ -188,6 +191,28 @@ function buildPluginApi(manifest: PluginManifest) {
identityId: string,
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
) => callApi('jmap.sendRaw', [rawBytes, identityId, opts]),
/** Submit without putting in sent box a fully-formed raw RFC822 message (already signed/encrypted). */
submitRaw: (
rawBytes: ArrayBuffer | ArrayBufferView,
identityId: string,
opts?: { delayedUntil?: string; envelopeRecipients?: string[] },
) => callApi('jmap.submitRaw', [rawBytes, identityId, opts]),
/** Import a fully-formed raw RFC822 message into the user's mailbox. */
importRaw: (
rawBytes: ArrayBuffer | ArrayBufferView,
mailboxRoles: string[],
opts?: { keywords?: Record<string, boolean>; accountId?: string },
) => callApi('jmap.importRaw', [rawBytes, mailboxRoles, opts]),
},
/**
* Used to alterate files before they are uploaded to server.
* Edited files are saved on indexedDB and remove once the upload to server begins.
*/
upfiles: {
save: (formerFileId:string, file:File) =>
callApi('upfiles.save', [formerFileId, file]) as Promise<string>,
get: (fileId:string) =>
callApi('upfiles.get', [fileId]) as Promise<File>,
},
toast: {
success: (m: string) => { void callApi('toast.success', [m]); },
@@ -203,9 +228,25 @@ function buildPluginApi(manifest: PluginManifest) {
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
callApi('ui.alert', [opts], 0) as Promise<void>,
/** Opens a host-rendered prompt collecting one or more (optionally masked)
* fields. Resolves to a namevalue map on submit, or null if cancelled.
* No timeout. */
prompt: (opts: {
title?: string;
message?: string;
confirmLabel?: string;
cancelLabel?: string;
fields?: Array<{ name: string; label: string; type?: 'text' | 'password'; placeholder?: string; required?: boolean }>;
}) => callApi('ui.prompt', [opts], 0) as Promise<Record<string, string> | null>,
/** Re-runs the onRenderEmailBody hook for the open message (e.g. after a
* crypto plugin unlocks a key) so its body re-renders without a reload. */
rerenderEmail: () => callApi('ui.rerenderEmail', []) as Promise<void>,
/** Opens an http/https URL in a new tab via host `window.open`. */
openExternalUrl: (url: string, target?: string) =>
callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
/** Downloads a file generated by the plugin. Not a user's file or attachment. */
downloadFile: (opts: { content: string; filename: string; contentType?: string }) =>
callApi('ui.downloadFile', [opts]) as Promise<void>,
},
admin: {
getConfig: (key: string) => callApi('admin.getConfig', [key]),
+2 -9
View File
@@ -8,6 +8,7 @@
// The listener ignores events when an editable element has focus, matching
// the convention in `use-keyboard-shortcuts.ts`.
import { isEditableEventTarget } from '@/lib/keyboard';
import type { SandboxInstance } from './host-bridge';
interface Binding {
@@ -62,16 +63,8 @@ function eventMatches(ev: KeyboardEvent, combo: NormalisedCombo): boolean {
return ev.key.toLowerCase() === combo.key;
}
function isEditableTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName.toLowerCase();
if (tag === 'input' || tag === 'textarea' || tag === 'select') return true;
if (target.isContentEditable) return true;
return false;
}
function onKeyDown(ev: KeyboardEvent): void {
if (isEditableTarget(ev.target)) return;
if (isEditableEventTarget(ev)) return;
if (bindings.size === 0) return;
for (const binding of bindings.values()) {
const combo = normaliseCombo(binding.keys);
+23 -3
View File
@@ -1,12 +1,13 @@
// IndexedDB storage for plugin/theme binary blobs (JS bundles, CSS, previews)
const DB_NAME = 'bulwark-plugins';
// Bumped to 2 to add the theme-skin store; existing stores are preserved.
const DB_VERSION = 2;
// Bumped to 3 to add the file-plugin store; existing stores are preserved.
const DB_VERSION = 3;
const STORE_PLUGINS = 'plugin-code';
const STORE_THEMES = 'theme-css';
const STORE_THEME_SKINS = 'theme-skin';
const STORE_PREVIEWS = 'previews';
const STORE_FILE_ACCESS_PLUGIN = 'file-plugin'
function openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
@@ -26,6 +27,9 @@ function openDB(): Promise<IDBDatabase> {
if (!db.objectStoreNames.contains(STORE_PREVIEWS)) {
db.createObjectStore(STORE_PREVIEWS);
}
if (!db.objectStoreNames.contains(STORE_FILE_ACCESS_PLUGIN)) {
db.createObjectStore(STORE_FILE_ACCESS_PLUGIN);
}
};
request.onsuccess = () => resolve(request.result);
@@ -33,7 +37,7 @@ function openDB(): Promise<IDBDatabase> {
});
}
async function putItem(storeName: string, key: string, value: string | Blob): Promise<void> {
async function putItem(storeName: string, key: string, value: string | Blob | File): Promise<void> {
const db = await openDB();
return new Promise((resolve, reject) => {
const tx = db.transaction(storeName, 'readwrite');
@@ -111,3 +115,19 @@ export const pluginStorage = {
await deleteItem(STORE_PREVIEWS, id);
},
};
/**
* Used in host-api for plugins to access to raw data files
* to modify them before they are uploaded.
*/
export const fileStorage = {
async saveFile(fileId: string, file: File): Promise<void> {
await putItem(STORE_FILE_ACCESS_PLUGIN, fileId, file);
},
async getFile(fileId: string): Promise<File | null> {
return getItem<File>(STORE_FILE_ACCESS_PLUGIN, fileId);
},
async deleteFile(fileId: string): Promise<void> {
await deleteItem(STORE_FILE_ACCESS_PLUGIN, fileId);
},
}
+24
View File
@@ -674,6 +674,22 @@ export interface OutgoingEmail {
/** Free-form custom headers added by the composer or earlier handlers */
headers?: Record<string, string>;
}
/**
* Passed to onBeforeDraftAutoSave handlers as a transform value.
*/
export interface AlmostSavedDraft{
to: string[],
subject: string,
body: string,
cc?: string[],
bcc?: string[],
identityId?: string,
fromEmail?: string,
draftId?: string,
attachments?: Array<{ blobId: string; name: string; type: string; size: number; disposition?: 'attachment' | 'inline'; cid?: string }>,
fromName?: string,
htmlBody?: string
}
/**
* Passed to onBeforeReply / onBeforeReplyAll / onBeforeForward intercept hooks.
@@ -819,6 +835,11 @@ export interface RecipientSuggestion {
/** Optional source label rendered as a small tag */
source?: string;
avatarUrl?: string;
/**
* Present when the suggestion is a contact group (empty email). Selecting
* it inserts a single group chip that expands into the members on send.
*/
group?: { id: string; memberCount: number };
}
/**
@@ -885,6 +906,8 @@ export const ALL_PERMISSIONS = [
'email:raw-send',
// Fetch a message blob's raw bytes by blobId (for decrypt/verify).
'email:blob-read',
// Upload a file to server (for encrypt).
'email:blob-write',
// Replace the rendered body of an opened email (render-takeover).
'email:render-takeover',
'calendar:read', 'calendar:write',
@@ -902,6 +925,7 @@ export const ALL_PERMISSIONS = [
'http:post', 'http:fetch',
'ui:observe', 'ui:toolbar', 'ui:app-top-banner', 'ui:email-banner', 'ui:email-footer',
'ui:email-details',
'ui:download-file',
'ui:composer-toolbar', 'ui:composer-sidebar',
'ui:sidebar-widget', 'ui:settings-section',
'ui:context-menu', 'ui:navigation-rail', 'ui:keyboard',
+28
View File
@@ -67,6 +67,34 @@ export function findReplyIdentityId(
return baseIdentity?.id ?? null;
}
/**
* Pick the identity to use for a NEW message started while viewing a specific
* mailbox/account. Matches the active mailbox's address to a configured
* identity (exact, then `+tag`-stripped) so composing from info@ defaults its
* From to info@. Returns `null` when no address is given or none matches, so
* the caller keeps the primary identity.
*/
export function findComposeIdentityId(
identities: Identity[],
accountEmail?: string | null,
): string | null {
const email = accountEmail?.trim();
if (identities.length === 0 || !email) {
return null;
}
const exact = normalizeEmailAddress(email);
const exactIdentity = identities.find((identity) => normalizeEmailAddress(identity.email) === exact);
if (exactIdentity) {
return exactIdentity.id;
}
const base = normalizeBaseEmailAddress(email);
const baseIdentity = identities.find((identity) => normalizeBaseEmailAddress(identity.email) === base);
return baseIdentity?.id ?? null;
}
export interface ReplyFromResolution {
/** Identity to use for JMAP `identityId` and the SMTP envelope MAIL FROM. */
identityId: string;
+132
View File
@@ -0,0 +1,132 @@
/**
* Server-side helpers for talking to a JMAP API endpoint derived from the
* stored `serverUrl`.
*
* A bare `fetch(`${serverUrl}/jmap/`)` breaks in two real deployments (#627):
*
* - A 301/302 in front of the server (Cloudflare httphttps upgrade,
* hostname normalization, trailing-slash rules) makes `fetch`'s default
* redirect handling re-issue the request as a GET. Stalwart answers
* `GET /jmap/` with `404 application/problem+json`, which the passthrough
* then forwards as an opaque 404.
* - The session's `apiUrl` may live on a path other than `/jmap/`.
*
* `postJmap` follows redirects manually so POST stays POST, and callers can
* recover from a wrong path by resolving the session's `apiUrl` rebased onto
* `serverUrl`'s host (the advertised public host may not be reachable from
* this process see calendar-agenda's session handling).
*/
const MAX_REDIRECTS = 3;
export interface JmapSessionDocument {
apiUrl?: string;
capabilities?: Record<string, unknown>;
primaryAccounts?: Record<string, string>;
accounts?: Record<string, unknown>;
}
/**
* Redirects are followed only towards the same host (path/trailing-slash
* fixes) or an https upgrade of the same hostname. Anything else would leak
* the Authorization header to a third party.
*/
function isTrustedRedirect(from: URL, to: URL): boolean {
if (to.host === from.host && to.protocol === from.protocol) return true;
return to.protocol === 'https:' && to.hostname === from.hostname;
}
export class JmapRedirectError extends Error {
constructor(message: string) {
super(message);
this.name = 'JmapRedirectError';
}
}
/**
* POST a JMAP request, preserving the POST method and body across redirects
* (native `fetch` downgrades POST to GET on 301/302).
*/
export async function postJmap(
apiUrl: string,
authHeader: string,
body: string,
): Promise<Response> {
let url = new URL(apiUrl);
for (let attempt = 0; attempt <= MAX_REDIRECTS; attempt++) {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': authHeader,
'Content-Type': 'application/json',
},
body,
redirect: 'manual',
});
if (response.status < 300 || response.status >= 400) {
return response;
}
const location = response.headers.get('location');
if (!location) return response;
const next = new URL(location, url);
if (!isTrustedRedirect(url, next)) {
throw new JmapRedirectError(
`JMAP endpoint redirected to an untrusted host: ${next.host}`,
);
}
url = next;
}
throw new JmapRedirectError('Too many redirects from JMAP endpoint');
}
/**
* Fetch the JMAP session document from the same host as `serverUrl`. Tries
* Stalwart's canonical /jmap/session first (no redirect), then
* /.well-known/jmap as a fallback for other servers. Returns null if neither
* yields a usable session.
*/
export async function fetchJmapSession(
serverUrl: string,
authHeader: string,
): Promise<JmapSessionDocument | null> {
const candidates = [`${serverUrl}/jmap/session`, `${serverUrl}/.well-known/jmap`];
for (const url of candidates) {
try {
const res = await fetch(url, {
method: 'GET',
headers: { Authorization: authHeader },
redirect: 'follow',
});
if (!res.ok) continue;
const session = (await res.json()) as JmapSessionDocument;
if (session && typeof session === 'object' && session.primaryAccounts) {
return session;
}
} catch {
// Try the next candidate (e.g. canonical path 404s on a non-Stalwart server).
}
}
return null;
}
/**
* Rebase the session's advertised `apiUrl` onto `serverUrl`'s origin, so
* method calls go to the host this process can actually reach rather than
* the server's configured public hostname.
*/
export function rebaseApiUrl(
session: JmapSessionDocument | null,
serverUrl: string,
): string | null {
if (!session?.apiUrl) return null;
try {
const api = new URL(session.apiUrl, `${serverUrl}/`);
const base = new URL(serverUrl);
return new URL(api.pathname + api.search, base.origin).toString();
} catch {
return null;
}
}
+1
View File
@@ -3,6 +3,7 @@ export interface EmailTemplate {
name: string;
subject: string;
body: string;
isHTML?: boolean;
category: string;
defaultRecipients?: {
to?: string[];
+22 -1
View File
@@ -80,6 +80,26 @@ export function filterTemplates(templates: EmailTemplate[], query: string): Emai
);
}
// Compose bodies carry the embedded signature bracketed by
// data-signature-block markers (see email-composer's
// buildEmbeddedSignatureHtml). Applying a template must replace only the
// message content, so splice the template above the signature range instead
// of overwriting the whole body.
export function spliceTemplateAboveSignature(prevHtml: string, templateHtml: string): string {
const doc = new DOMParser().parseFromString(prevHtml, 'text/html');
const startEl = doc.querySelector('[data-signature-block="separator"], [data-signature-block="start"]');
if (!startEl) return templateHtml;
const endEl = doc.querySelector('[data-signature-block="end"]');
const host = doc.createElement('div');
let cursor: Node | null = startEl;
while (cursor) {
host.appendChild(cursor.cloneNode(true));
if (cursor === endEl) break;
cursor = cursor.nextSibling;
}
return templateHtml + host.innerHTML;
}
function sanitizeText(value: unknown): string {
return DOMPurify.sanitize(String(value || ''), STRIP_HTML_CONFIG);
}
@@ -153,7 +173,8 @@ export function importTemplates(json: string): ImportResult {
id: generateUUID(),
name: sanitizeText(t.name),
subject: sanitizeText(t.subject),
body: sanitizeText(t.body),
body: t.isHTML ? String(t.body || '') : sanitizeText(t.body),
isHTML: Boolean(t.isHTML),
category: sanitizeText(t.category),
defaultRecipients: recipients && typeof recipients === 'object'
? {
+10 -2
View File
@@ -44,9 +44,10 @@ export function groupEmailsByThread(
// Collect unique participant names from all emails in thread
const participantNames = getThreadParticipants(sortedEmails);
// Check for unread, starred, and attachments
// Check for unread, starred, pinned, and attachments
const hasUnread = sortedEmails.some(e => !e.keywords?.$seen);
const hasStarred = sortedEmails.some(e => e.keywords?.$flagged);
const hasPinned = sortedEmails.some(e => e.keywords?.['$pinned']);
const hasAttachment = sortedEmails.some(e => e.hasAttachment);
const hasAnswered = sortedEmails.some(e => e.keywords?.$answered);
const hasForwarded = sortedEmails.some(e => e.keywords?.$forwarded);
@@ -58,6 +59,7 @@ export function groupEmailsByThread(
participantNames,
hasUnread,
hasStarred,
hasPinned,
hasAttachment,
hasAnswered,
hasForwarded,
@@ -70,10 +72,14 @@ export function groupEmailsByThread(
/**
* Sorts thread groups by their latest email's receivedAt date (newest first).
* Threads containing a pinned email ($pinned keyword) stay on top, mirroring
* the server-side pinned-first sort of the email list.
*/
export function sortThreadGroups(groups: ThreadGroup[]): ThreadGroup[] {
return [...groups].sort(
(a, b) => new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
(a, b) =>
(b.hasPinned ? 1 : 0) - (a.hasPinned ? 1 : 0) ||
new Date(b.latestEmail.receivedAt).getTime() - new Date(a.latestEmail.receivedAt).getTime()
);
}
@@ -136,6 +142,7 @@ export function mergeThreadEmails(
const participantNames = getThreadParticipants(mergedEmails);
const hasUnread = mergedEmails.some(e => !e.keywords?.$seen);
const hasStarred = mergedEmails.some(e => e.keywords?.$flagged);
const hasPinned = mergedEmails.some(e => e.keywords?.['$pinned']);
const hasAttachment = mergedEmails.some(e => e.hasAttachment);
const hasAnswered = mergedEmails.some(e => e.keywords?.$answered);
const hasForwarded = mergedEmails.some(e => e.keywords?.$forwarded);
@@ -147,6 +154,7 @@ export function mergeThreadEmails(
participantNames,
hasUnread,
hasStarred,
hasPinned,
hasAttachment,
hasAnswered,
hasForwarded,
+51 -3
View File
@@ -22,6 +22,18 @@ export interface UnifiedAccountClient {
// must use the mailbox's `originalId` and explicitly target this accountId
// so the server routes to the owner's data.
isShared?: boolean;
// Store-side mailbox ids that make up THIS account's contribution to the
// cross views (All mail / Unread / Starred). It is intentionally per-account,
// not a global list: mailbox ids are account-scoped, so an id from one account
// is meaningless in another. The effective folder set of a cross view is the
// UNION across every account's entry (one UnifiedAccountClient per account),
// i.e. the sum of the respective per-account selections.
//
// For personal accounts this is the user's folder selection
// (`allMailFolderIds[accountId]`); shared/group accounts are not individually
// configurable and leave this undefined. When undefined, getCrossIncludedMailboxes
// falls back to the role-exclusion default (inbox + custom folders).
crossIncludedMailboxIds?: string[];
}
export interface UnifiedFetchResult {
@@ -49,7 +61,12 @@ const ALL_UNIFIED_ROLES: UnifiedMailboxRole[] = [
*/
export function resolveSourceFolderName(email: Email, mailboxes: Mailbox[]): string | undefined {
for (const m of mailboxes) {
if (email.mailboxIds?.[m.originalId ?? m.id]) return m.name;
// All fetch paths now namespace shared emails' mailboxIds to the store id
// (`${ownerId}:${origId}`), so matching `m.id` works for own and shared
// alike. The `originalId` check stays as a defensive fallback for any email
// that still carries a bare owner id. (#281 V3)
if (email.mailboxIds?.[m.id]) return m.name;
if (m.originalId && email.mailboxIds?.[m.originalId]) return m.name;
}
return undefined;
}
@@ -313,10 +330,18 @@ export function fetchUnifiedMailboxCounts(
// filter is built from each account's included-mailbox ids.
/**
* Mailboxes of an account included in the cross-account views: everything whose
* role is not excluded (inbox + custom/no-role folders).
* Mailboxes of an account included in the cross views (All mail / Unread /
* Starred). When the account carries an explicit `crossIncludedMailboxIds`
* selection (personal accounts honor the user's folder picker, shared accounts
* include everything), only those mailboxes are used. Otherwise it falls back
* to the role-exclusion default: everything whose role is not excluded (inbox +
* custom/no-role folders).
*/
export function getCrossIncludedMailboxes(account: UnifiedAccountClient): Mailbox[] {
if (account.crossIncludedMailboxIds) {
const selected = new Set(account.crossIncludedMailboxIds);
return account.mailboxes.filter((m) => selected.has(m.id));
}
return account.mailboxes.filter((m) => !CROSS_EXCLUDED_ROLES.has(m.role ?? ''));
}
@@ -445,6 +470,29 @@ export async function searchCrossViewEmails(
));
}
/**
* Like `searchCrossViewEmails`, but applies an advanced filter (text + field
* conditions from `buildJMAPFilter`, built WITHOUT an `inMailbox` clause) on top
* of the cross-view membership. `extraFilter` may be empty ({}), in which case
* only the membership filter is used (equivalent to a plain browse).
*/
export async function advancedSearchCrossViewEmails(
accounts: UnifiedAccountClient[],
view: CrossView,
extraFilter: Record<string, unknown>,
limit: number,
position: number,
): Promise<UnifiedFetchResult> {
const hasExtra = Object.keys(extraFilter).length > 0;
return fanOutCrossQuery(accounts, (account, jmapAccountId, ids) => {
const membership = buildCrossFilter(view, ids);
const filter = hasExtra
? { operator: 'AND', conditions: [membership, extraFilter] }
: membership;
return account.client.advancedSearchEmails(filter, jmapAccountId, limit, position);
});
}
/**
* Returns the list of unified roles that exist in at least one account's
* mailboxes.
+73 -27
View File
@@ -3,8 +3,9 @@ import { twMerge } from "tailwind-merge";
import { Mailbox, UNIFIED_MAILBOX_IDS } from "./jmap/types";
import type { UnifiedMailboxRole } from "./jmap/types";
import { debug } from "./debug";
import { useLocaleStore } from "@/stores/locale-store";
import { getEffectiveLocale } from '@/i18n/detect-locale';
import { useSettingsStore } from "@/stores/settings-store";
import type { DateLocale } from "@/stores/settings-store";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -42,6 +43,32 @@ export function generateUUID(): string {
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
/**
* Resolve the Intl locale used to render NUMERIC date parts, honouring the
* user's regional `dateLocale` override while leaving weekday/month names on
* the UI language. `auto` returns `fallback` unchanged (prior behaviour); the
* explicit regions force a fixed numeric ordering (#456):
* - `iso` `en-CA` (YYYY-MM-DD)
* - `en-GB` `en-GB` (DD/MM/YYYY)
* - `en-US` `en-US` (MM/DD/YYYY)
*/
function resolveDateLocale<T extends string | undefined>(
dateLocale: DateLocale,
fallback: T,
): string | T {
switch (dateLocale) {
case "iso":
return "en-CA";
case "en-GB":
return "en-GB";
case "en-US":
return "en-US";
case "auto":
default:
return fallback;
}
}
/**
* Formats a received-at date for the email list. The output style is
* controlled by the `dateFormat` user setting:
@@ -53,19 +80,27 @@ export function generateUUID(): string {
* - `relative` legacy en-US relative format ("1h ago", "2d ago").
* - `full` always the full locale date+time.
*
* Both the locale (from the language picker) and 12h/24h preference are
* read via `getState()` so this stays SSR-safe.
* The numeric date ordering is additionally governed by the `dateLocale`
* region setting (`auto` = follow the UI language, unchanged; or a fixed
* ISO / DD-MM / MM-DD ordering). Weekday and month names always follow the
* UI language.
*
* The locale (from the language picker), the region override and the 12h/24h
* preference are all read via `getState()` so this stays SSR-safe.
*/
export function formatDate(date: Date | string): string {
const d = typeof date === "string" ? new Date(date) : date;
const now = new Date();
const localeRaw = useLocaleStore.getState().locale;
const locale = localeRaw && localeRaw.length > 0 ? localeRaw : "en";
// `en` alone resolves to en-US in Intl; everything else uses the language
// subtag as-is and lets the runtime pick a sensible default region.
const intlLocale = locale === "en" ? "en-US" : locale;
const { dateFormat, timeFormat } = useSettingsStore.getState();
const locale = getEffectiveLocale();
// Names (weekday, month) follow the UI language: `en` alone resolves to
// en-US in Intl; everything else uses the language subtag as-is and lets
// the runtime pick a sensible default region.
const uiLocale = locale === "en" ? "en-US" : locale;
const { dateFormat, dateLocale, timeFormat } = useSettingsStore.getState();
// Numeric dates additionally honour the regional `dateLocale` override
// (defaults to `auto` = the UI language, preserving prior behaviour). (#456)
const numericLocale = resolveDateLocale(dateLocale, uiLocale);
const hour12 = timeFormat === "12h";
if (dateFormat === "relative") {
@@ -73,11 +108,14 @@ export function formatDate(date: Date | string): string {
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return d.toLocaleDateString(intlLocale, {
// Natural, fully localized relative time ("an hour ago" / "לפני שעה",
// "2 days ago" / "לפני יומיים") with correct singular/dual/plural per locale.
const rtf = new Intl.RelativeTimeFormat(uiLocale, { numeric: "auto" });
if (minutes < 1) return rtf.format(0, "second");
if (minutes < 60) return rtf.format(-minutes, "minute");
if (hours < 24) return rtf.format(-hours, "hour");
if (days < 7) return rtf.format(-days, "day");
return d.toLocaleDateString(uiLocale, {
month: "short",
day: "numeric",
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
@@ -85,7 +123,7 @@ export function formatDate(date: Date | string): string {
}
if (dateFormat === "full") {
return d.toLocaleString(intlLocale, {
return d.toLocaleString(numericLocale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -96,7 +134,7 @@ export function formatDate(date: Date | string): string {
}
// 'smart' (default)
const timeStr = d.toLocaleTimeString(intlLocale, {
const timeStr = d.toLocaleTimeString(uiLocale, {
hour: "2-digit",
minute: "2-digit",
hour12,
@@ -113,12 +151,12 @@ export function formatDate(date: Date | string): string {
// German Intl outputs "Fr." with a trailing dot for `weekday: 'short'`;
// strip it so the result reads cleanly next to the time.
const weekday = d
.toLocaleDateString(intlLocale, { weekday: "short" })
.toLocaleDateString(uiLocale, { weekday: "short" })
.replace(/\.$/, "");
return `${weekday} ${timeStr}`;
}
return d.toLocaleDateString(intlLocale, {
return d.toLocaleDateString(numericLocale, {
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -144,6 +182,11 @@ export function formatDateTime(
const d = typeof date === 'string' ? new Date(date) : date;
if (isNaN(d.getTime())) return typeof date === 'string' ? date : '';
// Honour the regional `dateLocale` override; `auto` keeps the previous
// `undefined` (runtime default) locale so existing behaviour is unchanged. (#456)
const { dateLocale } = useSettingsStore.getState();
const effectiveLocale = resolveDateLocale(dateLocale, undefined);
const localeOptions: Intl.DateTimeFormatOptions = {};
if (options?.weekday) localeOptions.weekday = options.weekday;
if (options?.year) localeOptions.year = options.year;
@@ -158,7 +201,7 @@ export function formatDateTime(
if (options?.timeZoneName) localeOptions.timeZoneName = options.timeZoneName;
}
return d.toLocaleString(undefined, localeOptions);
return d.toLocaleString(effectiveLocale, localeOptions);
}
// Marketing emails pad the preheader with whitespace, format chars (soft
@@ -182,7 +225,7 @@ export function truncateText(text: string, maxLength: number): string {
}
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 Bytes';
if (!Number.isFinite(bytes) || bytes <= 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
@@ -443,25 +486,28 @@ export function buildMailboxTree(mailboxes: Mailbox[]): MailboxNode[] {
return a.isShared ? 1 : -1;
}
// 2. Priority: Role-based ordering (inbox first, trash last, etc.)
// 2. Priority: user-defined order. When a folder has been explicitly
// reordered its sortOrder is non-zero and takes precedence over the
// default role/name ordering below. Untouched folders keep sortOrder 0,
// so the default arrangement is unchanged until the user drags something.
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
// 3. Priority: Role-based ordering (inbox first, trash last, etc.)
const aPriority = a.role ? (ROLE_PRIORITY[a.role] ?? 999) : 999;
const bPriority = b.role ? (ROLE_PRIORITY[b.role] ?? 999) : 999;
if (aPriority !== bPriority) {
return aPriority - bPriority;
}
// 3. Priority: Year folders (e.g., "2025", "2024") sorted numerically descending
// 4. Priority: Year folders (e.g., "2025", "2024") sorted numerically descending
const aIsYear = /^\d{4}$/.test(a.name);
const bIsYear = /^\d{4}$/.test(b.name);
if (aIsYear && bIsYear) {
return parseInt(b.name) - parseInt(a.name); // Descending: 2025, 2024, 2023...
}
// 4. Fallback: Server sortOrder
if (a.sortOrder !== b.sortOrder) {
return a.sortOrder - b.sortOrder;
}
// 5. Fallback: Alphabetical by name
return a.name.localeCompare(b.name);
});
+44
View File
@@ -121,3 +121,47 @@ export function parseUnsubscribeUrls(header: string): {
return { http, mailto, preferred };
}
/**
* Parse a mailto: URL into its parts so the client can send the message
* itself. Query values are percent-decoded manually rather than via
* URLSearchParams because RFC 6068 uses %-encoding only - a literal "+"
* in a subject or address must stay a plus, not become a space.
* @param url - mailto: URL, e.g. "mailto:a@b.c?subject=Unsubscribe%20123"
* @returns Recipients plus optional subject/body, or null without a valid recipient
*/
export function parseMailtoUrl(url: string): { to: string[]; subject?: string; body?: string } | null {
if (!url?.startsWith('mailto:')) return null;
const rest = url.slice(7);
const queryIndex = rest.indexOf('?');
const addressPart = queryIndex === -1 ? rest : rest.slice(0, queryIndex);
const query = queryIndex === -1 ? '' : rest.slice(queryIndex + 1);
const decode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
const to = addressPart
.split(',')
.map(a => decode(a).trim())
.filter(a => isValidEmail(a));
let subject: string | undefined;
let body: string | undefined;
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
if (eq === -1) continue;
const key = pair.slice(0, eq).toLowerCase();
const value = decode(pair.slice(eq + 1));
if (key === 'subject') subject = value;
else if (key === 'body') body = value;
else if (key === 'to' && isValidEmail(value.trim())) to.push(value.trim());
}
return to.length > 0 ? { to, subject, body } : null;
}