feat: add plugin ui.rerenderEmail API and restyle read-receipt banner
This commit is contained in:
@@ -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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user