Files
webmail/lib/quote-header.ts
T

158 lines
6.3 KiB
TypeScript

// Builds the default reply/forward quote header and runs it through the
// emailHooks.onBuildQuoteHeader transform so plugins can replace it (e.g.
// with an Outlook-style From/Sent/To/Cc/Subject block).
//
// This module is the single source of truth for the default header strings -
// the composer keeps the same defaults inline as a fallback, but production
// flow goes through here.
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
// 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;
/** The forward header's To and Cc lines; left out when not given. */
toLabel?: string;
ccLabel?: string;
}
type HeaderAddress = { email?: string; name?: string };
/** "Name <address>, address", as the forward header's From line writes one. */
function formatAddressList(list: HeaderAddress[] | undefined): string {
return (list ?? [])
.filter((r) => r.email)
.map((r) => (r.name && r.name !== r.email ? `${r.name} <${r.email}>` : r.email as string))
.join(", ");
}
/**
* The To and Cc lines of a forward header, as Gmail and Outlook write them:
* one per list that has anyone on it, for a label the caller gave. Values
* are plain text; escape them for HTML.
*/
export function forwardRecipientLines(
email: { to?: HeaderAddress[]; cc?: HeaderAddress[] },
labels: { toLabel?: string; ccLabel?: string },
): { label: string; value: string }[] {
const lines: { label: string; value: string }[] = [];
const to = formatAddressList(email.to);
const cc = formatAddressList(email.cc);
if (labels.toLabel && to) lines.push({ label: labels.toLabel, value: to });
if (labels.ccLabel && cc) lines.push({ label: labels.ccLabel, value: cc });
return lines;
}
interface BuildArgs {
mode: "reply" | "replyAll" | "forward";
email: {
from?: { email?: string; name?: string }[];
to?: { email?: string; name?: string }[];
cc?: { email?: string; name?: string }[];
subject?: string;
receivedAt?: string;
};
newTo: string[];
newCc: string[];
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",
toLabel: "To",
ccLabel: "Cc",
};
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",
year: "numeric",
month: "short",
day: "numeric",
})
: "";
const from = email.from?.[0];
// 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}>`
: (from.email || from.name || unknownLabel))
: unknownLabel;
const subject = email.subject || "";
if (mode === "forward") {
const recipients = forwardRecipientLines(email, labels);
const text = `${labels.forwardedSeparator}\n${labels.fromLabel}: ${fromStrFull}\n${labels.dateLabel}: ${date}\n${labels.subjectLabel}: ${subject}\n`
+ recipients.map((line) => `${line.label}: ${line.value}\n`).join("");
// 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>`
+ recipients.map((line) => `${line.label}: ${escapeHtml(line.value)}<br>`).join("")
+ `<br></div>`;
return { html, text, wrapInBlockquote: false };
}
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 };
}
export async function buildQuoteHeader(args: BuildArgs): Promise<QuoteHeader> {
const def = defaultHeader(args);
const ctx: QuoteHeaderContext = {
mode: args.mode,
newTo: args.newTo,
newCc: args.newCc,
from: args.email.from?.[0]?.email
? { name: args.email.from[0].name, email: args.email.from[0].email }
: null,
to: (args.email.to ?? [])
.filter((r): r is { email: string; name?: string } => !!r.email)
.map((r) => ({ name: r.name, email: r.email })),
cc: (args.email.cc ?? [])
.filter((r): r is { email: string; name?: string } => !!r.email)
.map((r) => ({ name: r.name, email: r.email })),
subject: args.email.subject ?? "",
date: args.email.receivedAt
? formatDateTime(args.email.receivedAt, args.timeFormat, {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
: "",
receivedAt: args.email.receivedAt,
locale: args.locale,
};
return emailHooks.onBuildQuoteHeader.transform<QuoteHeader>(def, ctx);
}