Merge branch 'main' of https://github.com/bulwarkmail/webmail
This commit is contained in:
@@ -11,7 +11,7 @@ import { debug } from "@/lib/debug";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml } from "@/lib/email-sanitization";
|
||||
import { sanitizeSignatureHtml, sanitizeEmailHtml, escapeHtml } from "@/lib/email-sanitization";
|
||||
import { buildReplySubject, buildForwardSubject } from "@/lib/subject-prefix";
|
||||
import { isFilePreviewable } from "@/lib/file-preview";
|
||||
import { buildQuotedHtmlBlock, serializeEditorContent } from "@/components/email/quoted-html";
|
||||
@@ -347,9 +347,8 @@ 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).
|
||||
// Forward "From:" and the reply "On … wrote:" line both show the full
|
||||
// sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -377,7 +376,7 @@ export function EmailComposer({
|
||||
if (mode === 'forward') {
|
||||
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\n${tQuote('reply_line', { date, from: fromStr })}\n${quotedText}`;
|
||||
return `${prefix}${signatureBlock}\n\n${tQuote('reply_line', { date, from: fromStrFull })}\n${quotedText}`;
|
||||
}
|
||||
return prefix;
|
||||
}
|
||||
@@ -399,7 +398,7 @@ 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 and reply quote lines both show the full "Name <email>" sender (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -434,9 +433,11 @@ export function EmailComposer({
|
||||
|
||||
// Build quoted content as HTML
|
||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||
// HTML-escape user-controlled values: an unescaped sender "Name <email>"
|
||||
// has its "<email>" eaten as a bogus HTML tag by the rich-text editor (#482).
|
||||
const quoteHeader = mode === 'forward'
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>`
|
||||
: `${tQuote('reply_line', { date, from: fromStr })}<br>`;
|
||||
? `${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>`
|
||||
: `${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br>`;
|
||||
// Embed the original as a QuotedHtml island (verbatim, schema-free) so
|
||||
// its layout survives the editor round-trip. Sanitize first to strip
|
||||
// scripts/styles/head; cid rewrite afterwards so data-cid markers
|
||||
@@ -450,9 +451,9 @@ export function EmailComposer({
|
||||
if (replyTo.body) {
|
||||
const escapedOriginal = replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>');
|
||||
if (mode === 'forward') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${fromStrFull}<br>${tQuote('date_label')}: ${date}<br>${tQuote('subject_label')}: ${replyTo.subject || ''}<br><br>${escapedOriginal}`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('forwarded_separator')}<br>${tQuote('from_label')}: ${escapeHtml(fromStrFull)}<br>${tQuote('date_label')}: ${escapeHtml(date)}<br>${tQuote('subject_label')}: ${escapeHtml(replyTo.subject || '')}<br><br>${escapedOriginal}`;
|
||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date, from: fromStr })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
return `${prefix}${signatureBlock}<br><br>${tQuote('reply_line', { date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br><blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${escapedOriginal}</blockquote>`;
|
||||
}
|
||||
}
|
||||
return prefix;
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildQuoteHeader } from '@/lib/quote-header';
|
||||
|
||||
const base = {
|
||||
newTo: [] as string[],
|
||||
newCc: [] as string[],
|
||||
locale: 'en',
|
||||
timeFormat: '24h' as const,
|
||||
unknownLabel: 'Unknown',
|
||||
};
|
||||
|
||||
const sender = { name: 'Display Name', email: 'user@domain.tld' };
|
||||
|
||||
describe('buildQuoteHeader (#482 — sender address survives HTML rendering)', () => {
|
||||
it('forward TEXT keeps the full "Name <email>" sender', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.text).toContain('From: Display Name <user@domain.tld>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes the angle brackets so the address is not eaten as a tag', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
// The regression: a raw "<user@domain.tld>" is parsed as an HTML tag by the
|
||||
// rich-text composer and dropped, leaving only "From: Display Name".
|
||||
expect(h.html).toContain('Display Name <user@domain.tld>');
|
||||
expect(h.html).not.toContain('<user@domain.tld>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes a subject containing markup (injection hardening)', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: { from: [sender], subject: 'Hi <b>x</b>', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.html).toContain('Hi <b>x</b>');
|
||||
expect(h.html).not.toContain('<b>x</b>');
|
||||
});
|
||||
|
||||
it('forward HTML escapes a malicious display name', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'forward',
|
||||
email: {
|
||||
from: [{ name: '<img src=x onerror=alert(1)>', email: 'evil@x.tld' }],
|
||||
subject: 'Hello',
|
||||
receivedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
...base,
|
||||
});
|
||||
expect(h.html).not.toContain('<img src=x');
|
||||
expect(h.html).toContain('<img src=x');
|
||||
});
|
||||
|
||||
it('reply line includes the full "Name <email>" sender, escaped in HTML', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [sender], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
// TEXT keeps the real angle brackets ("On <date>, Display Name <user@domain.tld> wrote:").
|
||||
expect(h.text).toContain('Display Name <user@domain.tld> wrote:');
|
||||
// HTML escapes them so the address survives the rich-text editor (#482).
|
||||
expect(h.html).toContain('Display Name <user@domain.tld>');
|
||||
expect(h.html).not.toContain('<user@domain.tld>');
|
||||
});
|
||||
|
||||
it('reply line stays HTML-safe for a display name containing markup', async () => {
|
||||
const evil = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [{ name: '<b>x</b>', email: 'e@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(evil.html).not.toContain('<b>x</b>');
|
||||
expect(evil.html).toContain('<b>x</b>');
|
||||
});
|
||||
|
||||
it('reply line falls back to bare email when there is no display name', async () => {
|
||||
const h = await buildQuoteHeader({
|
||||
mode: 'reply',
|
||||
email: { from: [{ email: 'noname@x.tld' }], subject: 'Hello', receivedAt: '2026-01-01T10:00:00Z' },
|
||||
...base,
|
||||
});
|
||||
expect(h.text).toContain('noname@x.tld wrote:');
|
||||
expect(h.text).not.toContain('<noname@x.tld>');
|
||||
});
|
||||
});
|
||||
@@ -120,16 +120,17 @@ describe('fetchUnifiedEmails', () => {
|
||||
expect(result).toEqual({ emails: [], total: 0, hasMore: false, errors: new Map() });
|
||||
});
|
||||
|
||||
it('CHARACTERISATION: mutates the source email objects in place (shared reference)', async () => {
|
||||
it('does NOT mutate the source email objects (decorates copies)', async () => {
|
||||
const original = makeEmail('m1', '2026-01-01T00:00:00Z');
|
||||
const acc = makeAccount(
|
||||
{ accountId: 'A', accountLabel: 'Label A', mailboxes: [makeMailbox({ role: 'inbox' })] },
|
||||
{ getEmails: vi.fn(async (): Promise<FetchResult> => ({ emails: [original], total: 1, hasMore: false })) },
|
||||
);
|
||||
await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||
// The very object passed back by the client was mutated, not a copy.
|
||||
expect(original.accountId).toBe('A');
|
||||
expect(original.accountLabel).toBe('Label A');
|
||||
const res = await fetchUnifiedEmails([acc], 'inbox', 20, 0);
|
||||
// The returned email carries the account info, but the client's object is untouched.
|
||||
expect(res.emails[0]).toMatchObject({ id: 'm1', accountId: 'A', accountLabel: 'Label A' });
|
||||
expect('accountId' in original).toBe(false);
|
||||
expect('accountLabel' in original).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+13
-8
@@ -8,6 +8,7 @@
|
||||
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import { emailHooks } from "@/lib/plugin-hooks";
|
||||
import { escapeHtml } from "@/lib/email-sanitization";
|
||||
import type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
|
||||
|
||||
// Localized label set the caller passes in. Labels live on the client where
|
||||
@@ -62,10 +63,8 @@ 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
|
||||
// <email>"), like every mail client. The reply line keeps the bare name
|
||||
// (reads more naturally in "On … wrote:").
|
||||
// Both the forward "From:" line and the reply "On … wrote:" line show the
|
||||
// full sender incl. address ("Name <email>"), like Gmail/Outlook (#482).
|
||||
const fromStrFull = from
|
||||
? (from.name && from.email && from.name !== from.email
|
||||
? `${from.name} <${from.email}>`
|
||||
@@ -75,13 +74,19 @@ function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||
|
||||
if (mode === "forward") {
|
||||
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`;
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${fromStrFull}<br>${labels.dateLabel}: ${date}<br>${labels.subjectLabel}: ${subject}<br><br></div>`;
|
||||
// Escape the interpolated values for the HTML variant: the sender string is
|
||||
// "Name <email>", and the unescaped "<email>" would be parsed as an HTML tag
|
||||
// by the rich-text composer and silently dropped (#482). Subject/name are
|
||||
// likewise user-controlled. Label/separator strings are trusted i18n text.
|
||||
const html = `<div>${labels.forwardedSeparator}<br>${labels.fromLabel}: ${escapeHtml(fromStrFull)}<br>${labels.dateLabel}: ${escapeHtml(date)}<br>${labels.subjectLabel}: ${escapeHtml(subject)}<br><br></div>`;
|
||||
return { html, text, wrapInBlockquote: false };
|
||||
}
|
||||
|
||||
const replyLine = labels.formatReplyLine({ date, from: fromStr });
|
||||
const text = `${replyLine}\n`;
|
||||
const html = `<div>${replyLine}<br></div>`;
|
||||
const text = `${labels.formatReplyLine({ date, from: fromStrFull })}\n`;
|
||||
// Escape the interpolated sender/date for the HTML reply line: the sender is
|
||||
// now "Name <email>", and the unescaped "<email>" would be parsed as an HTML
|
||||
// tag by the rich-text composer and dropped (#482). Label template is trusted.
|
||||
const html = `<div>${labels.formatReplyLine({ date: escapeHtml(date), from: escapeHtml(fromStrFull) })}<br></div>`;
|
||||
return { html, text, wrapInBlockquote: true };
|
||||
}
|
||||
|
||||
|
||||
+32
-25
@@ -119,16 +119,19 @@ export async function fetchUnifiedEmails(
|
||||
|
||||
const { account, result } = outcome.value;
|
||||
|
||||
// Decorate each email with the source account info.
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
// Decorate each email with the source account info. The per-account client
|
||||
// returns shared object references; decorate shallow copies instead of
|
||||
// mutating them in place so retained callers/snapshots aren't corrupted.
|
||||
const decorated = result.emails.map((email) => ({
|
||||
...email,
|
||||
accountId: account.accountId,
|
||||
accountLabel: account.accountLabel,
|
||||
sourceClientAccountId: account.clientAccountId,
|
||||
sourceAccountId: account.jmapAccountId,
|
||||
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
|
||||
}));
|
||||
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
mergedEmails = mergedEmails.concat(decorated);
|
||||
totalSum += result.total;
|
||||
if (result.hasMore) {
|
||||
anyHasMore = true;
|
||||
@@ -247,14 +250,16 @@ async function fanOutUnifiedQuery(
|
||||
for (const outcome of results) {
|
||||
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
|
||||
const { account, result } = outcome.value;
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
// Decorate shallow copies, not the shared client-returned objects.
|
||||
const decorated = result.emails.map((email) => ({
|
||||
...email,
|
||||
accountId: account.accountId,
|
||||
accountLabel: account.accountLabel,
|
||||
sourceClientAccountId: account.clientAccountId,
|
||||
sourceAccountId: account.jmapAccountId,
|
||||
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
|
||||
}));
|
||||
mergedEmails = mergedEmails.concat(decorated);
|
||||
totalSum += result.total;
|
||||
if (result.hasMore) anyHasMore = true;
|
||||
}
|
||||
@@ -383,14 +388,16 @@ async function fanOutCrossQuery(
|
||||
for (const outcome of results) {
|
||||
if (outcome.status !== 'fulfilled' || outcome.value === null) continue;
|
||||
const { account, result } = outcome.value;
|
||||
for (const email of result.emails) {
|
||||
email.accountId = account.accountId;
|
||||
email.accountLabel = account.accountLabel;
|
||||
email.sourceClientAccountId = account.clientAccountId;
|
||||
email.sourceAccountId = account.jmapAccountId;
|
||||
email.sourceFolder = resolveSourceFolderName(email, account.mailboxes);
|
||||
}
|
||||
mergedEmails = mergedEmails.concat(result.emails);
|
||||
// Decorate shallow copies, not the shared client-returned objects.
|
||||
const decorated = result.emails.map((email) => ({
|
||||
...email,
|
||||
accountId: account.accountId,
|
||||
accountLabel: account.accountLabel,
|
||||
sourceClientAccountId: account.clientAccountId,
|
||||
sourceAccountId: account.jmapAccountId,
|
||||
sourceFolder: resolveSourceFolderName(email, account.mailboxes),
|
||||
}));
|
||||
mergedEmails = mergedEmails.concat(decorated);
|
||||
totalSum += result.total;
|
||||
if (result.hasMore) anyHasMore = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user