Merge remote-tracking branch 'origin/main' into pr/quote-header-i18n

This commit is contained in:
Linus Rath
2026-05-30 16:15:18 +02:00
59 changed files with 2130 additions and 337 deletions
+22
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
appendHtmlSignature,
appendPlainTextSignature,
getPlainTextSignature,
hasMeaningfulHtmlBody,
@@ -27,6 +28,27 @@ describe('signature-utils', () => {
});
});
describe('appendHtmlSignature', () => {
it('appends a sanitized html signature, preserving formatting', () => {
expect(appendHtmlSignature('<div>Hello</div>', { htmlSignature: '<strong>Alice</strong>' }))
.toBe('<div>Hello</div><br><br>-- <br><strong>Alice</strong>');
});
it('escapes and appends a text signature when no html signature exists', () => {
expect(appendHtmlSignature('<div>Hello</div>', { textSignature: 'Alice\nEng' }))
.toBe('<div>Hello</div><br><br>-- <br>Alice<br>Eng');
});
it('omits the separator marker when disabled', () => {
expect(appendHtmlSignature('<div>Hi</div>', { htmlSignature: '<strong>A</strong>' }, { separator: false }))
.toBe('<div>Hi</div><br><br><strong>A</strong>');
});
it('leaves the body untouched when no signature exists', () => {
expect(appendHtmlSignature('<div>Hi</div>', {})).toBe('<div>Hi</div>');
});
});
describe('hasMeaningfulHtmlBody', () => {
it('prefers html bodies that preserve signature formatting', () => {
expect(hasMeaningfulHtmlBody('<div>Hello</div><br><p>Alice</p>')).toBe(true);
+4
View File
@@ -15,6 +15,8 @@ export const BRANDING_OVERRIDE_KEYS = [
'appDescription',
'faviconUrl',
'pwaIconUrl',
'pwaScreenshotMobileUrl',
'pwaScreenshotDesktopUrl',
'pwaThemeColor',
'pwaBackgroundColor',
'appLogoLightUrl',
@@ -42,6 +44,8 @@ export interface DomainBrandingEntry {
appDescription?: string;
faviconUrl?: string;
pwaIconUrl?: string;
pwaScreenshotMobileUrl?: string;
pwaScreenshotDesktopUrl?: string;
pwaThemeColor?: string;
pwaBackgroundColor?: string;
appLogoLightUrl?: string;
+7
View File
@@ -53,6 +53,13 @@ export interface ServerPlugin {
forceEnabled?: boolean;
configSchema?: Record<string, PluginConfigField>;
settingsSchema?: Record<string, PluginSettingsField>;
/**
* Optional per-locale translation tables (locale -> key -> string) declared
* in the plugin manifest. Surfaced to the sandbox so plugin code can call
* `api.i18n.t(key)`; without it a plugin's strings stay in its hardcoded
* default language.
*/
locales?: Record<string, Record<string, string>>;
installedAt: string;
updatedAt: string;
/**
+2
View File
@@ -142,6 +142,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
devMode: { envVar: 'DEV_MOCK_JMAP', type: 'boolean', defaultValue: false },
faviconUrl: { envVar: 'FAVICON_URL', type: 'url', defaultValue: '/branding/Bulwark_Favicon.svg' },
pwaIconUrl: { envVar: 'PWA_ICON_URL', type: 'url', defaultValue: '' },
pwaScreenshotMobileUrl: { envVar: 'PWA_SCREENSHOT_MOBILE_URL', type: 'url', defaultValue: '' },
pwaScreenshotDesktopUrl: { envVar: 'PWA_SCREENSHOT_DESKTOP_URL', type: 'url', defaultValue: '' },
pwaThemeColor: { envVar: 'PWA_THEME_COLOR', type: 'string', defaultValue: '#ffffff' },
pwaBackgroundColor: { envVar: 'PWA_BACKGROUND_COLOR', type: 'string', defaultValue: '#ffffff' },
appLogoLightUrl: { envVar: 'APP_LOGO_LIGHT_URL', type: 'url', defaultValue: '' },
+8
View File
@@ -530,6 +530,14 @@ export class DemoJMAPClient implements IJMAPClient {
return { blobId, size: file.size, type: file.type };
}
async importEmail(): Promise<string | null> {
return generateDemoId('email');
}
async sendReadReceipt(): Promise<void> {
// Demo mode: no real network send.
}
getBlobDownloadUrl(blobId: string): string {
return `data:application/octet-stream;demo-blob=${blobId}`;
}
+22
View File
@@ -150,8 +150,30 @@ export interface IJMAPClient {
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string,
options?: { requestReadReceipt?: boolean },
): Promise<SendEmailResult>;
importEmail(
blobId: string,
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string,
): Promise<string | null>;
sendReadReceipt(params: {
to: string;
fromEmail: string;
fromName?: string;
identityId: string;
originalMessageId?: string | string[];
originalSubject?: string;
originalRecipient?: string;
automatic?: boolean;
accountId?: string;
subject?: string;
humanText?: string;
}): Promise<void>;
sendRawEmail(blob: Blob, identityId: string, sentMailboxId: string, draftMailboxId?: 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>;
+111 -1
View File
@@ -2149,7 +2149,8 @@ export class JMAPClient implements IJMAPClient {
inReplyTo?: string[],
references?: string[],
delayedUntil?: string,
envelopeMailFrom?: string
envelopeMailFrom?: string,
options?: { requestReadReceipt?: boolean }
): Promise<SendEmailResult> {
const holdForSeconds = delayedUntil ? this.validateDelayedUntil(delayedUntil) : undefined;
const emailId = `send-${Date.now()}`;
@@ -2219,6 +2220,13 @@ export class JMAPClient implements IJMAPClient {
mailboxIds: { [draftsMailbox.id]: true },
};
if (options?.requestReadReceipt) {
// RFC 8098: ask the recipient's client to return a Message Disposition
// Notification to our address. JMAP lets us set the raw header on create
// via the "header:<Name>:asText" property form.
emailCreate["header:Disposition-Notification-To:asText"] = fromEmail || this.username;
}
if (htmlBody) {
// Send as multipart/alternative with both text and HTML
emailCreate.bodyValues = {
@@ -3007,6 +3015,108 @@ export class JMAPClient implements IJMAPClient {
throw new Error('Invalid upload response: blobId not found');
}
/**
* Import a raw RFC822 message (referenced by a previously-uploaded blob) into
* one or more mailboxes. Returns the new email id. Used for sending MDNs,
* where the exact MIME bytes must be preserved (Email/set can't express a
* multipart/report report-type parameter reliably).
*/
async importEmail(
blobId: string,
mailboxIds: Record<string, boolean>,
keywords?: Record<string, boolean>,
accountId?: string
): Promise<string | null> {
const targetAccountId = accountId || this.accountId;
const creationId = `imp-${Date.now()}`;
const response = await this.request([
["Email/import", {
accountId: targetAccountId,
emails: {
[creationId]: { blobId, mailboxIds, keywords: keywords || { "$seen": true } },
},
}, "0"],
]);
const res = response.methodResponses?.[0];
if (res?.[0] !== "Email/import") {
console.error('Email/import: unexpected response', res);
return null;
}
const payload = res[1] as {
created?: Record<string, { id: string }>;
notCreated?: Record<string, { type?: string; description?: string }>;
};
const created = payload?.created?.[creationId];
if (!created) {
const reason = payload?.notCreated?.[creationId];
console.error('Email/import failed:', reason || payload);
throw new Error(`Email/import: ${reason?.description || reason?.type || 'unknown error'}`);
}
return created.id;
}
/**
* Send an RFC 8098 Message Disposition Notification (read receipt) in reply
* to a message that carried a Disposition-Notification-To header. Builds the
* multipart/report, uploads it as a blob, imports it into Sent, then submits
* it with an explicit envelope (MAIL FROM = our identity, RCPT TO = the
* requesting address).
*/
async sendReadReceipt(params: {
to: string;
fromEmail: string;
fromName?: string;
identityId: string;
originalMessageId?: string | string[];
originalSubject?: string;
originalRecipient?: string;
automatic?: boolean;
accountId?: string;
subject?: string;
humanText?: string;
}): Promise<void> {
const targetAccountId = params.accountId || this.accountId;
const { buildMdnMessage } = await import("@/lib/mdn");
const raw = buildMdnMessage(params);
const file = new File([raw], "receipt.eml", { type: "message/rfc822" });
const { blobId } = await this.uploadBlob(file);
const mailboxes = await this.getMailboxes();
const targetMailbox = mailboxes.find(mb => mb.role === 'sent') || mailboxes[0];
if (!targetMailbox) throw new Error('No mailbox available for MDN import');
const emailId = await this.importEmail(
blobId,
{ [targetMailbox.id]: true },
{ "$seen": true },
targetAccountId
);
if (!emailId) throw new Error('MDN import failed');
const subId = `mdnsub-${Date.now()}`;
const response = await this.request([
["EmailSubmission/set", {
accountId: targetAccountId,
create: {
[subId]: {
emailId,
identityId: params.identityId,
envelope: {
mailFrom: { email: params.fromEmail },
rcptTo: [{ email: params.to }],
},
},
},
}, "0"],
]);
const subRes = response.methodResponses?.[0];
const notCreated = (subRes?.[1] as { notCreated?: Record<string, { type?: string; description?: string }> })?.notCreated?.[subId];
if (notCreated) {
throw new Error(`MDN submission failed: ${notCreated.description || notCreated.type || 'unknown'}`);
}
}
getBlobDownloadUrl(blobId: string, name?: string, type?: string): string {
if (!this.downloadUrl) {
throw new Error('Download URL not available. Please reconnect.');
+19 -3
View File
@@ -13,14 +13,21 @@ export interface SieveCapabilities {
externalLists: string[];
}
export type FilterConditionField = 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body';
export type FilterConditionField =
| 'from' | 'to' | 'cc' | 'subject' | 'header' | 'size' | 'body'
| 'attachment';
export type FilterComparator =
| 'contains' | 'not_contains'
| 'is' | 'not_is'
| 'starts_with' | 'ends_with'
| 'matches'
| 'greater_than' | 'less_than';
| 'greater_than' | 'less_than'
// For field === 'attachment':
// has_any → message has any attachment (Content-Disposition: attachment)
// has_type → message has an attachment whose Content-Type matches `value`
// (substring match, e.g. "application/pdf" or "image/")
| 'has_any' | 'has_type';
export type FilterActionType =
| 'move' | 'copy' | 'forward'
@@ -30,7 +37,16 @@ export type FilterActionType =
export interface FilterCondition {
field: FilterConditionField;
comparator: FilterComparator;
value: string;
/**
* Match value. Use a string array for OR-within-condition semantics
* (e.g. `["@domain1.com", "@domain2.com"]` matches mail from either).
* Sieve emits the array as a list literal which the implementation
* treats as "matches any item". Use a plain string for single-value
* conditions; existing single-value rules continue to work unchanged.
*
* Not supported for: size (numeric), has_any (no value).
*/
value: string | string[];
headerName?: string;
}
+154
View File
@@ -0,0 +1,154 @@
// Builds an RFC 8098 Message Disposition Notification (MDN) as a raw RFC 5322
// message string. JMAP/Stalwart has no native MDN support, so the client
// constructs the multipart/report itself and sends it via
// blob-upload -> Email/import -> EmailSubmission/set (see client.sendReadReceipt).
//
// The message has two parts:
// 1. text/plain — human-readable explanation (English, ASCII; rarely shown)
// 2. message/disposition-notification — the machine-readable fields
// The optional third part (original message/headers) is omitted; RFC 8098 §3.1
// permits a two-part report.
export interface MdnOptions {
/** Address that requested the receipt (Disposition-Notification-To) — the MDN recipient. */
to: string;
/** Our identity address (sender of the MDN). */
fromEmail: string;
/** Optional display name for the From header. */
fromName?: string;
/** Original Message-ID. JMAP may hand this back as a string[]
* (header:Message-ID:asMessageIds), so accept both. */
originalMessageId?: string | string[];
/** Original Subject (used to build the MDN subject). */
originalSubject?: string;
/**
* The address the original message was delivered to (our address/alias).
* Used for Final-Recipient/Original-Recipient. Falls back to fromEmail.
*/
originalRecipient?: string;
/** true => automatic-action (setting "always"); false => manual-action (user clicked send). */
automatic?: boolean;
/** Reporting-UA value, e.g. "mail.dornig.de; Bulwark Webmail". */
reportingUa?: string;
/** Localized full Subject line. Defaults to "Read: <originalSubject>". */
subject?: string;
/** Localized human-readable explanation (first report part). Defaults to English. */
humanText?: string;
}
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** RFC 5322 date in UTC, e.g. "Thu, 28 May 2026 14:23:00 +0000". */
function rfc5322Date(d: Date = new Date()): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${DAYS[d.getUTCDay()]}, ${pad(d.getUTCDate())} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()} ` +
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} +0000`;
}
/** UTF-8 string -> base64, without the deprecated unescape(). */
function utf8ToBase64(value: string): string {
const bytes = new TextEncoder().encode(value);
let binary = "";
for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
/** UTF-8 base64 body, wrapped at 76 chars per RFC 2045. */
function base64Body(text: string): string {
return (utf8ToBase64(text).match(/.{1,76}/g) || []).join("\r\n");
}
/** RFC 2047 encoded-word for header values that contain non-ASCII characters. */
function encodeHeaderWord(value: string): string {
// eslint-disable-next-line no-control-regex
if (!/[^\x00-\x7F]/.test(value)) return value;
return `=?UTF-8?B?${utf8ToBase64(value)}?=`;
}
function ensureAngles(messageId: string | string[] | undefined): string {
// JMAP often returns Message-ID as a string[] (header:...:asMessageIds), so
// normalize string | string[] | undefined down to a single bracketed id.
const raw = Array.isArray(messageId) ? messageId[0] : messageId;
if (typeof raw !== "string") return "";
const trimmed = raw.trim();
if (!trimmed) return "";
return trimmed.startsWith("<") ? trimmed : `<${trimmed}>`;
}
function randomToken(): string {
const rnd = Math.random().toString(36).slice(2);
return `${Date.now().toString(36)}.${rnd}`;
}
/**
* Build the raw RFC 5322 MDN message. Lines are CRLF-terminated as required
* by the MIME standard so the bytes import/transmit verbatim.
*/
export function buildMdnMessage(opts: MdnOptions): string {
const finalRecipient = opts.originalRecipient || opts.fromEmail;
const domain = (opts.fromEmail.split("@")[1] || "localhost").trim();
const messageId = `<mdn.${randomToken()}@${domain}>`;
const boundary = `----=_MDN_${randomToken()}`;
const origMsgId = ensureAngles(opts.originalMessageId); // normalized "<...>" or ""
const fromHeader = opts.fromName
? `${encodeHeaderWord(opts.fromName)} <${opts.fromEmail}>`
: opts.fromEmail;
const subject = encodeHeaderWord(
opts.subject ?? `Read: ${opts.originalSubject || ""}`.trim()
);
const disposition = opts.automatic
? "automatic-action/MDN-sent-automatically; displayed"
: "manual-action/MDN-sent-manually; displayed";
const reportingUa = opts.reportingUa || `${domain}; Bulwark Webmail`;
// Human-readable part. Caller passes a localized humanText; fall back to
// English. Encoded as UTF-8/base64 below so any language survives.
const humanText = opts.humanText ?? [
`This is a return receipt for the message you sent to ${finalRecipient}.`,
``,
`Note: This receipt only acknowledges that the message was displayed on the`,
`recipient's computer. There is no guarantee that the recipient has read or`,
`understood the message contents.`,
].join("\r\n");
// Machine-readable disposition-notification part (pure ASCII tokens).
const mdnFields = [
`Reporting-UA: ${reportingUa}`,
`Final-Recipient: rfc822;${finalRecipient}`,
...(opts.originalRecipient ? [`Original-Recipient: rfc822;${opts.originalRecipient}`] : []),
...(origMsgId ? [`Original-Message-ID: ${origMsgId}`] : []),
`Disposition: ${disposition}`,
].join("\r\n");
return [
`Date: ${rfc5322Date()}`,
`From: ${fromHeader}`,
`To: ${opts.to}`,
`Subject: ${subject}`,
`Message-ID: ${messageId}`,
...(origMsgId ? [`In-Reply-To: ${origMsgId}`] : []),
`MIME-Version: 1.0`,
`Content-Type: multipart/report; report-type=disposition-notification;`,
`\tboundary="${boundary}"`,
``,
`--${boundary}`,
`Content-Type: text/plain; charset=utf-8`,
`Content-Transfer-Encoding: base64`,
``,
base64Body(humanText),
``,
`--${boundary}`,
`Content-Type: message/disposition-notification`,
`Content-Transfer-Encoding: 7bit`,
``,
mdnFields,
``,
`--${boundary}--`,
``,
].join("\r\n");
}
+12 -19
View File
@@ -10,32 +10,25 @@ import {
activateAllSandboxed,
deactivateAllSandboxed,
setSandboxStoreAccessor,
setSandboxLocale,
setupSandboxAutoDisable,
} from './plugin-sandbox/loader';
import { all as allActive, get as getActive } from './plugin-sandbox/registry';
// Re-export so the plugin store can keep the sandbox locale in step via this
// facade, instead of importing lib/plugin-sandbox/loader directly (which would
// also pull the hook buses into consumers' module graphs).
export { setSandboxLocale } from './plugin-sandbox/loader';
/**
* Previously: re-published React/ReactDOM on `globalThis.__PLUGIN_EXTERNALS__`
* so blob-imported plugin code could resolve `react`. With the sandbox model
* plugins receive React injected as a function argument inside their iframe
* runtime - there is nothing to expose on the host window.
*
* Kept as a no-op for callers that still invoke it during app bootstrap.
* Historically re-published React/ReactDOM on `globalThis` for the blob-import
* loader, and later also bootstrapped plugin locale sync. Both are obsolete:
* the sandbox injects React per-iframe, and locale sync now lives where plugin
* activation is orchestrated (stores/plugin-store -> initializePlugins, via
* setSandboxLocale). Kept as a no-op for the legacy activateAllPlugins()
* wrapper and its test.
*/
export function exposePluginExternals(): void {
if (typeof window === 'undefined') return;
// Initialise the locale sync once. Importing the store lazily avoids the
// circular module graph we used to fight before the sandbox refactor.
void import('@/stores/locale-store').then(({ useLocaleStore }) => {
setSandboxLocale(useLocaleStore.getState().locale);
useLocaleStore.subscribe((state) => setSandboxLocale(state.locale));
// Mirror on a global so the slot-iframe component can read it at spawn.
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = useLocaleStore.getState().locale;
useLocaleStore.subscribe((state) => {
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = state.locale;
});
}).catch(() => { /* locale sync is best-effort */ });
/* no-op */
}
// ─── Store accessor (status updates) ──────────────────────────
+1 -1
View File
@@ -140,7 +140,7 @@ async function doHttpPost(plugin: InstalledPlugin, path: string, body: unknown):
headers['Authorization'] = client.getAuthHeader();
headers['X-JMAP-Username'] = client.getUsername();
}
const res = await fetch(url.pathname + url.search, {
const res = await apiFetch(url.pathname + url.search, {
method: 'POST',
headers,
body: JSON.stringify(body),
+5 -1
View File
@@ -10,6 +10,7 @@
import type { InstalledPlugin, SlotName } from '../plugin-types';
import { dispatchApiCall } from './host-api';
import { SANDBOX_PATH } from './protocol';
import { withBasePath } from '../browser-navigation';
import type {
SandboxToHost, HostToSandbox, InitMsg, InitPayload,
} from './protocol';
@@ -143,7 +144,10 @@ export class SandboxInstance {
this.iframe.style.width = '100%';
this.iframe.style.height = '0px';
}
this.iframe.src = SANDBOX_PATH;
// Prefix with the mount path so the sandbox route resolves under a
// subpath deployment (NEXT_PUBLIC_BASE_PATH=/webmail). A bare
// "/plugin-sandbox" would hit the origin root and 404, breaking plugins.
this.iframe.src = withBasePath(SANDBOX_PATH);
this.listener = (ev) => this.onMessage(ev);
window.addEventListener('message', this.listener);
+9 -4
View File
@@ -41,11 +41,16 @@ export function setSandboxStoreAccessor(a: StoreAccessor): void { storeAccessor
let currentLocale = 'en';
export function setSandboxLocale(locale: string): void {
// Ignore empty/falsy values so a not-yet-seeded locale store can't clobber a
// good locale back to '' - the initial 'en' default stands until the real
// locale arrives via the store subscription.
if (!locale) return;
currentLocale = locale;
// Push to all active background instances.
// Slot iframes inherit locale at spawn time; they're short-lived.
// (We don't import the registry here to avoid a circular import; the
// PluginIframeSlot subscribes to locale changes on its own.)
// Background instances read `currentLocale` at load time; the slot-iframe
// component reads this global at spawn time (plugin-iframe-slot.tsx). Keep
// both in step from one place. Already-running instances are not re-pushed,
// so a locale switch only affects plugins/slots loaded afterwards.
(globalThis as unknown as { __APP_LOCALE__?: string }).__APP_LOCALE__ = locale;
}
// ─── Bundle fetch ─────────────────────────────────────────────
+44 -12
View File
@@ -73,18 +73,26 @@ function uid(): string {
// ─── Sandboxed API facade (calls flow to host via postMessage) ─
function callApi(method: string, args: unknown[]): Promise<unknown> {
const DEFAULT_API_TIMEOUT_MS = 30_000;
function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise<unknown> {
const id = uid();
return new Promise((resolve, reject) => {
pendingApi.set(id, { resolve, reject });
sendToHost({ type: 'api-request', id, method, args });
// Reject after 30s to prevent unbounded promise leaks if the host hangs.
setTimeout(() => {
const entry = pendingApi.get(id);
if (!entry) return;
pendingApi.delete(id);
entry.reject(new Error(`API call ${method} timed out after 30s`));
}, 30_000);
// Bounded so a hung host can't leak the promise forever. Interactive UI
// dialogs (ui.confirm/ui.alert) pass timeoutMs <= 0 to opt out: they wait
// for human input, the host always resolves them on confirm/cancel/close,
// and any still-pending call dies with the iframe on teardown - so there's
// nothing to leak, and a thinking user must not trip a 30s timeout.
if (timeoutMs > 0 && Number.isFinite(timeoutMs)) {
setTimeout(() => {
const entry = pendingApi.get(id);
if (!entry) return;
pendingApi.delete(id);
entry.reject(new Error(`API call ${method} timed out after ${Math.round(timeoutMs / 1000)}s`));
}, timeoutMs);
}
});
}
@@ -151,12 +159,13 @@ function buildPluginApi(manifest: PluginManifest) {
warning: (m: string) => { void callApi('toast.warning', [m]); },
},
ui: {
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise. */
/** Opens a host-rendered confirm dialog. Resolves to true on confirm, false otherwise.
* No timeout - it waits for the user's choice. */
confirm: (opts: { title?: string; message?: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean }) =>
callApi('ui.confirm', [opts]) as Promise<boolean>,
/** Opens a host-rendered alert (one button). Resolves once dismissed. */
callApi('ui.confirm', [opts], 0) as Promise<boolean>,
/** Opens a host-rendered alert (one button). Resolves once dismissed. No timeout. */
alert: (opts: { title?: string; message?: string; confirmLabel?: string }) =>
callApi('ui.alert', [opts]) as Promise<void>,
callApi('ui.alert', [opts], 0) 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>,
@@ -173,6 +182,26 @@ function buildPluginApi(manifest: PluginManifest) {
warn: (...a: unknown[]) => console.warn(`[plugin:${manifest.id}]`, ...a),
error: (...a: unknown[]) => console.error(`[plugin:${manifest.id}]`, ...a),
},
// Localization for plugins. The host pushes the active locale (init +
// 'locale-change'); `t` resolves a key against the plugin's declared
// `locales` map (manifest.locales), falling back to English then the key
// itself, with optional {placeholder} interpolation.
i18n: {
get locale(): string {
return (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
},
t(key: string, vars?: Record<string, string | number>): string {
const loc = (globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ || 'en';
const tables = manifest.locales || {};
let out = tables[loc]?.[key] ?? tables['en']?.[key] ?? key;
if (vars) {
for (const [k, v] of Object.entries(vars)) {
out = out.split('{' + k + '}').join(String(v));
}
}
return out;
},
},
};
}
@@ -344,6 +373,9 @@ async function handleInit(payload: InitPayload): Promise<void> {
if (bootDone) return;
bootDone = true;
mode = payload.mode;
// Make the active locale available to plugin code (api.i18n) right away -
// not only after the first 'locale-change' push.
(globalThis as unknown as { __PLUGIN_LOCALE__?: string }).__PLUGIN_LOCALE__ = payload.locale;
try {
if (payload.mode === 'background') {
await bootBackground(payload);
+53 -12
View File
@@ -12,42 +12,82 @@ function escapeString(value: string): string {
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
// Normalise the condition value to a non-empty string array. Single-value
// conditions stay one-element; arrays are filtered for empty strings.
function toValueList(value: string | string[]): string[] {
const arr = Array.isArray(value) ? value : [value];
return arr.map((v) => (v ?? '').toString()).filter((v) => v.length > 0);
}
// Render one or many strings as a Sieve string-literal-or-list. Sieve treats
// `header :contains "From" ["a", "b"]` as "any of a, b" (built-in OR within
// the condition); the single-string form is emitted unchanged when len === 1
// so existing scripts and tests stay byte-identical.
function formatStringArg(values: string[], transform: (s: string) => string = (s) => s): string {
if (values.length === 1) {
return `"${escapeString(transform(values[0]))}"`;
}
return `[${values.map((v) => `"${escapeString(transform(v))}"`).join(', ')}]`;
}
function generateCondition(condition: FilterCondition): string {
const { field, comparator, value } = condition;
if (field === 'size') {
// Size is numeric, single value only.
const sizeValue = Array.isArray(value) ? value[0] : value;
const op = comparator === 'greater_than' ? ':over' : ':under';
return `size ${op} ${value}`;
return `size ${op} ${sizeValue}`;
}
const values = toValueList(value);
if (field === 'body') {
const matchType = comparator === 'is' ? ':is' : ':contains';
return `body ${matchType} "${escapeString(value)}"`;
return `body ${matchType} ${formatStringArg(values)}`;
}
if (field === 'attachment') {
// RFC 5703: :mime :anychild matches against headers of any MIME part.
// has_any tests Content-Disposition for "attachment"; has_type matches
// the file extension against the filename across BOTH Content-Disposition
// (filename= parameter) and Content-Type (name= parameter) - many older
// senders (Microsoft SMTPSVC, PrintToMail, etc.) put the filename only
// in Content-Type and leave Content-Disposition without a filename.
// RFC 5228 §5.7 allows a string-list for header names; the test passes
// if any listed header matches. Wildcard "*.<ext>*" catches quoted,
// unquoted, and RFC-2231-encoded forms alike since ".<ext>" appears as
// a literal substring in all of them.
// Multiple extensions become a Sieve value-list ["*.pdf*", "*.xml*"]
// = OR within the condition (any item matches → test passes).
if (comparator === 'has_any') {
return `header :mime :anychild :contains "Content-Disposition" "attachment"`;
}
const normalised = values.map((v) => v.replace(/^[.*]+/, '').trim()).filter(Boolean);
return `header :mime :anychild :matches ["Content-Disposition", "Content-Type"] ${formatStringArg(normalised, (ext) => `*.${ext}*`)}`;
}
const headerName = field === 'header'
? (condition.headerName || 'X-Unknown')
: HEADER_MAP[field];
const escaped = escapeString(value);
switch (comparator) {
case 'contains':
return `header :contains "${headerName}" "${escaped}"`;
return `header :contains "${headerName}" ${formatStringArg(values)}`;
case 'not_contains':
return `not header :contains "${headerName}" "${escaped}"`;
return `not header :contains "${headerName}" ${formatStringArg(values)}`;
case 'is':
return `header :is "${headerName}" "${escaped}"`;
return `header :is "${headerName}" ${formatStringArg(values)}`;
case 'not_is':
return `not header :is "${headerName}" "${escaped}"`;
return `not header :is "${headerName}" ${formatStringArg(values)}`;
case 'starts_with':
return `header :matches "${headerName}" "${escaped}*"`;
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `${v}*`)}`;
case 'ends_with':
return `header :matches "${headerName}" "*${escaped}"`;
return `header :matches "${headerName}" ${formatStringArg(values, (v) => `*${v}`)}`;
case 'matches':
return `header :matches "${headerName}" "${escaped}"`;
return `header :matches "${headerName}" ${formatStringArg(values)}`;
default:
return `header :contains "${headerName}" "${escaped}"`;
return `header :contains "${headerName}" ${formatStringArg(values)}`;
}
}
@@ -89,6 +129,7 @@ function computeRequires(rules: FilterRule[], vacation?: VacationSieveConfig): s
for (const rule of enabledRules) {
for (const condition of rule.conditions) {
if (condition.field === 'body') extensions.add('body');
if (condition.field === 'attachment') extensions.add('mime');
}
for (const action of rule.actions) {
switch (action.type) {
+133 -22
View File
@@ -36,7 +36,11 @@ const FIELD_FROM_HEADER: Record<string, FilterConditionField> = {
function isValidCondition(c: unknown): boolean {
if (!c || typeof c !== 'object') return false;
const cond = c as Record<string, unknown>;
return typeof cond.field === 'string' && typeof cond.comparator === 'string' && typeof cond.value === 'string';
if (typeof cond.field !== 'string' || typeof cond.comparator !== 'string') return false;
// value may be a string OR a non-empty array of strings (Patch 11
// multi-value semantics). Accept both.
if (typeof cond.value === 'string') return true;
return Array.isArray(cond.value) && cond.value.every((v) => typeof v === 'string');
}
function isValidAction(a: unknown): boolean {
@@ -334,43 +338,150 @@ function parseAtom(raw: string): FilterCondition | null {
}
}
let m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
// Parse the value-tail of a header/body test: either a single quoted
// string or a Sieve list literal ["a", "b", ...]. Returns the unwrapped
// value(s), preserving the array shape when present so the caller can
// detect multi-value conditions.
const parseValueTail = (raw: string): string | string[] | null => {
const trimmed = raw.trim();
// List form
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
const inner = trimmed.slice(1, -1);
const items: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let mm: RegExpExecArray | null;
let cursor = 0;
while ((mm = re.exec(inner)) !== null) {
// Ensure only whitespace and commas appear between items
if (inner.slice(cursor, mm.index).replace(/[\s,]/g, '') !== '') return null;
items.push(unescapeSieveString(mm[1]));
cursor = mm.index + mm[0].length;
}
if (inner.slice(cursor).replace(/[\s,]/g, '') !== '') return null;
if (items.length === 0) return null;
return items.length === 1 ? items[0] : items;
}
// Single string form
const single = /^"((?:[^"\\]|\\.)*)"$/.exec(trimmed);
if (single) return unescapeSieveString(single[1]);
return null;
};
// Classify a :matches value (or values) into starts_with / ends_with /
// matches by inspecting wildcard positions. For multi-value, all items
// must share the same shape; otherwise we fall back to 'matches' and
// keep the wildcards verbatim.
const classifyMatches = (
values: string | string[],
): { comparator: 'starts_with' | 'ends_with' | 'matches'; stripped: string | string[] } => {
const arr = Array.isArray(values) ? values : [values];
const isTrailing = (v: string) => {
const stars = [...v].filter((c) => c === '*').length;
return stars === 1 && v.endsWith('*');
};
const isLeading = (v: string) => {
const stars = [...v].filter((c) => c === '*').length;
return stars === 1 && v.startsWith('*');
};
if (arr.every(isTrailing)) {
const stripped = arr.map((v) => v.slice(0, -1));
return { comparator: 'starts_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
}
if (arr.every(isLeading)) {
const stripped = arr.map((v) => v.slice(1));
return { comparator: 'ends_with', stripped: Array.isArray(values) ? stripped : stripped[0] };
}
return { comparator: 'matches', stripped: values };
};
// Match attachment-aware :mime :anychild tests before the generic header
// pattern - emitted by our own generator for field === 'attachment'.
// has_any: ":contains Content-Disposition attachment"
let m = /^header\s+:mime\s+:anychild\s+:contains\s+"Content-Disposition"\s+"attachment"$/.exec(s);
if (m) {
const [, tag, headerName, rawValue] = m;
const value = unescapeSieveString(rawValue);
return { field: 'attachment', comparator: 'has_any', value: '' };
}
// has_type: ":matches <headers> <value-tail>"
// - Current emit form uses a header-list ["Content-Disposition", "Content-Type"]
// to catch senders who put the filename only in Content-Type's name= param
// (Microsoft SMTPSVC, PrintToMail.net, etc.).
// - Legacy emit form used a single "Content-Disposition" header - still
// recognised here so rules saved before the fix remain editable.
// Each value item must be a "*.<ext>*" wildcard pattern.
const tryHasType = (rawHeaders: string, rawValue: string): FilterCondition | null => {
// Header part: accept either a single quoted string or a 2-element list
// containing exactly Content-Disposition + Content-Type (in any order).
const single = /^"Content-Disposition"$/.exec(rawHeaders.trim());
const listForm = /^\[\s*((?:"(?:[^"\\]|\\.)*"\s*,?\s*)+)\]$/.exec(rawHeaders.trim());
let headersOk = false;
if (single) {
headersOk = true;
} else if (listForm) {
const inner = listForm[1];
const items: string[] = [];
const re = /"((?:[^"\\]|\\.)*)"/g;
let mm: RegExpExecArray | null;
while ((mm = re.exec(inner)) !== null) items.push(unescapeSieveString(mm[1]));
const expected = new Set(['Content-Disposition', 'Content-Type']);
const got = new Set(items);
headersOk =
items.length === expected.size &&
[...expected].every((h) => got.has(h));
}
if (!headersOk) return null;
const tail = parseValueTail(rawValue);
if (tail === null) return null;
const arr = Array.isArray(tail) ? tail : [tail];
const exts: string[] = [];
for (const item of arr) {
const em = /^\*\.((?:[^*\\]|\\.)+)\*$/.exec(item);
if (!em) return null;
exts.push(unescapeSieveString(em[1]));
}
return { field: 'attachment', comparator: 'has_type', value: exts.length === 1 ? exts[0] : exts };
};
m = /^header\s+:mime\s+:anychild\s+:matches\s+(\[[\s\S]+?\]|"[^"]+")\s+([\s\S]+)$/.exec(s);
if (m) {
const result = tryHasType(m[1], m[2]);
if (result) return result;
}
// Unknown :mime :anychild pattern (e.g. from external scripts) - bail to
// opaque rendering so we don't silently misrepresent the script.
if (/^header\s+:mime\s+:anychild\b/.test(s)) {
return null;
}
m = /^header\s+:(contains|is|matches)\s+"((?:[^"\\]|\\.)*)"\s+([\s\S]+)$/.exec(s);
if (m) {
const [, tag, headerName, rawTail] = m;
const value = parseValueTail(rawTail);
if (value === null) return null;
const { field, headerName: customHeaderName } = normalizeHeaderName(unescapeSieveString(headerName));
let comparator: FilterComparator;
let finalValue: string | string[];
if (tag === 'contains') {
comparator = negated ? 'not_contains' : 'contains';
finalValue = value;
} else if (tag === 'is') {
comparator = negated ? 'not_is' : 'is';
finalValue = value;
} else {
// :matches - distinguish starts_with / ends_with / matches
const starPositions = [...value].reduce<number[]>((acc, ch, idx) => (ch === '*' ? [...acc, idx] : acc), []);
if (starPositions.length === 1 && starPositions[0] === value.length - 1) {
comparator = 'starts_with';
const cond: FilterCondition = { field, comparator, value: value.slice(0, -1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
if (starPositions.length === 1 && starPositions[0] === 0) {
comparator = 'ends_with';
const cond: FilterCondition = { field, comparator, value: value.slice(1) };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
comparator = 'matches';
const classified = classifyMatches(value);
comparator = classified.comparator;
finalValue = classified.stripped;
}
const cond: FilterCondition = { field, comparator, value };
const cond: FilterCondition = { field, comparator, value: finalValue };
if (customHeaderName !== undefined) cond.headerName = customHeaderName;
return cond;
}
m = /^body\s+:(contains|is)\s+"((?:[^"\\]|\\.)*)"$/.exec(s);
m = /^body\s+:(contains|is)\s+([\s\S]+)$/.exec(s);
if (m) {
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value: unescapeSieveString(m[2]) };
const value = parseValueTail(m[2]);
if (value === null) return null;
return { field: 'body', comparator: m[1] === 'is' ? 'is' : 'contains', value };
}
m = /^size\s+:(over|under)\s+(\d+)$/.exec(s);
+29
View File
@@ -124,6 +124,35 @@ export function appendPlainTextSignature(
return `${body}${sep}${plainTextSignature}`;
}
/**
* Append a signature to an HTML body, preserving rich formatting. Used by the
* quick-reply path so an HTML signature keeps its markup instead of being
* flattened to plain text. Mirrors the composer's send-time signature block
* (`buildSignatureHtml` in email-composer.tsx).
*/
export function appendHtmlSignature(
htmlBody: string,
signature?: SignatureSource | null,
options: { separator?: boolean } = {},
): string {
const sep = options.separator === false ? '<br><br>' : '<br><br>-- <br>';
if (signature?.htmlSignature?.trim()) {
return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`;
}
if (signature?.textSignature?.trim()) {
const escaped = signature.textSignature
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br>');
return `${htmlBody}${sep}${escaped}`;
}
return htmlBody;
}
export function hasMeaningfulHtmlBody(html: string): boolean {
if (!html.trim()) return false;
+119
View File
@@ -0,0 +1,119 @@
// Reply / forward subject prefix handling.
//
// Real-world email subjects accumulate prefixes across clients and languages:
// "Re: AW: WG: Fwd: Re: foo". The deduplication regex needs to know ALL
// commonly-used reply/forward markers - not just the current locale's, since
// inbound messages may come from any locale. Failing to strip a foreign-locale
// prefix means the user's locale prefix gets *added on top* and the subject
// chain keeps growing.
//
// Sources: de-facto conventions in Outlook / Thunderbird / Apple Mail per
// language. Includes a handful of legacy short-forms (R:, Fw:) that some
// mobile clients still emit.
const REPLY_TOKENS = [
"Re", // English, Italian, French (also generic ISO)
"RE", // Outlook variant
"AW", // German (Antwort)
"Antw", // German verbose
"Sv", // Danish / Swedish / Norwegian (Svar)
"Yn", // Turkish (Yanit)
"Yanit", // Turkish verbose
"Odp", // Polish (Odpowiedz)
"Ответ", // Russian
"Resp", // Spanish/Portuguese variant
"Vá", // Hungarian
"回复", // Chinese
"回覆", // Chinese traditional
"답장", // Korean
// NB: deliberately no bare "R" token — a single letter would strip the first
// word of legitimate subjects like "R: budget 2024". The full "Re" covers
// the common Italian/English case anyway.
];
const FORWARD_TOKENS = [
"Fwd", // English standard
"Fw", // English short / Polish / German short
"WG", // German (Weitergeleitet)
"Tr", // French (Transfert)
"Vs", // Danish (Videresend)
"Enc", // Portuguese (Encaminhar)
"ENC", // Portuguese caps
"Rv", // Spanish (Reenviar)
"RV", // Spanish caps
"Rvf", // Spanish variant
"Inol", // Italian (Inoltro)
// NB: deliberately no bare "I" token — see the REPLY_TOKENS note above.
"PD", // Polish (Przekazane Dalej)
"PR", // Czech (Preposlat)
"İlt", // Turkish (Ilet)
"Ilt", // Turkish ASCII
"Пересл", // Russian (Peresylka)
"Пер", // Russian short
"转发", // Chinese
"轉寄", // Chinese traditional
"전달", // Korean
];
// Match a single prefix token + optional [N] counter (Outlook) or *N (Eudora)
// + colon + whitespace. Case-insensitive. The non-capturing groups keep the
// regex composable for stripping multiple prefixes in a row.
function buildPrefixRegex(tokens: string[]): RegExp {
// Escape regex specials in tokens (none currently, but be defensive)
const escaped = tokens.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
// Sort by length DESC so longer tokens (e.g. "Пересл") win over their
// shorter prefixes (e.g. "Пер") during alternation matching.
escaped.sort((a, b) => b.length - a.length);
return new RegExp(
`^\\s*(?:${escaped.join("|")})(?:\\[\\d+\\]|\\*\\d*)?\\s*:\\s*`,
"i",
);
}
const ANY_PREFIX_RE = buildPrefixRegex([...REPLY_TOKENS, ...FORWARD_TOKENS]);
/**
* Strip any leading sequence of reply/forward prefixes (across languages) from
* a subject line. Idempotent and safe for empty input.
*
* Examples:
* stripSubjectPrefixes("Re: AW: WG: foo") === "foo"
* stripSubjectPrefixes("Re[2]: foo") === "foo"
* stripSubjectPrefixes("RE: Re: foo") === "foo"
* stripSubjectPrefixes("foo") === "foo"
* stripSubjectPrefixes("") === ""
*/
export function stripSubjectPrefixes(subject: string | undefined | null): string {
if (!subject) return "";
let s = subject;
// Bounded loop: in practice you never see more than ~10 prefixes; the bound
// protects against pathological input. Each iteration must consume input.
for (let i = 0; i < 20; i++) {
const next = s.replace(ANY_PREFIX_RE, "");
if (next === s) break;
s = next;
}
return s;
}
/**
* Build a reply subject with the given locale-aware prefix. Strips any
* pre-existing prefixes (in any language) first so chains don't accumulate.
*
* buildReplySubject("AW: WG: foo", "Re:") === "Re: foo"
* buildReplySubject("foo", "AW:") === "AW: foo"
* buildReplySubject("", "AW:") === "AW:"
*/
export function buildReplySubject(subject: string | undefined | null, prefix: string): string {
const stripped = stripSubjectPrefixes(subject);
return stripped ? `${prefix} ${stripped}` : prefix;
}
/**
* Build a forward subject. Same logic as buildReplySubject but conceptually
* separate for clarity at the call site.
*/
export function buildForwardSubject(subject: string | undefined | null, prefix: string): string {
const stripped = stripSubjectPrefixes(subject);
return stripped ? `${prefix} ${stripped}` : prefix;
}