mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 08:16:22 +00:00
158 lines
6.3 KiB
TypeScript
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);
|
|
}
|