From 05e2837f6b3c20937e002e059ba4ab6ac4cd25f2 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 13:02:12 +0200 Subject: [PATCH 01/12] Fix: localize reply/forward quote header incl. sender address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reply/forward quote header was always emitted in English ("On {date}, {from} wrote:", "---------- Forwarded message ----------", From/Date/Subject) regardless of UI language, in both the main path (lib/quote-header.ts) and the composer's inline fallback. quote-header.ts now takes an optional localized QuoteHeaderLabels set (English defaults preserved for back-compat); page.tsx builds it from a new quote_header message namespace, and the composer fallback uses the same keys. Added the quote_header namespace to all 17 locales. Also folds in the forward-sender-address fix: the forward "From:" line now shows the full "Name " like every mail client (the reply line keeps the bare name, which reads naturally in "On … wrote:"). --- app/(main)/[locale]/page.tsx | 10 ++++++- components/email/email-composer.tsx | 25 ++++++++++++----- lib/quote-header.ts | 42 ++++++++++++++++++++++++++--- locales/cs/common.json | 7 +++++ locales/da/common.json | 7 +++++ locales/de/common.json | 7 +++++ locales/en/common.json | 7 +++++ locales/es/common.json | 7 +++++ locales/fr/common.json | 7 +++++ locales/it/common.json | 7 +++++ locales/ja/common.json | 7 +++++ locales/ko/common.json | 7 +++++ locales/lv/common.json | 7 +++++ locales/nl/common.json | 7 +++++ locales/pl/common.json | 7 +++++ locales/pt/common.json | 7 +++++ locales/ru/common.json | 7 +++++ locales/tr/common.json | 7 +++++ locales/uk/common.json | 7 +++++ locales/zh/common.json | 7 +++++ 20 files changed, 185 insertions(+), 11 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index ae57e8ec..e951b6c9 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -81,6 +81,7 @@ const SCHEDULED_MAILBOX_ID = '__scheduled__'; export default function Home() { const t = useTranslations(); const tCommon = useTranslations('common'); + const tQuote = useTranslations('quote_header'); const { appName } = useConfig(); const mailLayout = useSettingsStore((state) => state.mailLayout); const [showComposer, setShowComposer] = useState(false); @@ -1212,13 +1213,20 @@ export default function Home() { locale: useLocaleStore.getState().locale, timeFormat: useSettingsStore.getState().timeFormat, unknownLabel: tCommon('unknown'), + labels: { + formatReplyLine: (vars) => tQuote('reply_line', vars), + forwardedSeparator: tQuote('forwarded_separator'), + fromLabel: tQuote('from_label'), + dateLabel: tQuote('date_label'), + subjectLabel: tQuote('subject_label'), + }, }); setComposerQuoteHeader(header); } catch (err) { console.warn('[quote-header] plugin transform failed; using default', err); setComposerQuoteHeader(null); } - }, [tCommon]); + }, [tCommon, tQuote]); // Force a clean composer remount on every fresh entry point so prior // compose state can't bleed into the new session (#329 C). The composer is diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index 64f9c9d8..2b9566b1 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -196,6 +196,7 @@ export function EmailComposer({ }: EmailComposerProps) { const t = useTranslations('email_composer'); const tCommon = useTranslations('common'); + const tQuote = useTranslations('quote_header'); const timeFormat = useSettingsStore((state) => state.timeFormat); const plainTextMode = useSettingsStore((state) => state.plainTextMode); const subAddressDelimiter = useSettingsStore((state) => state.subAddressDelimiter); @@ -291,6 +292,13 @@ export function EmailComposer({ const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); + // Forward "From:" shows the full sender incl. address; reply line keeps + // the bare name (reads naturally in the localized "On … wrote:" line). + const fromStrFull = from + ? (from.name && from.email && from.name !== from.email + ? `${from.name} <${from.email}>` + : (from.email || from.name || tCommon('unknown'))) + : tCommon('unknown'); const originalText = replyTo.body || (replyTo.htmlBody ? htmlToPlainText(replyTo.htmlBody) : ''); const quotedText = originalText.split('\n').map(line => `> ${line}`).join('\n'); @@ -311,9 +319,9 @@ export function EmailComposer({ } if (mode === 'forward') { - return `${prefix}${signatureBlock}\n\n---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`; + return `${prefix}${signatureBlock}\n\n${tQuote('forwarded_separator')}\n${tQuote('from_label')}: ${fromStrFull}\n${tQuote('date_label')}: ${date}\n${tQuote('subject_label')}: ${replyTo.subject || ''}\n\n${originalText}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}\n\nOn ${date}, ${fromStr} wrote:\n${quotedText}`; + return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`; } return prefix; } @@ -336,6 +344,11 @@ export function EmailComposer({ const date = replyTo.receivedAt ? formatDateTime(replyTo.receivedAt, timeFormat, { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' }) : ""; const from = replyTo.from?.[0]; const fromStr = from ? `${from.name || from.email}` : tCommon('unknown'); + const fromStrFull = from + ? (from.name && from.email && from.name !== from.email + ? `${from.name} <${from.email}>` + : (from.email || from.name || tCommon('unknown'))) + : tCommon('unknown'); const signatureBlock = buildEmbeddedSignatureHtml(initialSignatureIdentity, { embed: shouldEmbedSignatureAboveQuote, @@ -359,8 +372,8 @@ export function EmailComposer({ // Build quoted content as HTML if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) { const quoteHeader = mode === 'forward' - ? `---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

` - : `On ${date}, ${fromStr} wrote:
`; + ? `${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

` + : `${tQuote('reply_line', { date, from: fromStr })}
`; // cid: image refs are rewritten so they render in the editor (browsers // can't fetch cid: URLs); see useEffect below for the data-URL backfill. const quotedHtml = rewriteCidImagesForEditor(replyTo.htmlBody); @@ -370,9 +383,9 @@ export function EmailComposer({ if (replyTo.body) { const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); if (mode === 'forward') { - return `${prefix}${signatureBlock}

---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${replyTo.subject || ''}

${escapedOriginal}`; + return `${prefix}${signatureBlock}

${tQuote('forwarded_separator')}
${tQuote('from_label')}: ${fromStrFull}
${tQuote('date_label')}: ${date}
${tQuote('subject_label')}: ${replyTo.subject || ''}

${escapedOriginal}`; } else if (mode === 'reply' || mode === 'replyAll') { - return `${prefix}${signatureBlock}

On ${date}, ${fromStr} wrote:
${escapedOriginal}
`; + return `${prefix}${signatureBlock}

${tQuote('reply_line', { date, from: fromStr })}
${escapedOriginal}
`; } } return prefix; diff --git a/lib/quote-header.ts b/lib/quote-header.ts index 786abfc8..c55eb046 100644 --- a/lib/quote-header.ts +++ b/lib/quote-header.ts @@ -10,6 +10,17 @@ import { formatDateTime } from "@/lib/utils"; import { emailHooks } from "@/lib/plugin-hooks"; import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types"; +// Localized label set the caller passes in. Labels live on the client where +// useTranslations is available; this module stays framework-agnostic. +export interface QuoteHeaderLabels { + /** ICU-formatted reply line, e.g. "On {date}, {from} wrote:" with placeholders already substituted. */ + formatReplyLine: (vars: { date: string; from: string }) => string; + forwardedSeparator: string; + fromLabel: string; + dateLabel: string; + subjectLabel: string; +} + interface BuildArgs { mode: "reply" | "replyAll" | "forward"; email: { @@ -24,10 +35,24 @@ interface BuildArgs { locale: string; timeFormat: "12h" | "24h"; unknownLabel: string; + /** + * Localized labels. Optional for backward compatibility; falls back to + * English (matching the original hardcoded behaviour) when not supplied. + */ + labels?: QuoteHeaderLabels; } +const DEFAULT_LABELS: QuoteHeaderLabels = { + formatReplyLine: ({ date, from }) => `On ${date}, ${from} wrote:`, + forwardedSeparator: "---------- Forwarded message ----------", + fromLabel: "From", + dateLabel: "Date", + subjectLabel: "Subject", +}; + function defaultHeader(args: BuildArgs): QuoteHeader { const { mode, email, timeFormat, unknownLabel } = args; + const labels = args.labels ?? DEFAULT_LABELS; const date = email.receivedAt ? formatDateTime(email.receivedAt, timeFormat, { weekday: "short", @@ -38,16 +63,25 @@ function defaultHeader(args: BuildArgs): QuoteHeader { : ""; const from = email.from?.[0]; const fromStr = from ? `${from.name || from.email}` : unknownLabel; + // Forward header "From:" shows the full sender incl. address ("Name + // "), like every mail client. The reply line keeps the bare name + // (reads more naturally in "On … wrote:"). + const fromStrFull = from + ? (from.name && from.email && from.name !== from.email + ? `${from.name} <${from.email}>` + : (from.email || from.name || unknownLabel)) + : unknownLabel; const subject = email.subject || ""; if (mode === "forward") { - const text = `---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${subject}\n`; - const html = `
---------- Forwarded message ----------
From: ${fromStr}
Date: ${date}
Subject: ${subject}

`; + const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`; + const html = `
${labels.forwardedSeparator}
${labels.fromLabel}: ${fromStrFull}
${labels.dateLabel}: ${date}
${labels.subjectLabel}: ${subject}

`; return { html, text, wrapInBlockquote: false }; } - const text = `On ${date}, ${fromStr} wrote:\n`; - const html = `
On ${date}, ${fromStr} wrote:
`; + const replyLine = labels.formatReplyLine({ date, from: fromStr }); + const text = `${replyLine}\n`; + const html = `
${replyLine}
`; return { html, text, wrapInBlockquote: true }; } diff --git a/locales/cs/common.json b/locales/cs/common.json index 6620a55b..3ce40153 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Dne {date} napsal(a) {from}:", + "forwarded_separator": "---------- Přeposlaná zpráva ----------", + "from_label": "Od", + "date_label": "Datum", + "subject_label": "Předmět" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/da/common.json b/locales/da/common.json index 2f455b42..26d72337 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Den {date} skrev {from}:", + "forwarded_separator": "---------- Videresendt besked ----------", + "from_label": "Fra", + "date_label": "Dato", + "subject_label": "Emne" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/de/common.json b/locales/de/common.json index fa621a99..180258ae 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Am {date} schrieb {from}:", + "forwarded_separator": "---------- Weitergeleitete Nachricht ----------", + "from_label": "Von", + "date_label": "Datum", + "subject_label": "Betreff" + }, "login": { "title": "Webmail", "username_label": "E-Mail", diff --git a/locales/en/common.json b/locales/en/common.json index 1dbba593..b24adfd3 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "On {date}, {from} wrote:", + "forwarded_separator": "---------- Forwarded message ----------", + "from_label": "From", + "date_label": "Date", + "subject_label": "Subject" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/es/common.json b/locales/es/common.json index 7b83d66a..615ebeb1 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "El {date}, {from} escribió:", + "forwarded_separator": "---------- Mensaje reenviado ----------", + "from_label": "De", + "date_label": "Fecha", + "subject_label": "Asunto" + }, "login": { "title": "Correo Web", "username_label": "Correo electrónico", diff --git a/locales/fr/common.json b/locales/fr/common.json index f9cb00ad..0ac4ee61 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Le {date}, {from} a écrit :", + "forwarded_separator": "---------- Message transféré ----------", + "from_label": "De", + "date_label": "Date", + "subject_label": "Sujet" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/it/common.json b/locales/it/common.json index 3c96458a..6da32b5e 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Il {date}, {from} ha scritto:", + "forwarded_separator": "---------- Messaggio inoltrato ----------", + "from_label": "Da", + "date_label": "Data", + "subject_label": "Oggetto" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/ja/common.json b/locales/ja/common.json index aa366688..ff4153fe 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}に{from}が書きました:", + "forwarded_separator": "---------- 転送メッセージ ----------", + "from_label": "差出人", + "date_label": "日付", + "subject_label": "件名" + }, "login": { "title": "ウェブメール", "username_label": "メールアドレス", diff --git a/locales/ko/common.json b/locales/ko/common.json index 79a238d7..1545df90 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}에 {from}님이 작성:", + "forwarded_separator": "---------- 전달된 메시지 ----------", + "from_label": "보낸 사람", + "date_label": "날짜", + "subject_label": "제목" + }, "login": { "title": "웹메일", "username_label": "이메일", diff --git a/locales/lv/common.json b/locales/lv/common.json index 62a09509..2dd8d7b0 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date} {from} rakstīja:", + "forwarded_separator": "---------- Pārsūtītā ziņa ----------", + "from_label": "No", + "date_label": "Datums", + "subject_label": "Tēma" + }, "login": { "title": "Tīmekļa pasts", "username_label": "E-pasta adrese", diff --git a/locales/nl/common.json b/locales/nl/common.json index d1208b36..2901d918 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Op {date} schreef {from}:", + "forwarded_separator": "---------- Doorgestuurd bericht ----------", + "from_label": "Van", + "date_label": "Datum", + "subject_label": "Onderwerp" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pl/common.json b/locales/pl/common.json index ee550e28..5cf29463 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}, {from} napisał(a):", + "forwarded_separator": "---------- Wiadomość przekazana ----------", + "from_label": "Od", + "date_label": "Data", + "subject_label": "Temat" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3f7ef129..47b5d68c 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "Em {date}, {from} escreveu:", + "forwarded_separator": "---------- Mensagem encaminhada ----------", + "from_label": "De", + "date_label": "Data", + "subject_label": "Assunto" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/ru/common.json b/locales/ru/common.json index 747b269c..b46e2970 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}, {from} написал:", + "forwarded_separator": "---------- Пересланное сообщение ----------", + "from_label": "От", + "date_label": "Дата", + "subject_label": "Тема" + }, "login": { "title": "Веб-почта", "username_label": "Электронная почта", diff --git a/locales/tr/common.json b/locales/tr/common.json index 287522ab..b590d642 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date} tarihinde {from} şunu yazdı:", + "forwarded_separator": "---------- İletilen mesaj ----------", + "from_label": "Kimden", + "date_label": "Tarih", + "subject_label": "Konu" + }, "login": { "title": "Webmail", "username_label": "E-posta", diff --git a/locales/uk/common.json b/locales/uk/common.json index e08ee372..c2aed60b 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "{date}, {from} написав:", + "forwarded_separator": "---------- Переслане повідомлення ----------", + "from_label": "Від", + "date_label": "Дата", + "subject_label": "Тема" + }, "login": { "title": "Веб-пошта", "username_label": "Електронна пошта", diff --git a/locales/zh/common.json b/locales/zh/common.json index 00a7634f..dc473be0 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1,4 +1,11 @@ { + "quote_header": { + "reply_line": "在 {date},{from} 写道:", + "forwarded_separator": "---------- 转发邮件 ----------", + "from_label": "发件人", + "date_label": "日期", + "subject_label": "主题" + }, "login": { "title": "网页邮箱", "username_label": "邮箱地址", From 0879030dc8f210604e908392852d47b02e319b08 Mon Sep 17 00:00:00 2001 From: Phongsaton Untan Date: Sat, 30 May 2026 07:19:11 +0700 Subject: [PATCH 02/12] feat(dev-jmap): persist identity create/update/destroy in mock server The dev mock's Identity/set discarded its payload and Identity/get always returned a static list, so saved identities never round-tripped in local development. Persist create (with mayDelete: true), update, and destroy in place, mirroring handleMailboxSet, so signature edits stick when testing without a real JMAP server. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/api/dev-jmap/[...path]/route.ts | 63 ++++++++++++++++++++++++++--- 1 file changed, 58 insertions(+), 5 deletions(-) diff --git a/app/api/dev-jmap/[...path]/route.ts b/app/api/dev-jmap/[...path]/route.ts index 1d18ad4a..74d5971b 100644 --- a/app/api/dev-jmap/[...path]/route.ts +++ b/app/api/dev-jmap/[...path]/route.ts @@ -723,7 +723,18 @@ const emails: MockEmail[] = [ // Identities // --------------------------------------------------------------------------- -const IDENTITIES = [ +type MockIdentity = { + id: string; + name: string; + email: string; + replyTo: Array<{ name?: string; email: string }> | null; + bcc: Array<{ name?: string; email: string }> | null; + textSignature: string | null; + htmlSignature: string | null; + mayDelete: boolean; +}; + +const IDENTITIES: MockIdentity[] = [ { id: 'identity-001', name: 'Dev User', @@ -1562,13 +1573,55 @@ function handleIdentityGet(_args: MethodArgs, callId: string): MethodResult { function handleIdentitySet(args: MethodArgs, callId: string): MethodResult { const created: Record = {}; - const create = args.create as Record | undefined; + const updated: Record = {}; + const destroyed: string[] = []; + + const create = args.create as Record> | undefined; if (create) { - for (const key of Object.keys(create)) { - created[key] = { id: `identity-new-${Date.now()}-${key}` }; + for (const [key, data] of Object.entries(create)) { + const newId = `identity-${Date.now()}-${key}`; + IDENTITIES.push({ + id: newId, + name: (data.name as string) || '', + email: (data.email as string) || '', + replyTo: (data.replyTo as MockIdentity['replyTo']) ?? null, + bcc: (data.bcc as MockIdentity['bcc']) ?? null, + textSignature: (data.textSignature as string | null) ?? null, + htmlSignature: (data.htmlSignature as string | null) ?? null, + mayDelete: true, + }); + created[key] = { id: newId }; } } - return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated: null, destroyed: null }, callId]; + + const update = args.update as Record> | undefined; + if (update) { + for (const [id, changes] of Object.entries(update)) { + const identity = IDENTITIES.find((i) => i.id === id); + if (identity) { + // Email is immutable per the identity form, so it's never in `changes`. + if (changes.name !== undefined) identity.name = changes.name as string; + if (changes.replyTo !== undefined) identity.replyTo = changes.replyTo as MockIdentity['replyTo']; + if (changes.bcc !== undefined) identity.bcc = changes.bcc as MockIdentity['bcc']; + if (changes.textSignature !== undefined) identity.textSignature = changes.textSignature as string | null; + if (changes.htmlSignature !== undefined) identity.htmlSignature = changes.htmlSignature as string | null; + updated[id] = null; + } + } + } + + const destroy = args.destroy as string[] | undefined; + if (destroy) { + for (const id of destroy) { + const idx = IDENTITIES.findIndex((i) => i.id === id); + if (idx !== -1) { + IDENTITIES.splice(idx, 1); + destroyed.push(id); + } + } + } + + return ['Identity/set', { accountId: ACCOUNT_ID, oldState: nextState(), newState: nextState(), created, updated, destroyed, notCreated: null, notUpdated: null, notDestroyed: null }, callId]; } function handleThreadGet(args: MethodArgs, callId: string): MethodResult { From 196e51e91be2eee2520cfb02ee802926f673b96f Mon Sep 17 00:00:00 2001 From: Phongsaton Untan Date: Sat, 30 May 2026 07:19:38 +0700 Subject: [PATCH 03/12] fix: preserve HTML signature when sending a quick reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quick-reply box built its body with appendPlainTextSignature, which runs the identity's HTML signature through htmlToPlainText, and sent a text-only message (htmlBody was undefined). A formatted signature (e.g. …) was therefore flattened to plain text in the sent mail, even though it previewed correctly in the identity editor. The full composer already builds an HTML signature block; quick reply did not. Add an appendHtmlSignature helper (mirrors the composer's send-time block) and, when the sending identity has an HTML signature, send a matching HTML body from handleQuickReply so the markup is preserved. Text-only identities keep the plain-text-only behavior. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/(main)/[locale]/page.tsx | 22 +++++++++++++++----- lib/__tests__/signature-utils.test.ts | 22 ++++++++++++++++++++ lib/signature-utils.ts | 29 +++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 5 deletions(-) diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index ae57e8ec..dfaa9a20 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -55,7 +55,7 @@ import { useProMultiAccountMailboxes } from "@/hooks/use-pro-multi-account-mailb import { Input } from "@/components/ui/input"; import { FilePreviewModal } from "@/components/files/file-preview-modal"; import { isFilePreviewable } from "@/lib/file-preview"; -import { appendPlainTextSignature } from "@/lib/signature-utils"; +import { appendHtmlSignature, appendPlainTextSignature } from "@/lib/signature-utils"; import { computeReplyThreadingHeaders } from "@/lib/email-threading"; import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import"; import { resolveReplyFrom } from "@/lib/reply-identity"; @@ -2090,9 +2090,21 @@ export default function Home() { // Append signature from the sending identity (fall back to primary // when the reply-from lives on the same identity but a different alias). - const finalBody = appendPlainTextSignature(body, sendingIdentity, { - separator: useSettingsStore.getState().signatureSeparatorEnabled, - }); + const separator = useSettingsStore.getState().signatureSeparatorEnabled; + const finalBody = appendPlainTextSignature(body, sendingIdentity, { separator }); + + // When the identity has an HTML signature, send a matching HTML body so the + // signature keeps its formatting; appendPlainTextSignature would otherwise + // flatten it to plain text. Text-only identities keep the plain-text-only + // behavior (htmlBody stays undefined). + const escapedBody = body + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + const finalHtmlBody = sendingIdentity?.htmlSignature?.trim() + ? appendHtmlSignature(`
${escapedBody}
`, sendingIdentity, { separator }) + : undefined; const originalEmailId = selectedEmail.id; const sendDelaySeconds = useSettingsStore.getState().sendDelaySeconds; @@ -2124,7 +2136,7 @@ export default function Home() { headerFromEmail, undefined, headerFromName, - undefined, + finalHtmlBody, undefined, threading?.inReplyTo, threading?.references, diff --git a/lib/__tests__/signature-utils.test.ts b/lib/__tests__/signature-utils.test.ts index d2a95d61..27cb2d52 100644 --- a/lib/__tests__/signature-utils.test.ts +++ b/lib/__tests__/signature-utils.test.ts @@ -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('
Hello
', { htmlSignature: 'Alice' })) + .toBe('
Hello


--
Alice'); + }); + + it('escapes and appends a text signature when no html signature exists', () => { + expect(appendHtmlSignature('
Hello
', { textSignature: 'Alice\nEng' })) + .toBe('
Hello


--
Alice
Eng'); + }); + + it('omits the separator marker when disabled', () => { + expect(appendHtmlSignature('
Hi
', { htmlSignature: 'A' }, { separator: false })) + .toBe('
Hi


A'); + }); + + it('leaves the body untouched when no signature exists', () => { + expect(appendHtmlSignature('
Hi
', {})).toBe('
Hi
'); + }); + }); + describe('hasMeaningfulHtmlBody', () => { it('prefers html bodies that preserve signature formatting', () => { expect(hasMeaningfulHtmlBody('
Hello

Alice

')).toBe(true); diff --git a/lib/signature-utils.ts b/lib/signature-utils.ts index f348124e..678fa461 100644 --- a/lib/signature-utils.ts +++ b/lib/signature-utils.ts @@ -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 ? '

' : '

--
'; + + if (signature?.htmlSignature?.trim()) { + return `${htmlBody}${sep}${sanitizeSignatureHtml(signature.htmlSignature)}`; + } + + if (signature?.textSignature?.trim()) { + const escaped = signature.textSignature + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\n/g, '
'); + return `${htmlBody}${sep}${escaped}`; + } + + return htmlBody; +} + export function hasMeaningfulHtmlBody(html: string): boolean { if (!html.trim()) return false; From f0d87d594a25c2a2d4ffc0555f1cf9328ec6fcc8 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 12:00:44 +0200 Subject: [PATCH 04/12] Fix: honour basePath in plugin sandbox, http.post proxy, and branding Upstream 1.7.2 prefixes most hand-written URLs with basePath via apiFetch / withBasePath, but four subpath-relevant spots were missed: - host-bridge: the sandbox iframe src was a bare "/plugin-sandbox" -> 404 under NEXT_PUBLIC_BASE_PATH, breaking all plugins. Wrap in withBasePath. - host-api doHttpPost: the same-origin /api/* plugin proxy used raw fetch on url.pathname -> 404 under a subpath. Route it through apiFetch. - admin branding preview : unprefixed src -> broken thumbnail. - (sandbox) layout: drop the Geist font + globals.css imports. The sandbox runs with an opaque origin, so those assets are CORS-blocked; the plugin bundle and all host API calls travel over the postMessage bridge, so no same-origin asset fetch happens there. --- app/(main)/admin/_tabs/branding.tsx | 6 +++--- app/(sandbox)/layout.tsx | 24 +++++++++--------------- lib/plugin-sandbox/host-api.ts | 2 +- lib/plugin-sandbox/host-bridge.ts | 6 +++++- 4 files changed, 18 insertions(+), 20 deletions(-) diff --git a/app/(main)/admin/_tabs/branding.tsx b/app/(main)/admin/_tabs/branding.tsx index 09bd2ce7..99e2249a 100644 --- a/app/(main)/admin/_tabs/branding.tsx +++ b/app/(main)/admin/_tabs/branding.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from 'react'; import { Save, Loader2, RotateCcw, ImageIcon, Upload, Trash2, Globe, Plus, X } from 'lucide-react'; -import { apiFetch } from '@/lib/browser-navigation'; +import { apiFetch, withBasePath } from '@/lib/browser-navigation'; import { BRANDING_OVERRIDE_KEYS, parseDomainBranding, @@ -528,7 +528,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} @@ -606,7 +606,7 @@ export function BrandingTab() {
{field.label} { (e.target as HTMLImageElement).style.display = 'none'; }} diff --git a/app/(sandbox)/layout.tsx b/app/(sandbox)/layout.tsx index 9813c79d..45dfe938 100644 --- a/app/(sandbox)/layout.tsx +++ b/app/(sandbox)/layout.tsx @@ -1,17 +1,14 @@ import type { Metadata } from 'next'; import type { ReactNode } from 'react'; -import { Geist, Geist_Mono } from 'next/font/google'; -import '../globals.css'; -const geistSans = Geist({ - variable: '--font-geist-sans', - subsets: ['latin'], -}); - -const geistMono = Geist_Mono({ - variable: '--font-geist-mono', - subsets: ['latin'], -}); +// The plugin sandbox iframe runs with an opaque origin (the `sandbox` +// attribute in production excludes `allow-same-origin` for isolation). Any +// asset request from this layout - bundled fonts, globals.css, etc. - is then +// cross-origin from the "null" origin to the host origin and gets blocked +// (fonts in particular require CORS). So this layout is intentionally minimal: +// no font imports, no CSS imports. Plugins ship their own styles, and both the +// plugin bundle and all host API calls travel over the postMessage RPC bridge, +// so the sandbox never fetches same-origin assets itself. export const metadata: Metadata = { title: 'Plugin sandbox', @@ -21,10 +18,7 @@ export const metadata: Metadata = { export default function PluginSandboxLayout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/lib/plugin-sandbox/host-api.ts b/lib/plugin-sandbox/host-api.ts index 856650d0..168c65ac 100644 --- a/lib/plugin-sandbox/host-api.ts +++ b/lib/plugin-sandbox/host-api.ts @@ -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), diff --git a/lib/plugin-sandbox/host-bridge.ts b/lib/plugin-sandbox/host-bridge.ts index 90b0c6df..a0235ec8 100644 --- a/lib/plugin-sandbox/host-bridge.ts +++ b/lib/plugin-sandbox/host-bridge.ts @@ -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); From 229992853b43a30889e6890b5c2c9ff6a1228cf9 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 12:35:59 +0200 Subject: [PATCH 05/12] Fix: localize the PWA install prompt The PWA install prompt was hardcoded English regardless of the selected UI language (and the large English block tripped Chrome's translate popup on Android). Add a pwa_install namespace to all 17 locales, switch the component to useTranslations, and move from (main)/layout into (main)/[locale]/layout so it renders inside the IntlProvider. The title keeps the dynamic {appName}, so per-domain branding still applies. --- app/(main)/[locale]/layout.tsx | 2 ++ app/(main)/layout.tsx | 2 -- components/pwa-install-prompt.tsx | 14 ++++++++------ locales/cs/common.json | 8 ++++++++ locales/da/common.json | 8 ++++++++ locales/de/common.json | 8 ++++++++ locales/en/common.json | 8 ++++++++ locales/es/common.json | 8 ++++++++ locales/fr/common.json | 8 ++++++++ locales/it/common.json | 8 ++++++++ locales/ja/common.json | 8 ++++++++ locales/ko/common.json | 8 ++++++++ locales/lv/common.json | 8 ++++++++ locales/nl/common.json | 8 ++++++++ locales/pl/common.json | 8 ++++++++ locales/pt/common.json | 8 ++++++++ locales/ru/common.json | 8 ++++++++ locales/tr/common.json | 8 ++++++++ locales/uk/common.json | 8 ++++++++ locales/zh/common.json | 8 ++++++++ 20 files changed, 146 insertions(+), 8 deletions(-) diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 60944ea5..8c3126af 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -9,6 +9,7 @@ import { ProtocolLaunchHandlerProvider } from "@/components/protocol/protocol-la import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; +import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { locales } from "@/i18n/routing"; export default async function LocaleLayout({ @@ -41,6 +42,7 @@ export default async function LocaleLayout({ {children} + diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index ac5c3b42..99468f94 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata, Viewport } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { getLocale } from "next-intl/server"; -import { PWAInstallPrompt } from "@/components/pwa-install-prompt"; import { ServiceWorkerRegistration } from "@/components/service-worker-registration"; import { configManager } from "@/lib/admin/config-manager"; import { withBasePath } from "@/lib/browser-navigation"; @@ -92,7 +91,6 @@ export default async function RootLayout({ > {children} - ); diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index 00c8bfb3..4cc95f95 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useState } from "react"; +import { useTranslations } from "next-intl"; import { X, Download } from "lucide-react"; import { useConfig } from "@/hooks/use-config"; import { withBasePath } from "@/lib/browser-navigation"; @@ -17,6 +18,7 @@ export function PWAInstallPrompt() { useState(null); const [showPrompt, setShowPrompt] = useState(false); const { appName, faviconUrl, appLogoLightUrl, appLogoDarkUrl } = useConfig(); + const t = useTranslations("pwa_install"); useEffect(() => { if (localStorage.getItem(DISMISSED_KEY)) return; @@ -84,17 +86,17 @@ export function PWAInstallPrompt() { )}

- Install {appName} + {t("title", { appName })}

- Install our app for quick access and offline support. + {t("description")}

@@ -105,20 +107,20 @@ export function PWAInstallPrompt() { onClick={handleDismiss} className="flex-1 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-300 bg-neutral-100 dark:bg-neutral-800 rounded hover:bg-neutral-200 dark:hover:bg-neutral-700 transition-colors" > - Not now + {t("not_now")}
diff --git a/locales/cs/common.json b/locales/cs/common.json index 6620a55b..40b0ed6a 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Nainstalovat {appName}", + "description": "Nainstalujte si naši aplikaci pro rychlý přístup a offline podporu.", + "not_now": "Teď ne", + "install": "Nainstalovat", + "dont_remind": "Už mi to nepřipomínat", + "dismiss_aria": "Zavřít výzvu k instalaci" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/da/common.json b/locales/da/common.json index 2f455b42..7b379c5d 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Installer {appName}", + "description": "Installer vores app for hurtig adgang og offline-understøttelse.", + "not_now": "Ikke nu", + "install": "Installer", + "dont_remind": "Påmind mig ikke igen", + "dismiss_aria": "Afvis installationsprompt" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/de/common.json b/locales/de/common.json index fa621a99..7b3cd5ca 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} installieren", + "description": "Installiere unsere App für schnellen Zugriff und Offline-Unterstützung.", + "not_now": "Nicht jetzt", + "install": "Installieren", + "dont_remind": "Nicht mehr erinnern", + "dismiss_aria": "Installationshinweis schließen" + }, "login": { "title": "Webmail", "username_label": "E-Mail", diff --git a/locales/en/common.json b/locales/en/common.json index 1dbba593..f9ababbe 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Install {appName}", + "description": "Install our app for quick access and offline support.", + "not_now": "Not now", + "install": "Install", + "dont_remind": "Don't remind me again", + "dismiss_aria": "Dismiss install prompt" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/es/common.json b/locales/es/common.json index 7b83d66a..c75a5ed2 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Instalar {appName}", + "description": "Instala nuestra app para un acceso rápido y soporte sin conexión.", + "not_now": "Ahora no", + "install": "Instalar", + "dont_remind": "No volver a recordármelo", + "dismiss_aria": "Cerrar aviso de instalación" + }, "login": { "title": "Correo Web", "username_label": "Correo electrónico", diff --git a/locales/fr/common.json b/locales/fr/common.json index f9cb00ad..3d8b23ce 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Installer {appName}", + "description": "Installez notre application pour un accès rapide et un support hors ligne.", + "not_now": "Pas maintenant", + "install": "Installer", + "dont_remind": "Ne plus me le rappeler", + "dismiss_aria": "Fermer l'invite d'installation" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/it/common.json b/locales/it/common.json index 3c96458a..59e685d9 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Installa {appName}", + "description": "Installa la nostra app per un accesso rapido e il supporto offline.", + "not_now": "Non ora", + "install": "Installa", + "dont_remind": "Non ricordarmelo più", + "dismiss_aria": "Chiudi avviso di installazione" + }, "login": { "title": "Webmail", "username_label": "Email", diff --git a/locales/ja/common.json b/locales/ja/common.json index aa366688..19790b80 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} をインストール", + "description": "素早いアクセスとオフライン対応のため、アプリをインストールしましょう。", + "not_now": "後で", + "install": "インストール", + "dont_remind": "今後表示しない", + "dismiss_aria": "インストールプロンプトを閉じる" + }, "login": { "title": "ウェブメール", "username_label": "メールアドレス", diff --git a/locales/ko/common.json b/locales/ko/common.json index 79a238d7..fc5cc258 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} 설치", + "description": "빠른 액세스와 오프라인 지원을 위해 앱을 설치하세요.", + "not_now": "나중에", + "install": "설치", + "dont_remind": "다시 알리지 않음", + "dismiss_aria": "설치 프롬프트 닫기" + }, "login": { "title": "웹메일", "username_label": "이메일", diff --git a/locales/lv/common.json b/locales/lv/common.json index 62a09509..980f1e3c 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Instalēt {appName}", + "description": "Instalējiet mūsu lietotni ātrai piekļuvei un bezsaistes atbalstam.", + "not_now": "Ne tagad", + "install": "Instalēt", + "dont_remind": "Vairs man neatgādināt", + "dismiss_aria": "Aizvērt instalēšanas paziņojumu" + }, "login": { "title": "Tīmekļa pasts", "username_label": "E-pasta adrese", diff --git a/locales/nl/common.json b/locales/nl/common.json index d1208b36..49b62770 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} installeren", + "description": "Installeer onze app voor snelle toegang en offline-ondersteuning.", + "not_now": "Niet nu", + "install": "Installeren", + "dont_remind": "Niet meer herinneren", + "dismiss_aria": "Installatiemelding sluiten" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pl/common.json b/locales/pl/common.json index ee550e28..b4d08a91 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Zainstaluj {appName}", + "description": "Zainstaluj naszą aplikację, aby uzyskać szybki dostęp i obsługę offline.", + "not_now": "Nie teraz", + "install": "Zainstaluj", + "dont_remind": "Nie przypominaj mi więcej", + "dismiss_aria": "Zamknij monit instalacji" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/pt/common.json b/locales/pt/common.json index 3f7ef129..6b8d8408 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Instalar {appName}", + "description": "Instale o nosso app para acesso rápido e suporte offline.", + "not_now": "Agora não", + "install": "Instalar", + "dont_remind": "Não lembrar novamente", + "dismiss_aria": "Dispensar aviso de instalação" + }, "login": { "title": "Webmail", "username_label": "E-mail", diff --git a/locales/ru/common.json b/locales/ru/common.json index 747b269c..0d4302af 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Установить {appName}", + "description": "Установите наше приложение для быстрого доступа и работы офлайн.", + "not_now": "Не сейчас", + "install": "Установить", + "dont_remind": "Больше не напоминать", + "dismiss_aria": "Закрыть запрос на установку" + }, "login": { "title": "Веб-почта", "username_label": "Электронная почта", diff --git a/locales/tr/common.json b/locales/tr/common.json index 287522ab..a0728370 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "{appName} uygulamasını yükle", + "description": "Hızlı erişim ve çevrimdışı destek için uygulamamızı yükleyin.", + "not_now": "Şimdi değil", + "install": "Yükle", + "dont_remind": "Bir daha hatırlatma", + "dismiss_aria": "Yükleme istemini kapat" + }, "login": { "title": "Webmail", "username_label": "E-posta", diff --git a/locales/uk/common.json b/locales/uk/common.json index e08ee372..3e0601e3 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "Встановити {appName}", + "description": "Встановіть наш застосунок для швидкого доступу та офлайн-підтримки.", + "not_now": "Не зараз", + "install": "Встановити", + "dont_remind": "Більше не нагадувати", + "dismiss_aria": "Закрити запит на встановлення" + }, "login": { "title": "Веб-пошта", "username_label": "Електронна пошта", diff --git a/locales/zh/common.json b/locales/zh/common.json index 00a7634f..cd296f35 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1,4 +1,12 @@ { + "pwa_install": { + "title": "安装 {appName}", + "description": "安装我们的应用,以获得快速访问和离线支持。", + "not_now": "暂不", + "install": "安装", + "dont_remind": "不再提醒", + "dismiss_aria": "关闭安装提示" + }, "login": { "title": "网页邮箱", "username_label": "邮箱地址", From 8353b28b339f0d795b7e958bebe4468c6f7ab818 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 13:05:36 +0200 Subject: [PATCH 06/12] =?UTF-8?q?Feature:=20extended=20filter=20rules=20?= =?UTF-8?q?=E2=80=94=20attachment=20field=20+=20multi-value=20conditions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an "Attachment" condition field (is present / of type ) backed by the RFC 5703 Sieve mime extension, matching the filename in both Content-Disposition and Content-Type headers so real-world senders that only put the name in Content-Type (Microsoft SMTPSVC, etc.) are caught. Users type extensions (pdf, doc) not MIME types. Also makes each text condition accept comma-separated multiple values emitted as a Sieve string list (OR within the condition), so "(domain1 OR domain2) AND attachment pdf/xml" is expressible in one rule. value is now string | string[] (single-value rules stay strings -> backward compatible). New filter locale keys in all 17 locales. --- components/filters/filter-rule-modal.tsx | 139 ++++++++++++++++---- components/settings/filter-settings.tsx | 29 ++++- lib/jmap/sieve-types.ts | 22 +++- lib/sieve/generator.ts | 65 ++++++++-- lib/sieve/parser.ts | 155 +++++++++++++++++++---- locales/cs/common.json | 5 + locales/da/common.json | 5 + locales/de/common.json | 5 + locales/en/common.json | 5 + locales/es/common.json | 5 + locales/fr/common.json | 5 + locales/it/common.json | 5 + locales/ja/common.json | 5 + locales/ko/common.json | 5 + locales/lv/common.json | 5 + locales/nl/common.json | 5 + locales/pl/common.json | 5 + locales/pt/common.json | 5 + locales/ru/common.json | 5 + locales/tr/common.json | 5 + locales/uk/common.json | 5 + locales/zh/common.json | 5 + 22 files changed, 431 insertions(+), 64 deletions(-) diff --git a/components/filters/filter-rule-modal.tsx b/components/filters/filter-rule-modal.tsx index 25b0eb9b..221a6e7a 100644 --- a/components/filters/filter-rule-modal.tsx +++ b/components/filters/filter-rule-modal.tsx @@ -27,7 +27,7 @@ interface FilterRuleModalProps { } const ALL_FIELDS: FilterConditionField[] = [ - "from", "to", "cc", "subject", "header", "size", "body", + "from", "to", "cc", "subject", "header", "size", "body", "attachment", ]; const TEXT_COMPARATORS: FilterComparator[] = [ @@ -36,6 +36,14 @@ const TEXT_COMPARATORS: FilterComparator[] = [ const SIZE_COMPARATORS: FilterComparator[] = ["greater_than", "less_than"]; +const ATTACHMENT_COMPARATORS: FilterComparator[] = ["has_any", "has_type"]; + +function comparatorsFor(field: FilterConditionField): FilterComparator[] { + if (field === "size") return SIZE_COMPARATORS; + if (field === "attachment") return ATTACHMENT_COMPARATORS; + return TEXT_COMPARATORS; +} + const ALL_ACTION_TYPES: FilterActionType[] = [ "move", "copy", "forward", "mark_read", "star", "add_label", "discard", "reject", "keep", "stop", ]; @@ -47,6 +55,27 @@ function makeEmptyCondition(): FilterCondition { return { field: "from", comparator: "contains", value: "" }; } +// Multi-value handling: conditions are stored as string | string[]. The UI +// presents them as a single comma-separated text input — the user types +// "a, b, c" and the saved value becomes ["a","b","c"]. Single entries stay +// strings so existing single-value rules don't change shape. +function valueToInputString(v: string | string[]): string { + if (Array.isArray(v)) return v.join(", "); + return v; +} + +function inputStringToValue(s: string): string | string[] { + const parts = s.split(",").map((p) => p.trim()).filter((p) => p.length > 0); + if (parts.length === 0) return ""; + if (parts.length === 1) return parts[0]; + return parts; +} + +function isConditionValueEmpty(v: string | string[]): boolean { + if (Array.isArray(v)) return v.length === 0 || v.every((x) => !x.trim()); + return !v.trim(); +} + function makeEmptyAction(): FilterAction { return { type: "move", value: "" }; } @@ -97,9 +126,23 @@ export function FilterRuleModal({ return; } - const validConditions = conditions.filter( - (c) => c.value.trim() - ); + // While editing, condition.value is always the raw string typed into the + // input (commas not yet split). Convert to array form here on save so a + // user typing "a, b, c" actually persists as ["a","b","c"]. This is the + // moment we know editing is finished - splitting earlier would eat any + // comma the user just typed mid-edit. + const validConditions = conditions + .filter((c) => { + if (c.field === "attachment" && c.comparator === "has_any") return true; + return !isConditionValueEmpty(c.value); + }) + .map((c) => { + if (c.field === "attachment" && c.comparator === "has_any") return c; + if (c.field === "size") return c; // numeric, single-value only + if (typeof c.value !== "string") return c; // already structured + const parsed = inputStringToValue(c.value); + return { ...c, value: parsed }; + }); if (validConditions.length === 0) { toast.error(t("validation_empty_conditions")); return; @@ -129,15 +172,27 @@ export function FilterRuleModal({ prev.map((c, i) => { if (i !== index) return c; const updated = { ...c, ...updates }; - if (updates.field === "size" && !SIZE_COMPARATORS.includes(c.comparator)) { - updated.comparator = "greater_than"; - } - if (updates.field && updates.field !== "size" && SIZE_COMPARATORS.includes(c.comparator)) { - updated.comparator = "contains"; + // Reconcile the comparator when the field changes so we never end up + // with e.g. (field=attachment, comparator=contains) — invalid for the + // Sieve generator. Each field has its own valid comparator set. + if (updates.field && updates.field !== c.field) { + const allowed = comparatorsFor(updates.field); + if (!allowed.includes(c.comparator)) { + updated.comparator = allowed[0]; + } } if (updates.field && updates.field !== "header") { delete updated.headerName; } + // has_any takes no value; clear it so we don't leak old text into + // the generated Sieve. + if (updated.field === "attachment" && updated.comparator === "has_any") { + updated.value = ""; + } + // Size is numeric, single value only - collapse any list to scalar. + if (updated.field === "size" && Array.isArray(updated.value)) { + updated.value = updated.value[0] ?? ""; + } return updated; }) ); @@ -281,24 +336,58 @@ export function FilterRuleModal({ className={selectClass} aria-label={t("comparators.contains")} > - {(condition.field === "size" ? SIZE_COMPARATORS : TEXT_COMPARATORS).map( - (c) => ( - - ) - )} + {comparatorsFor(condition.field).map((c) => ( + + ))} - updateCondition(index, { value: e.target.value })} - placeholder={ - condition.field === "size" ? t("size_placeholder") : t("header_placeholder") - } - className="flex-1 min-w-[120px]" - type={condition.field === "size" ? "number" : "text"} - /> + {/* has_any takes no value; render a stub so the row layout + stays consistent but no input is editable. */} + {condition.field === "attachment" && condition.comparator === "has_any" ? ( +
+ ) : ( + + // Store the raw input string while typing. Splitting + // commas into an array on every keystroke would eat + // the comma the moment it's typed. + updateCondition(index, { value: e.target.value }) + } + onBlur={(e) => { + // On blur: normalise comma-separated input into an + // array (or single string when only one item). Size + // stays numeric/single-value; attachment-has_any has + // no value at all. + if (condition.field === "size") return; + if ( + condition.field === "attachment" && + condition.comparator === "has_any" + ) + return; + const parsed = inputStringToValue(e.target.value); + // Only update if the normalised shape actually + // differs - avoids triggering a no-op re-render and + // resetting the user's cursor on every blur. + if ( + JSON.stringify(parsed) !== JSON.stringify(condition.value) + ) { + updateCondition(index, { value: parsed }); + } + }} + placeholder={ + condition.field === "size" + ? t("size_placeholder") + : condition.field === "attachment" + ? t("attachment_type_placeholder") + : t("value_placeholder_multi") + } + className="flex-1 min-w-[120px]" + type={condition.field === "size" ? "number" : "text"} + /> + )}
-
+
From plugin: {current.pluginId}
diff --git a/lib/admin/plugin-registry.ts b/lib/admin/plugin-registry.ts index e1754f87..0913adf5 100644 --- a/lib/admin/plugin-registry.ts +++ b/lib/admin/plugin-registry.ts @@ -53,6 +53,13 @@ export interface ServerPlugin { forceEnabled?: boolean; configSchema?: Record; settingsSchema?: Record; + /** + * 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>; installedAt: string; updatedAt: string; /** diff --git a/lib/plugin-loader.ts b/lib/plugin-loader.ts index b388a41f..a4b1aa09 100644 --- a/lib/plugin-loader.ts +++ b/lib/plugin-loader.ts @@ -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) ────────────────────────── diff --git a/lib/plugin-sandbox/loader.ts b/lib/plugin-sandbox/loader.ts index 16894bb8..fbba5721 100644 --- a/lib/plugin-sandbox/loader.ts +++ b/lib/plugin-sandbox/loader.ts @@ -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 ───────────────────────────────────────────── diff --git a/lib/plugin-sandbox/runtime.tsx b/lib/plugin-sandbox/runtime.tsx index e171875e..dbd4ba51 100644 --- a/lib/plugin-sandbox/runtime.tsx +++ b/lib/plugin-sandbox/runtime.tsx @@ -73,18 +73,26 @@ function uid(): string { // ─── Sandboxed API facade (calls flow to host via postMessage) ─ -function callApi(method: string, args: unknown[]): Promise { +const DEFAULT_API_TIMEOUT_MS = 30_000; + +function callApi(method: string, args: unknown[], timeoutMs: number = DEFAULT_API_TIMEOUT_MS): Promise { 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, - /** Opens a host-rendered alert (one button). Resolves once dismissed. */ + callApi('ui.confirm', [opts], 0) as Promise, + /** 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, + callApi('ui.alert', [opts], 0) as Promise, /** 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, @@ -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 { + 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 { 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); diff --git a/stores/plugin-store.ts b/stores/plugin-store.ts index 017b7007..ad8d0604 100644 --- a/stores/plugin-store.ts +++ b/stores/plugin-store.ts @@ -6,7 +6,8 @@ import { persist } from 'zustand/middleware'; import type { InstalledPlugin, PluginStatus } from '@/lib/plugin-types'; import { pluginStorage } from '@/lib/plugin-storage'; import { extractPlugin } from '@/lib/plugin-validator'; -import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable } from '@/lib/plugin-loader'; +import { loadPlugin, deactivatePlugin, setPluginStoreAccessor, setupAutoDisable, setSandboxLocale } from '@/lib/plugin-loader'; +import { useLocaleStore } from '@/stores/locale-store'; import { removeAllPluginHooks } from '@/lib/plugin-hooks'; import { requestConsent } from '@/lib/plugin-sandbox/consent'; import { sha256Hex } from '@/lib/plugin-sandbox/bundle-integrity'; @@ -17,6 +18,8 @@ import { IMPLICIT_PERMISSIONS } from '@/lib/plugin-types'; import type { Permission } from '@/lib/plugin-types'; let pluginInitializationPromise: Promise | null = null; +// One-time guard so we attach the locale->sandbox subscription only once. +let localeSubscribed = false; // ─── Store Interface ───────────────────────────────────────── @@ -258,6 +261,17 @@ export const usePluginStore = create()( setPluginStatus: get().setPluginStatus, }); setupAutoDisable(); + // Keep the sandbox locale in step with the app locale. Set it + // synchronously *before* activation so background instances get the + // right locale in their init payload (the bug: the only wiring lived + // in the dead activateAllPlugins() path, so the sandbox locale stayed + // 'en' forever and plugin i18n never localized). Subscribe once for + // later language switches; those affect plugins/slots loaded after. + setSandboxLocale(useLocaleStore.getState().locale); + if (!localeSubscribed) { + localeSubscribed = true; + useLocaleStore.subscribe((s) => setSandboxLocale(s.locale)); + } // Sync server-managed plugins before loading await syncServerPlugins(get, set); @@ -324,6 +338,33 @@ interface ServerPluginInfo { apiPostPaths?: string[]; /** Per-user settings schema, captured from the manifest server-side. */ settingsSchema?: InstalledPlugin['settingsSchema']; + /** Plugin-declared i18n tables (locale -> key -> string), from the manifest. */ + locales?: InstalledPlugin['locales']; +} + +/** + * Server-owned metadata, passed through verbatim on every sync. Centralised in + * ONE place so a newly added passthrough field can't be silently dropped at one + * of several copy sites - which is exactly what previously lost `settingsSchema` + * (hence the old "schema drift" special-case) and then `locales`. Excludes + * fields the client owns (id, type, enabled/status, settings, adminApproved). + */ +function serverMeta(sp: ServerPluginInfo) { + return { + name: sp.name, + version: sp.version, + author: sp.author, + description: sp.description, + permissions: sp.permissions, + entrypoint: sp.entrypoint, + managed: true as const, + forceEnabled: sp.forceEnabled, + bundleHash: sp.bundleHash, + httpOrigins: sp.httpOrigins, + apiPostPaths: sp.apiPostPaths, + settingsSchema: sp.settingsSchema, + locales: sp.locales, + }; } const SERVER_MANAGED_KEY = 'server-managed-plugin-ids'; @@ -402,113 +443,57 @@ async function syncServerPlugins( const local = get().plugins.find(p => p.id === sp.id); if (!local) { - // New server plugin - download and install + // New server plugin - download bundle and install. const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; - await pluginStorage.saveCode(sp.id, code); const plugin: InstalledPlugin = { id: sp.id, - name: sp.name, - version: sp.version, - author: sp.author, - description: sp.description, type: sp.type as InstalledPlugin['type'], - permissions: sp.permissions, - entrypoint: sp.entrypoint, enabled: sp.forceEnabled, status: sp.forceEnabled ? 'enabled' : 'installed', - managed: true, - forceEnabled: sp.forceEnabled, adminApproved: true, // Server-managed plugins are always approved settings: {}, - settingsSchema: sp.settingsSchema, - bundleHash: sp.bundleHash, - ...(sp.httpOrigins && sp.httpOrigins.length > 0 - ? { httpOrigins: sp.httpOrigins } - : {}), - ...(sp.apiPostPaths && sp.apiPostPaths.length > 0 - ? { apiPostPaths: sp.apiPostPaths } - : {}), + ...serverMeta(sp), }; + set(state => + state.plugins.some(p => p.id === sp.id) + ? {} + : { plugins: [...state.plugins, plugin] }, + ); + continue; + } - set(state => { - if (state.plugins.some(p => p.id === sp.id)) { - return {}; - } - return { plugins: [...state.plugins, plugin] }; - }); - } else if ( + // Existing plugin. Re-download the bundle only when the code actually + // changed, but ALWAYS re-derive server-owned metadata from one place + // (serverMeta) so no passthrough field is silently dropped on a + // metadata-only change. Only write when something differs, to avoid a + // needless persist/re-render on every sync. + const needsBundle = local.version !== sp.version || // bundleHash mismatch covers re-uploads of the same version with new - // code. Falsy local hash (older installs that never carried one) also - // forces a refresh so we capture the hash on the next sync. - (sp.bundleHash && local.bundleHash !== sp.bundleHash) - ) { - // Version or content changed - re-download bundle + // code; a falsy local hash (older installs) also forces a refresh so + // we capture the hash on the next sync. + (!!sp.bundleHash && local.bundleHash !== sp.bundleHash); + + if (needsBundle) { const code = await downloadPluginBundle(sp.id, sp.bundleHash); if (!code) continue; - await pluginStorage.saveCode(sp.id, code); + } + // Force-enable in the same pass when the server flips it on, so the user + // doesn't need a second refresh for it to run. + const shouldAutoEnable = sp.forceEnabled && !local.enabled; + const next: InstalledPlugin = { + ...local, + ...serverMeta(sp), + ...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}), + }; + if (needsBundle || JSON.stringify(next) !== JSON.stringify(local)) { set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { - ...p, - name: sp.name, - version: sp.version, - author: sp.author, - description: sp.description, - permissions: sp.permissions, - entrypoint: sp.entrypoint, - managed: true, - forceEnabled: sp.forceEnabled, - bundleHash: sp.bundleHash, - httpOrigins: sp.httpOrigins, - apiPostPaths: sp.apiPostPaths, - settingsSchema: sp.settingsSchema, - } - : p - ), - })); - } else if (local.managed !== true || local.forceEnabled !== sp.forceEnabled) { - // When forceEnabled flips on, enable the plugin in the same pass so - // the user doesn't need a second refresh for it to run. - const shouldAutoEnable = sp.forceEnabled && !local.enabled; - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { - ...p, - managed: true, - forceEnabled: sp.forceEnabled, - settingsSchema: sp.settingsSchema, - ...(shouldAutoEnable ? { enabled: true, status: 'enabled' as const } : {}), - } - : p - ), - })); - } else if ( - JSON.stringify(local.settingsSchema ?? null) !== JSON.stringify(sp.settingsSchema ?? null) - ) { - // Schema drift: the bundle is current but the persisted plugin record - // pre-dates the server passing settingsSchema through, so the per-user - // settings UI was rendering empty. Patch the schema in place. - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id ? { ...p, settingsSchema: sp.settingsSchema } : p - ), - })); - } else if (sp.forceEnabled && !local.enabled) { - // Force-enable if the server says so but client has it disabled - set(state => ({ - plugins: state.plugins.map(p => - p.id === sp.id - ? { ...p, enabled: true, status: 'enabled' as const, managed: true, forceEnabled: true } - : p - ), + plugins: state.plugins.map(p => (p.id === sp.id ? next : p)), })); } } From bebb394f542a759681f8d0ed15c086fdc20af176 Mon Sep 17 00:00:00 2001 From: dealerweb Date: Fri, 29 May 2026 13:22:38 +0200 Subject: [PATCH 11/12] Feature: read receipts (MDN, RFC 8098) Bulwark had no read-receipt support (JMAP/Stalwart have no native MDN). End-to-end, client-side, in three parts: - Request (compose): a toolbar toggle (MailCheck, green when on) sets Disposition-Notification-To on the outgoing message via the JMAP "header::asText" create property. Threaded composer -> page -> email-store -> client.sendEmail. Default from requestReadReceiptDefault. - Detect (viewer): reads Disposition-Notification-To case-insensitively from the parsed headers and shows a banner (green Send / red Ignore) in the unified notification bar. Hidden in Sent/Drafts/Trash/Junk and once handled. message/disposition-notification + message/delivery-status report parts are filtered out of the attachment list. - Respond (MDN): lib/mdn.ts builds an RFC 8098 multipart/report (text/plain + message/disposition-notification, UTF-8/base64, localized subject + body). client.sendReadReceipt uploads the blob, imports it into Sent via Email/import, then submits with an explicit envelope. Both Send and Ignore set the $MDNSent keyword (RFC 3503) so no client re-prompts. Behaviour configurable: ask / always / never. New: lib/mdn.ts, read-receipt-banner.tsx. Settings (requestReadReceiptDefault, readReceiptResponse) + UI. All 17 locales. --- app/(main)/[locale]/page.tsx | 3 +- components/email/email-composer.tsx | 23 ++- components/email/email-viewer.tsx | 119 +++++++++++++++- components/email/read-receipt-banner.tsx | 60 ++++++++ components/settings/composing-settings.tsx | 21 +++ lib/demo/demo-client.ts | 8 ++ lib/jmap/client-interface.ts | 22 +++ lib/jmap/client.ts | 112 ++++++++++++++- lib/mdn.ts | 154 +++++++++++++++++++++ locales/cs/common.json | 22 +++ locales/da/common.json | 22 +++ locales/de/common.json | 22 +++ locales/en/common.json | 22 +++ locales/es/common.json | 22 +++ locales/fr/common.json | 22 +++ locales/it/common.json | 22 +++ locales/ja/common.json | 22 +++ locales/ko/common.json | 22 +++ locales/lv/common.json | 22 +++ locales/nl/common.json | 22 +++ locales/pl/common.json | 22 +++ locales/pt/common.json | 22 +++ locales/ru/common.json | 22 +++ locales/tr/common.json | 22 +++ locales/uk/common.json | 22 +++ locales/zh/common.json | 22 +++ stores/email-store.ts | 6 +- stores/settings-store.ts | 8 ++ 28 files changed, 901 insertions(+), 9 deletions(-) create mode 100644 components/email/read-receipt-banner.tsx create mode 100644 lib/mdn.ts diff --git a/app/(main)/[locale]/page.tsx b/app/(main)/[locale]/page.tsx index a7c57c61..d9ab5b77 100644 --- a/app/(main)/[locale]/page.tsx +++ b/app/(main)/[locale]/page.tsx @@ -1130,6 +1130,7 @@ export default function Home() { inReplyTo?: string[]; references?: string[]; delayedUntil?: string; + requestReadReceipt?: boolean; }) => { if (!client) return; @@ -1137,7 +1138,7 @@ export default function Home() { const effectiveMode = pendingDraft?.mode ?? composerMode; const originalEmailId = selectedEmail?.id; - const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom); + const result = await sendEmail(client, data.to, data.subject, data.body, data.cc, data.bcc, data.identityId, data.fromEmail, data.draftId, data.fromName, data.htmlBody, data.attachments, data.inReplyTo, data.references, data.delayedUntil, data.envelopeMailFrom, { requestReadReceipt: data.requestReadReceipt }); setShowComposer(false); if (result.scheduled) { await refreshScheduledMetadata(client); diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx index bf80385c..acdf43fa 100644 --- a/components/email/email-composer.tsx +++ b/components/email/email-composer.tsx @@ -5,7 +5,7 @@ import { useFocusTrap } from "@/hooks/use-focus-trap"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown } from "lucide-react"; +import { X, Paperclip, Send, Save, Check, Loader2, AlertCircle, FileText, BookmarkPlus, ShieldCheck, Lock, CalendarClock, ChevronDown, MailCheck } from "lucide-react"; import { cn, formatFileSize, formatDateTime, generateUUID } from "@/lib/utils"; import { debug } from "@/lib/debug"; import { toast } from "@/stores/toast-store"; @@ -90,6 +90,7 @@ interface EmailComposerProps { inReplyTo?: string[]; references?: string[]; delayedUntil?: string; + requestReadReceipt?: boolean; }) => void | Promise; onScheduledSendCreated?: () => void | Promise; onClose?: () => void; @@ -206,6 +207,7 @@ export function EmailComposer({ const sendDelaySeconds = useSettingsStore((state) => state.sendDelaySeconds); const signaturePosition = useSettingsStore((state) => state.signaturePosition); const signatureSeparatorEnabled = useSettingsStore((state) => state.signatureSeparatorEnabled); + const requestReadReceiptDefault = useSettingsStore((state) => state.requestReadReceiptDefault); const activeIdentities = useIdentityStore((s) => s.identities); // Pro shell: surface identities from every connected account, grouped // for the From dropdown's s. Outside Pro this collapses to @@ -384,6 +386,7 @@ export function EmailComposer({ const [body, setBody] = useState(initialData?.body ?? getInitialBody()); const [showCc, setShowCc] = useState(initialData?.showCc ?? !!getInitialCc()); const [showBcc, setShowBcc] = useState(initialData?.showBcc ?? false); + const [requestReadReceipt, setRequestReadReceipt] = useState(requestReadReceiptDefault); const [draftId, setDraftId] = useState(initialData?.draftId ?? null); // Mirror of draftId for synchronous reads inside chained saves; React's // setDraftId is async, so a queued saveDraft would otherwise see the old @@ -1656,6 +1659,7 @@ export function EmailComposer({ attachments: uploadedAttachments.length > 0 ? uploadedAttachments : undefined, inReplyTo: threadingHeaders?.inReplyTo, references: threadingHeaders?.references, + requestReadReceipt, delayedUntil: effectiveDelayedUntil, }); @@ -2217,7 +2221,7 @@ export function EmailComposer({ )} {/* Bottom toolbar */} -
+
{/* Left side actions */}
)} + + {/* Read-receipt request toggle */} +
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index 0f5b25e6..08b6d603 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -83,6 +83,8 @@ import { useThemeStore } from "@/stores/theme-store"; import { EmailIdentityBadge } from "./email-identity-badge"; import { UnsubscribeBanner } from "./unsubscribe-banner"; import { CalendarInvitationBanner } from "./calendar-invitation-banner"; +import { ReadReceiptBanner } from "./read-receipt-banner"; +import { stripCrossAccountIdentityPrefix } from "@/hooks/use-pro-multi-account-identities"; import { useTour } from "@/components/tour/tour-provider"; import { useIsEmbedded } from "@/hooks/use-is-embedded"; import { SmimePassphraseDialog } from "@/components/settings/smime-passphrase-dialog"; @@ -911,6 +913,7 @@ export function EmailViewer({ const showToolbarLabels = useSettingsStore((state) => state.showToolbarLabels); const mailLayout = useSettingsStore((state) => state.mailLayout); const calendarInvitationParsingEnabled = useSettingsStore((state) => state.calendarInvitationParsingEnabled); + const readReceiptResponse = useSettingsStore((state) => state.readReceiptResponse); const hideInlineImageAttachments = useSettingsStore((state) => state.hideInlineImageAttachments); const attachmentImagePreviewsEnabled = useSettingsStore((state) => state.attachmentImagePreviewsEnabled); const dragOutActive = useMemo(() => isDragOutSupported(), []); @@ -2193,6 +2196,9 @@ export function EmailViewer({ // Hide inline cid-referenced images when the user has opted to keep them // out of the attachment list (default on): these are embedded in the body. .filter(att => !(hideInlineImageAttachments && att.cid && att.disposition === 'inline' && (att.type || '').startsWith('image/'))) + // Hide machine-readable report parts (MDN read-receipts, DSN bounce + // reports). These are required MIME parts, not real user attachments. + .filter(att => att.type !== 'message/disposition-notification' && att.type !== 'message/delivery-status') .map((attachment, index) => ({ id: attachment.blobId || `${attachment.name || 'attachment'}-${index}`, name: attachment.name || null, @@ -3252,6 +3258,103 @@ export function EmailViewer({ ? calendarInvitationParsingEnabled && !!findCalendarAttachment(email) : false; + // ── Read receipt (MDN, RFC 8098) ────────────────────────────── + // Detect a Disposition-Notification-To request on the open message. The + // header is parsed into email.headers by the client; look it up + // case-insensitively and extract the bare address. + const readReceiptRequestedBy = useMemo(() => { + const headers = email?.headers as Record | undefined; + if (!headers) return null; + let raw: string | undefined; + for (const key of Object.keys(headers)) { + if (key.toLowerCase() === 'disposition-notification-to') { + const v = headers[key]; + raw = Array.isArray(v) ? v[0] : v; + break; + } + } + if (!raw) return null; + const m = raw.match(/<([^>]+)>/); + const addr = (m ? m[1] : raw).trim(); + return addr || null; + }, [email?.headers]); + + // The identity whose address received the original message — the MDN is sent + // "from" that address. Falls back to the primary identity. + const receiptIdentity = useMemo(() => { + if (!identities?.length) return null; + const recipients = [...(email?.to || []), ...(email?.cc || [])] + .map(r => r.email?.toLowerCase()) + .filter(Boolean); + return identities.find(i => recipients.includes(i.email?.toLowerCase())) || identities[0]; + }, [identities, email?.to, email?.cc]); + + const mdnAlreadyHandled = email?.keywords?.['$mdnsent'] === true; + const [mdnHandledLocally, setMdnHandledLocally] = useState(false); + useEffect(() => { setMdnHandledLocally(false); }, [email?.id]); + + // Only offer the receipt for mail you're actually reading in a "received" + // location. Suppress your own copies (sent/drafts), discarded mail (trash), + // and spam (junk) - never confirm your address to spammers. Inbox, Archive + // and user folders all qualify. + const inReceiptEligibleFolder = !['sent', 'drafts', 'trash', 'junk'].includes(currentMailboxRole || ''); + + const shouldOfferReadReceipt = + !!readReceiptRequestedBy && + !mdnAlreadyHandled && + !mdnHandledLocally && + readReceiptResponse !== 'never' && + inReceiptEligibleFolder && + !isDraft && + !!receiptIdentity; + + const sendReadReceiptNow = useCallback(async (automatic: boolean) => { + if (!client || !email || !readReceiptRequestedBy || !receiptIdentity) return; + const { rawId } = stripCrossAccountIdentityPrefix(receiptIdentity.id); + try { + await client.sendReadReceipt({ + to: readReceiptRequestedBy, + fromEmail: receiptIdentity.email, + fromName: receiptIdentity.name, + identityId: rawId ?? receiptIdentity.id, + originalMessageId: email.messageId, + originalSubject: email.subject, + originalRecipient: receiptIdentity.email, + automatic, + subject: t('read_receipt.mdn_subject', { subject: email.subject || '' }), + humanText: t('read_receipt.mdn_body', { recipient: receiptIdentity.email }), + }); + await client.setKeyword(email.id, '$mdnsent'); + } catch (err) { + // Surface the failure instead of silently resetting the banner so we can + // see which step (upload / import / submission) failed. + console.error('Read-receipt (MDN) send failed:', err); + toast.error(t('read_receipt.send_failed'), { + message: err instanceof Error ? err.message : String(err), + }); + throw err; + } + }, [client, email, readReceiptRequestedBy, receiptIdentity, t]); + + const ignoreReadReceipt = useCallback(async () => { + setMdnHandledLocally(true); + if (client && email) { + // $MDNSent is the RFC 3503 flag every IMAP/JMAP client honours, so the + // request is suppressed everywhere - not just locally. + try { await client.setKeyword(email.id, '$mdnsent'); } catch { /* best effort */ } + } + }, [client, email]); + + // "always" mode: auto-send the MDN once when the message is opened. + const autoMdnRef = useRef(null); + useEffect(() => { + if (readReceiptResponse !== 'always') return; + if (!shouldOfferReadReceipt || !email) return; + if (autoMdnRef.current === email.id) return; + autoMdnRef.current = email.id; + sendReadReceiptNow(true).catch(() => { autoMdnRef.current = null; }); + }, [readReceiptResponse, shouldOfferReadReceipt, email?.id, sendReadReceiptNow]); + // Show loading skeleton while email is being fetched if (isLoading && !email) { return ( @@ -4946,9 +5049,10 @@ export function EmailViewer({ error={smimeUnlockError} /> - {/* Unified Notification Banner - External Content + Calendar Invitation */} + {/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */} {((hasBlockedContent && !allowExternalContent && externalContentPolicy !== 'allow') || - hasCalendarInvitation) && ( + hasCalendarInvitation || + (readReceiptResponse === 'ask' && shouldOfferReadReceipt)) && (
@@ -5003,6 +5107,17 @@ export function EmailViewer({ + {/* Read-receipt (MDN) request banner — only in "ask" mode */} + {readReceiptResponse === 'ask' && shouldOfferReadReceipt && readReceiptRequestedBy && ( +
+ sendReadReceiptNow(false)} + onIgnore={ignoreReadReceipt} + /> +
+ )} + {/* Calendar Invitation Banner */} {hasCalendarInvitation && (
diff --git a/components/email/read-receipt-banner.tsx b/components/email/read-receipt-banner.tsx new file mode 100644 index 00000000..933befc9 --- /dev/null +++ b/components/email/read-receipt-banner.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState } from 'react'; +import { MailCheck, Loader2, CheckCircle } from 'lucide-react'; +import { useTranslations } from 'next-intl'; + +interface ReadReceiptBannerProps { + /** Address that requested the receipt (Disposition-Notification-To). */ + requestedBy: string; + /** Sends the MDN. Should resolve when the receipt has been submitted. */ + onSend: () => Promise; + /** Suppresses the request without sending (sets $MDNSent server-side). */ + onIgnore: () => void; +} + +export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceiptBannerProps) { + const t = useTranslations('email_viewer.read_receipt'); + const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle'); + + if (state === 'sent') { + return ( +
+ + {t('sent')} +
+ ); + } + + return ( +
+ + {t('prompt')} + {requestedBy} +
+ + +
+
+ ); +} diff --git a/components/settings/composing-settings.tsx b/components/settings/composing-settings.tsx index c7787b6d..7646e488 100644 --- a/components/settings/composing-settings.tsx +++ b/components/settings/composing-settings.tsx @@ -28,6 +28,8 @@ export function ComposingSettings() { subAddressDelimiter, signaturePosition, signatureSeparatorEnabled, + requestReadReceiptDefault, + readReceiptResponse, updateSetting, } = useSettingsStore(); const { client } = useAuthStore(); @@ -78,6 +80,25 @@ export function ComposingSettings() { /> + + updateSetting('requestReadReceiptDefault', checked)} + /> + + + +