Merge remote-tracking branch 'origin/main' into pr/quote-header-i18n
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
/**
|
||||
|
||||
@@ -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: '' },
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) ──────────────────────────
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\n/g, '<br>');
|
||||
return `${htmlBody}${sep}${escaped}`;
|
||||
}
|
||||
|
||||
return htmlBody;
|
||||
}
|
||||
|
||||
export function hasMeaningfulHtmlBody(html: string): boolean {
|
||||
if (!html.trim()) return false;
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user