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 [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
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
const [tnefHtml, setTnefHtml] = useState<string | null>(null);
@@ -1205,7 +1214,7 @@ export function EmailViewer({
})();
return () => { cancelled = true; };
}, [email]);
}, [email, pluginRenderNonce]);
// TNEF (winmail.dat) detection and processing
useEffect(() => {
+49 -31
View File
@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import { MailCheck, Loader2, CheckCircle } from 'lucide-react';
import { MailCheck, Loader2, CheckCircle, X } from 'lucide-react';
import { useTranslations } from 'next-intl';
interface ReadReceiptBannerProps {
@@ -17,43 +17,61 @@ export function ReadReceiptBanner({ requestedBy, onSend, onIgnore }: ReadReceipt
const t = useTranslations('email_viewer.read_receipt');
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') {
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">
<CheckCircle className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
<span>{t('sent')}</span>
<div className="flex items-center gap-3 py-1">
<div className="w-10 h-10 rounded-full bg-success/15 text-success flex items-center justify-center flex-shrink-0 shadow-sm">
<CheckCircle className="w-5 h-5" />
</div>
<span className="text-sm text-muted-foreground">{t('sent')}</span>
</div>
);
}
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">
<MailCheck className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
<span className="text-foreground">{t('prompt')}</span>
<span className="break-all text-muted-foreground">{requestedBy}</span>
<div className="ms-auto flex items-center gap-2">
<button
onClick={async () => {
setState('sending');
try {
await onSend();
setState('sent');
} catch {
setState('idle');
}
}}
disabled={state === 'sending'}
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"
>
{state === 'sending' && <Loader2 className="w-3 h-3 animate-spin" />}
{t('send')}
</button>
<button
onClick={onIgnore}
className="rounded-md bg-red-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-red-700"
>
{t('ignore')}
</button>
<div className="flex items-start gap-3 py-1">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<MailCheck className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div>
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
Read receipt
</div>
<div className="text-sm font-medium text-foreground break-words">
{t('prompt')}
</div>
<div className="text-xs text-muted-foreground break-all">
Requested by <span className="text-foreground/80">{requestedBy}</span>
</div>
</div>
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
<button
onClick={async () => {
setState('sending');
try {
await onSend();
setState('sent');
} catch {
setState('idle');
}
}}
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>
);