feat: pluggable reply/forward quote header #295
This commit is contained in:
+70
-3
@@ -67,6 +67,9 @@ import type { ParsedMailto } from "@/lib/protocol-handlers/mailto";
|
|||||||
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
import { plainTextToComposerBody } from "@/lib/email-composer-utils";
|
||||||
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
import { appLifecycleHooks, uiHooks, routerHooks, toastHooks, emailHooks } from "@/lib/plugin-hooks";
|
||||||
import { emailToReadView } from "@/lib/plugin-projection";
|
import { emailToReadView } from "@/lib/plugin-projection";
|
||||||
|
import { buildQuoteHeader } from "@/lib/quote-header";
|
||||||
|
import { useLocaleStore } from "@/stores/locale-store";
|
||||||
|
import type { QuoteHeader } from "@/lib/plugin-types";
|
||||||
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
@@ -79,6 +82,9 @@ export default function Home() {
|
|||||||
const [composerDraftText, setComposerDraftText] = useState("");
|
const [composerDraftText, setComposerDraftText] = useState("");
|
||||||
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
const [pendingDraft, setPendingDraft] = useState<ComposerDraftData | null>(null);
|
||||||
const [composerSessionId, setComposerSessionId] = useState(0);
|
const [composerSessionId, setComposerSessionId] = useState(0);
|
||||||
|
// Plugin-resolved quote header for the next reply/forward composer open.
|
||||||
|
// Cleared on close so a subsequent "compose new" doesn't reuse stale state.
|
||||||
|
const [composerQuoteHeader, setComposerQuoteHeader] = useState<QuoteHeader | null>(null);
|
||||||
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
|
const suppressComposerStateSaveSessionRef = useRef<number | null>(null);
|
||||||
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
const { dialogProps: confirmDialogProps, confirm: confirmDialog } = useConfirmDialog();
|
||||||
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
const { dialogProps: promptDialogProps, prompt: promptDialog } = usePromptDialog();
|
||||||
@@ -1000,6 +1006,50 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build the quote header for a reply/forward open, running it through the
|
||||||
|
// emailHooks.onBuildQuoteHeader transform so plugins can replace it. Stores
|
||||||
|
// the result in composerQuoteHeader; the render site spreads it into
|
||||||
|
// EmailComposer.replyTo. Errors fall back to the composer's built-in
|
||||||
|
// header (state set to null).
|
||||||
|
const prepareComposerQuoteHeader = useCallback(async (
|
||||||
|
email: Email | null,
|
||||||
|
mode: 'reply' | 'replyAll' | 'forward',
|
||||||
|
) => {
|
||||||
|
if (!email) { setComposerQuoteHeader(null); return; }
|
||||||
|
try {
|
||||||
|
const replyTargets = (email.replyTo?.length
|
||||||
|
? email.replyTo
|
||||||
|
: email.from ?? []).filter(r => r.email).map(r => r.email!);
|
||||||
|
const newTo = mode === 'reply'
|
||||||
|
? replyTargets
|
||||||
|
: mode === 'replyAll'
|
||||||
|
? [...replyTargets, ...(email.to ?? []).filter(r => r.email).map(r => r.email!)]
|
||||||
|
: [];
|
||||||
|
const newCc = mode === 'replyAll'
|
||||||
|
? (email.cc ?? []).filter(r => r.email).map(r => r.email!)
|
||||||
|
: [];
|
||||||
|
const header = await buildQuoteHeader({
|
||||||
|
mode,
|
||||||
|
email: {
|
||||||
|
from: email.from,
|
||||||
|
to: email.to,
|
||||||
|
cc: email.cc,
|
||||||
|
subject: email.subject,
|
||||||
|
receivedAt: email.receivedAt,
|
||||||
|
},
|
||||||
|
newTo,
|
||||||
|
newCc,
|
||||||
|
locale: useLocaleStore.getState().locale,
|
||||||
|
timeFormat: useSettingsStore.getState().timeFormat,
|
||||||
|
unknownLabel: tCommon('unknown'),
|
||||||
|
});
|
||||||
|
setComposerQuoteHeader(header);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[quote-header] plugin transform failed; using default', err);
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
|
}
|
||||||
|
}, [tCommon]);
|
||||||
|
|
||||||
const handleReply = async (draftText?: string) => {
|
const handleReply = async (draftText?: string) => {
|
||||||
if (selectedEmail) {
|
if (selectedEmail) {
|
||||||
const ok = await emailHooks.onBeforeReply.intercept({
|
const ok = await emailHooks.onBeforeReply.intercept({
|
||||||
@@ -1008,6 +1058,9 @@ export default function Home() {
|
|||||||
mode: 'reply' as const,
|
mode: 'reply' as const,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
await prepareComposerQuoteHeader(selectedEmail, 'reply');
|
||||||
|
} else {
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
setComposerDraftText(draftText || "");
|
setComposerDraftText(draftText || "");
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
@@ -1075,6 +1128,9 @@ export default function Home() {
|
|||||||
mode: 'reply-all' as const,
|
mode: 'reply-all' as const,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
await prepareComposerQuoteHeader(selectedEmail, 'replyAll');
|
||||||
|
} else {
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
setComposerMode('replyAll');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1089,6 +1145,9 @@ export default function Home() {
|
|||||||
mode: 'forward' as const,
|
mode: 'forward' as const,
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
await prepareComposerQuoteHeader(selectedEmail, 'forward');
|
||||||
|
} else {
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
}
|
}
|
||||||
setComposerMode('forward');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
@@ -1917,22 +1976,25 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle reply from conversation view
|
// Handle reply from conversation view
|
||||||
const handleConversationReply = (email: Email) => {
|
const handleConversationReply = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
|
await prepareComposerQuoteHeader(email, 'reply');
|
||||||
setComposerMode('reply');
|
setComposerMode('reply');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConversationReplyAll = (email: Email) => {
|
const handleConversationReplyAll = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
|
await prepareComposerQuoteHeader(email, 'replyAll');
|
||||||
setComposerMode('replyAll');
|
setComposerMode('replyAll');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConversationForward = (email: Email) => {
|
const handleConversationForward = async (email: Email) => {
|
||||||
selectEmail(email);
|
selectEmail(email);
|
||||||
|
await prepareComposerQuoteHeader(email, 'forward');
|
||||||
setComposerMode('forward');
|
setComposerMode('forward');
|
||||||
setShowComposer(true);
|
setShowComposer(true);
|
||||||
if (isMobile) setActiveView('viewer');
|
if (isMobile) setActiveView('viewer');
|
||||||
@@ -2435,6 +2497,7 @@ export default function Home() {
|
|||||||
onReset={() => {
|
onReset={() => {
|
||||||
setShowComposer(false);
|
setShowComposer(false);
|
||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<EmailComposer
|
<EmailComposer
|
||||||
@@ -2454,6 +2517,9 @@ export default function Home() {
|
|||||||
messageId: selectedEmail.messageId,
|
messageId: selectedEmail.messageId,
|
||||||
inReplyTo: selectedEmail.inReplyTo,
|
inReplyTo: selectedEmail.inReplyTo,
|
||||||
references: selectedEmail.references,
|
references: selectedEmail.references,
|
||||||
|
quoteHeaderHtml: composerQuoteHeader?.html,
|
||||||
|
quoteHeaderText: composerQuoteHeader?.text,
|
||||||
|
quoteWrapInBlockquote: composerQuoteHeader?.wrapInBlockquote,
|
||||||
} : undefined)}
|
} : undefined)}
|
||||||
initialDraftText={composerDraftText}
|
initialDraftText={composerDraftText}
|
||||||
initialData={pendingDraft}
|
initialData={pendingDraft}
|
||||||
@@ -2473,6 +2539,7 @@ export default function Home() {
|
|||||||
setComposerMode('compose');
|
setComposerMode('compose');
|
||||||
setComposerDraftText("");
|
setComposerDraftText("");
|
||||||
setPendingDraft(null);
|
setPendingDraft(null);
|
||||||
|
setComposerQuoteHeader(null);
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
setActiveView('list');
|
setActiveView('list');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,14 @@ interface EmailComposerProps {
|
|||||||
messageId?: string;
|
messageId?: string;
|
||||||
inReplyTo?: string[];
|
inReplyTo?: string[];
|
||||||
references?: string[];
|
references?: string[];
|
||||||
|
// Pre-built quote header block. Supplied by the composer opener after it
|
||||||
|
// runs emailHooks.onBuildQuoteHeader through plugin transforms. When set,
|
||||||
|
// the composer uses these verbatim instead of building its own default
|
||||||
|
// "On X, Y wrote:" / "---------- Forwarded message ----------" block.
|
||||||
|
quoteHeaderHtml?: string;
|
||||||
|
quoteHeaderText?: string;
|
||||||
|
/** Mirror of QuoteHeader.wrapInBlockquote. Defaults to true. */
|
||||||
|
quoteWrapInBlockquote?: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,6 +254,12 @@ export function EmailComposer({
|
|||||||
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
|
? `${plainSep}${getPlainTextSignature(initialSignatureIdentity)}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
|
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||||
|
if (replyTo.quoteHeaderText !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
|
const body = mode === 'forward' ? originalText : quotedText;
|
||||||
|
return `${prefix}${signatureBlock}\n\n${replyTo.quoteHeaderText}\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === 'forward') {
|
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---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${replyTo.subject || ''}\n\n${originalText}`;
|
||||||
} else if (mode === 'reply' || mode === 'replyAll') {
|
} else if (mode === 'reply' || mode === 'replyAll') {
|
||||||
@@ -266,6 +280,19 @@ export function EmailComposer({
|
|||||||
separator: signatureSeparatorEnabled,
|
separator: signatureSeparatorEnabled,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Plugin override (resolved at composer open via onBuildQuoteHeader).
|
||||||
|
if (replyTo.quoteHeaderHtml !== undefined && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
|
const wrap = replyTo.quoteWrapInBlockquote !== false;
|
||||||
|
const originalHtml = replyTo.htmlBody
|
||||||
|
?? (replyTo.body
|
||||||
|
? replyTo.body.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/\n/g, '<br>')
|
||||||
|
: '');
|
||||||
|
const bodyHtml = wrap
|
||||||
|
? `<blockquote style="margin:0 0 0 0.8ex;border-left:2px solid #ccc;padding-left:1ex">${originalHtml}</blockquote>`
|
||||||
|
: originalHtml;
|
||||||
|
return `${prefix}${signatureBlock}<br>${replyTo.quoteHeaderHtml}${bodyHtml}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Build quoted content as HTML
|
// Build quoted content as HTML
|
||||||
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
if (replyTo.htmlBody && (mode === 'reply' || mode === 'replyAll' || mode === 'forward')) {
|
||||||
const quoteHeader = mode === 'forward'
|
const quoteHeader = mode === 'forward'
|
||||||
|
|||||||
@@ -222,6 +222,12 @@ export const emailHooks = {
|
|||||||
onBeforeReply: new HookBus(),
|
onBeforeReply: new HookBus(),
|
||||||
onBeforeReplyAll: new HookBus(),
|
onBeforeReplyAll: new HookBus(),
|
||||||
onBeforeForward: new HookBus(),
|
onBeforeForward: new HookBus(),
|
||||||
|
// Transform hook - lets plugins replace the quote header block ("On X,
|
||||||
|
// Y wrote:" / "---------- Forwarded message ----------") used when opening
|
||||||
|
// a reply or forward. Initial value: QuoteHeader (host default), second
|
||||||
|
// argument: QuoteHeaderContext. Handlers return a QuoteHeader (or
|
||||||
|
// undefined to pass through). Fires once per composer open.
|
||||||
|
onBuildQuoteHeader: new HookBus(),
|
||||||
// Intercept hook fired before a file is added to the composer as an
|
// Intercept hook fired before a file is added to the composer as an
|
||||||
// attachment. Handler receives AttachmentInfo (size/type/name only - the
|
// attachment. Handler receives AttachmentInfo (size/type/name only - the
|
||||||
// raw file is not exposed). Return false to refuse the upload.
|
// raw file is not exposed). Return false to refuse the upload.
|
||||||
|
|||||||
@@ -645,6 +645,52 @@ export interface ReplyContext {
|
|||||||
mode: 'reply' | 'reply-all' | 'forward';
|
mode: 'reply' | 'reply-all' | 'forward';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Second argument to onBuildQuoteHeader transform handlers. Describes the
|
||||||
|
* original message and how the host plans to render the quote header so
|
||||||
|
* plugins can produce a replacement block (e.g. an Outlook-style
|
||||||
|
* From/Sent/To/Cc/Subject section).
|
||||||
|
*/
|
||||||
|
export interface QuoteHeaderContext {
|
||||||
|
mode: 'reply' | 'replyAll' | 'forward';
|
||||||
|
/** Recipients of the new outgoing message (already resolved by the host). */
|
||||||
|
newTo: string[];
|
||||||
|
newCc: string[];
|
||||||
|
/** Original message metadata. */
|
||||||
|
from: { name?: string; email: string } | null;
|
||||||
|
to: { name?: string; email: string }[];
|
||||||
|
cc: { name?: string; email: string }[];
|
||||||
|
subject: string;
|
||||||
|
/** Pre-formatted date string the host already produced (locale-aware). */
|
||||||
|
date: string;
|
||||||
|
/** Raw ISO datetime, in case the plugin wants to reformat. */
|
||||||
|
receivedAt?: string;
|
||||||
|
/** Active UI locale (BCP-47), useful for Intl.DateTimeFormat in plugins. */
|
||||||
|
locale: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initial value for the onBuildQuoteHeader transform hook. Plugins return a
|
||||||
|
* replacement; returning undefined falls through to the next handler or the
|
||||||
|
* default. The composer splices `html` into HTML drafts and `text` into
|
||||||
|
* plain-text drafts.
|
||||||
|
*
|
||||||
|
* For HTML, returning a header that includes its own surrounding wrapper
|
||||||
|
* (`<div>...</div>`) is fine; the composer does not add extra wrappers.
|
||||||
|
* For text, the host appends the quoted body after the header.
|
||||||
|
*/
|
||||||
|
export interface QuoteHeader {
|
||||||
|
html: string;
|
||||||
|
text: string;
|
||||||
|
/**
|
||||||
|
* When false, the composer skips its default blockquote wrapping around the
|
||||||
|
* quoted body (HTML mode only). Use this for the Outlook style where the
|
||||||
|
* quoted message is intended to follow the header without indentation.
|
||||||
|
* Defaults to true (preserve the existing blockquote wrapping).
|
||||||
|
*/
|
||||||
|
wrapInBlockquote?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describes an attachment crossing an attachment hook (upload, download, preview).
|
* Describes an attachment crossing an attachment hook (upload, download, preview).
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
// 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 type { QuoteHeader, QuoteHeaderContext } from "@/lib/plugin-types";
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultHeader(args: BuildArgs): QuoteHeader {
|
||||||
|
const { mode, email, timeFormat, unknownLabel } = args;
|
||||||
|
const date = email.receivedAt
|
||||||
|
? formatDateTime(email.receivedAt, timeFormat, {
|
||||||
|
weekday: "short",
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
})
|
||||||
|
: "";
|
||||||
|
const from = email.from?.[0];
|
||||||
|
const fromStr = from ? `${from.name || from.email}` : unknownLabel;
|
||||||
|
const subject = email.subject || "";
|
||||||
|
|
||||||
|
if (mode === "forward") {
|
||||||
|
const text = `---------- Forwarded message ----------\nFrom: ${fromStr}\nDate: ${date}\nSubject: ${subject}\n`;
|
||||||
|
const html = `<div>---------- Forwarded message ----------<br>From: ${fromStr}<br>Date: ${date}<br>Subject: ${subject}<br><br></div>`;
|
||||||
|
return { html, text, wrapInBlockquote: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = `On ${date}, ${fromStr} wrote:\n`;
|
||||||
|
const html = `<div>On ${date}, ${fromStr} wrote:<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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user