feat: add plugin ui.rerenderEmail API and restyle read-receipt banner

This commit is contained in:
Linus Rath
2026-07-09 16:04:33 +02:00
parent 5ccba83129
commit d3f77ef9cf
6 changed files with 73 additions and 34 deletions
+10 -1
View File
@@ -809,6 +809,15 @@ export function EmailViewer({
const [pluginRenderedHtml, setPluginRenderedHtml] = useState<string | null>(null); const [pluginRenderedHtml, setPluginRenderedHtml] = useState<string | null>(null);
const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null); const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>([]); const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>([]);
// Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin
// after the user unlocks a key from the banner) to force the onRenderEmailBody
// hook to run again for the open message so the body re-decrypts.
const [pluginRenderNonce, setPluginRenderNonce] = useState(0);
useEffect(() => {
const bump = () => setPluginRenderNonce((n) => n + 1);
window.addEventListener('plugin:rerender-email', bump);
return () => window.removeEventListener('plugin:rerender-email', bump);
}, []);
// TNEF (winmail.dat) support // TNEF (winmail.dat) support
const [tnefHtml, setTnefHtml] = useState<string | null>(null); const [tnefHtml, setTnefHtml] = useState<string | null>(null);
@@ -1205,7 +1214,7 @@ export function EmailViewer({
})(); })();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [email]); }, [email, pluginRenderNonce]);
// TNEF (winmail.dat) detection and processing // TNEF (winmail.dat) detection and processing
useEffect(() => { useEffect(() => {
+49 -31
View File
@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { MailCheck, Loader2, CheckCircle } from 'lucide-react'; import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
interface ReadReceiptBannerProps { interface ReadReceiptBannerProps {
@@ -17,43 +17,61 @@ export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceipt
const t = useTranslations('email_viewer.read_receipt'); const t = useTranslations('email_viewer.read_receipt');
const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle'); const [state, setState] = useState<'idle' | 'sending' | 'sent'>('idle');
// Matches the host's "External Content" banner row: a round tinted icon chip,
// an uppercase eyebrow, a foreground message, and neutral bordered actions.
if (state === 'sent') { if (state === 'sent') {
return ( return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-2 text-sm text-muted-foreground"> <div className="flex items-center gap-3 py-1">
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" /> <div className="w-10 h-10 rounded-full bg-success/15 text-success flex items-center justify-center flex-shrink-0 shadow-sm">
<span>{t('sent')}</span> <CheckCircle className="w-5 h-5" />
</div>
<span className="text-sm text-muted-foreground">{t('sent')}</span>
</div> </div>
); );
} }
return ( return (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-amber-300/60 bg-amber-50 px-3 py-2 text-sm dark:border-amber-700/50 dark:bg-amber-950/30"> <div className="flex items-start gap-3 py-1">
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" /> <div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<span className="text-foreground">{t('prompt')}</span> <MailCheck className="w-5 h-5" />
<span className="break-all text-muted-foreground">{requestedBy}</span> </div>
<div className="ms-auto flex items-center gap-2"> <div className="flex-1 min-w-0 space-y-2">
<button <div>
onClick={async () => { <div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
setState('sending'); Read receipt
try { </div>
await onSend(); <div className="text-sm font-medium text-foreground break-words">
setState('sent'); {t('prompt')}
} catch { </div>
setState('idle'); <div className="text-xs text-muted-foreground break-all">
} Requested by <span className="text-foreground/80">{requestedBy}</span>
}} </div>
disabled={state === 'sending'} </div>
className="inline-flex items-center gap-1.5 rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50" <div className="flex flex-wrap items-center gap-1.5 pt-0.5">
> <button
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />} onClick={async () => {
{t('send')} setState('sending');
</button> try {
<button await onSend();
onClick={onIgnore} setState('sent');
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700" } catch {
> setState('idle');
{t('ignore')} }
</button> }}
disabled={state === 'sending'}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px] disabled:opacity-50 disabled:cursor-not-allowed"
>
{state === 'sending' ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <MailCheck className="w-3.5 h-3.5" />}
{t('send')}
</button>
<button
onClick={onIgnore}
className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground px-3 py-1.5 rounded-md border border-border hover:bg-muted transition-colors min-h-[36px]"
>
<X className="w-3.5 h-3.5" />
{t('ignore')}
</button>
</div>
</div> </div>
</div> </div>
); );
+9
View File
@@ -56,6 +56,7 @@ const PERM_PER_METHOD: Record<string, Permission | null> = {
'ui.confirm': null, 'ui.confirm': null,
'ui.alert': null, 'ui.alert': null,
'ui.prompt': null, 'ui.prompt': null,
'ui.rerenderEmail': null,
'ui.openExternalUrl': null, 'ui.openExternalUrl': null,
}; };
@@ -410,6 +411,14 @@ export async function dispatchApiCall(
fields, fields,
}); });
} }
case 'ui.rerenderEmail': {
// Re-run the onRenderEmailBody hook for the currently open message. Used
// by crypto plugins after they change decryption state (e.g. an S/MIME key
// was just unlocked) so the body re-decrypts without a full reload — which
// would wipe the in-memory session keys.
window.dispatchEvent(new CustomEvent('plugin:rerender-email'));
return undefined;
}
case 'ui.openExternalUrl': { case 'ui.openExternalUrl': {
const url = String(args[0] ?? ''); const url = String(args[0] ?? '');
// Only http(s) - the sandbox should not be able to navigate the host // Only http(s) - the sandbox should not be able to navigate the host
+1 -1
View File
@@ -244,7 +244,7 @@ export const API_METHODS = [
'jmap.fetchBlob', 'jmap.sendRaw', 'jmap.fetchBlob', 'jmap.sendRaw',
'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig', 'admin.getConfig', 'admin.getAllConfig', 'admin.setConfig', 'admin.deleteConfig',
'toast.success', 'toast.error', 'toast.info', 'toast.warning', 'toast.success', 'toast.error', 'toast.info', 'toast.warning',
'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.openExternalUrl', 'ui.confirm', 'ui.alert', 'ui.prompt', 'ui.rerenderEmail', 'ui.openExternalUrl',
] as const; ] as const;
export type ApiMethod = (typeof API_METHODS)[number]; export type ApiMethod = (typeof API_METHODS)[number];
+3
View File
@@ -223,6 +223,9 @@ function buildPluginApi(manifest: PluginManifest) {
cancelLabel?: string; cancelLabel?: string;
fields?: Array<{ name: string; label: string; type?: 'text' | 'password'; placeholder?: string; required?: boolean }>; fields?: Array<{ name: string; label: string; type?: 'text' | 'password'; placeholder?: string; required?: boolean }>;
}) => callApi('ui.prompt', [opts], 0) as Promise<Record<string, string> | null>, }) => callApi('ui.prompt', [opts], 0) as Promise<Record<string, string> | null>,
/** Re-runs the onRenderEmailBody hook for the open message (e.g. after a
* crypto plugin unlocks a key) so its body re-renders without a reload. */
rerenderEmail: () => callApi('ui.rerenderEmail', []) as Promise<void>,
/** Opens an http/https URL in a new tab via host `window.open`. */ /** Opens an http/https URL in a new tab via host `window.open`. */
openExternalUrl: (url: string, target?: string) => openExternalUrl: (url: string, target?: string) =>
callApi('ui.openExternalUrl', [url, target]) as Promise<void>, callApi('ui.openExternalUrl', [url, target]) as Promise<void>,
+1 -1
View File
@@ -257,7 +257,7 @@
}, },
"email_viewer": { "email_viewer": {
"read_receipt": { "read_receipt": {
"prompt": "The sender requested a read receipt:", "prompt": "The sender asked to be notified when you open this message.",
"send": "Send receipt", "send": "Send receipt",
"ignore": "Ignore", "ignore": "Ignore",
"sent": "Read receipt sent.", "sent": "Read receipt sent.",