Skip to content
File

Blob: src/client/components/email-detail.tsx

typescript292 lines
1import { useEffect, useMemo, useState } from "react";
2import { Download, FileText, Loader2, Lock, MailOpen, Shield, Trash2 } from "lucide-react";
3import { toast } from "@/client/components/toast";
4import { Button, Card, EmptyState } from "@/client/components/ui";
5import { prepareEmailHtml } from "@/client/lib/email-html";
6import {
7 downloadAttachment,
8 getRawEmailSource,
9 getErrorMessage,
10 type AuthDescriptor,
11 type EmailAttachment,
12 type EmailDetail as EmailDetailType,
13} from "@/client/lib/api";
14import { fullDate } from "@/client/lib/time";
15 
16interface EmailDetailProps {
17 address: string;
18 auth: AuthDescriptor;
19 email: EmailDetailType | null;
20 loading: boolean;
21 canDelete: boolean;
22 canViewRaw: boolean;
23 onDelete: (emailId: string) => void;
24}
25 
26function formatSize(sizeBytes: number) {
27 if (sizeBytes < 1024) {
28 return `${sizeBytes} B`;
29 }
30 if (sizeBytes < 1024 * 1024) {
31 return `${(sizeBytes / 1024).toFixed(1)} KB`;
32 }
33 return `${(sizeBytes / (1024 * 1024)).toFixed(1)} MB`;
34}
35 
36function buildSrcDoc(html: string, bodyAttributes = "", headHtml = "") {
37 return `<!doctype html>
38<html>
39 <head>
40 <meta charset="utf-8" />
41 <meta name="referrer" content="no-referrer" />
42 <base target="_blank" />
43 <style>
44 html {
45 background: transparent;
46 }
47
48 body {
49 margin: 0;
50 padding: 20px;
51 background: transparent;
52 }
53
54 img {
55 max-width: 100%;
56 height: auto;
57 }
58
59 [data-remote-blocked="image"] {
60 border: 1px dashed #52525b;
61 border-radius: 12px;
62 padding: 12px 14px;
63 color: #a1a1aa;
64 background: rgba(39, 39, 42, 0.5);
65 }
66
67 a {
68 color: #fb923c;
69 }
70 </style>
71 ${headHtml}
72 </head>
73 <body${bodyAttributes ? ` ${bodyAttributes}` : ""}>${html}</body>
74</html>`;
75}
76 
77export function EmailDetail({ address, auth, email, loading, canDelete, canViewRaw, onDelete }: EmailDetailProps) {
78 const [downloadingId, setDownloadingId] = useState<string | null>(null);
79 const [downloadingRaw, setDownloadingRaw] = useState(false);
80 const [allowRemoteContent, setAllowRemoteContent] = useState(false);
81 
82 useEffect(() => {
83 setAllowRemoteContent(false);
84 }, [email?.id]);
85 
86 const preparedHtml = useMemo(
87 () => (email?.html ? prepareEmailHtml(email.html, allowRemoteContent) : null),
88 [allowRemoteContent, email?.html, email?.id],
89 );
90 
91 const handleDownload = async (attachment: EmailAttachment) => {
92 if (!email) {
93 return;
94 }
95 
96 setDownloadingId(attachment.id);
97 
98 try {
99 const blob = await downloadAttachment(address, email.id, attachment.id, auth);
100 const fileName = attachment.filename ?? "attachment.bin";
101 const url = URL.createObjectURL(blob);
102 const link = document.createElement("a");
103 link.href = url;
104 link.download = fileName;
105 link.click();
106 URL.revokeObjectURL(url);
107 } catch (error) {
108 toast.error(getErrorMessage(error));
109 } finally {
110 setDownloadingId(null);
111 }
112 };
113 
114 const handleViewRaw = async () => {
115 if (!email) return;
116 
117 const rawWindow = window.open("about:blank", "_blank");
118 if (!rawWindow) {
119 toast.error("Browser blocked the raw source tab");
120 return;
121 }
122 
123 rawWindow.document.title = `Raw email ${email.id}`;
124 rawWindow.document.body.textContent = "Loading raw source...";
125 rawWindow.document.body.style.cssText =
126 "margin:0;padding:16px;background:#0f172a;color:#cbd5e1;font:14px/1.5 ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;";
127 rawWindow.document.documentElement.style.colorScheme = "dark";
128 setDownloadingRaw(true);
129 
130 try {
131 const rawSource = await getRawEmailSource(address, email.id, auth);
132 const pre = rawWindow.document.createElement("pre");
133 pre.style.cssText = "margin:0;white-space:pre-wrap;word-break:break-word;";
134 pre.textContent = rawSource;
135 rawWindow.document.body.textContent = "";
136 rawWindow.document.body.appendChild(pre);
137 } catch (error) {
138 rawWindow.close();
139 toast.error(getErrorMessage(error));
140 } finally {
141 setDownloadingRaw(false);
142 }
143 };
144 
145 if (loading && !email) {
146 return (
147 <Card className="flex min-h-[560px] items-center justify-center" aria-busy="true">
148 <div className="flex items-center gap-3 text-sm text-zinc-400">
149 <Loader2 className="h-4 w-4 animate-spin" />
150 Loading message...
151 </div>
152 </Card>
153 );
154 }
155 
156 if (!email) {
157 return (
158 <Card className="flex min-h-[560px] items-center justify-center">
159 <EmptyState
160 icon={<MailOpen className="h-7 w-7 text-zinc-600" />}
161 caption="Viewer"
162 heading="Select an email"
163 description="Pick a message from the list to view its contents."
164 />
165 </Card>
166 );
167 }
168 
169 return (
170 <section className="flex min-h-[560px] flex-col rounded-xl border border-zinc-800/60 bg-zinc-900 p-5">
171 {/* Header */}
172 <div className="min-w-0">
173 <div className="flex items-start justify-between gap-3">
174 <h2 className="min-w-0 wrap-break-word text-lg font-semibold tracking-tight text-zinc-100">
175 {email.subject}
176 </h2>
177 <div className="flex shrink-0 items-center gap-2">
178 {canViewRaw ? (
179 <Button
180 icon={downloadingRaw ? <Loader2 className="h-3 w-3 animate-spin" /> : <FileText className="h-3 w-3" />}
181 disabled={loading || downloadingRaw}
182 onClick={() => void handleViewRaw()}
183 >
184 View raw
185 </Button>
186 ) : null}
187 {canDelete ? (
188 <Button
189 variant="danger"
190 icon={<Trash2 className="h-3 w-3" />}
191 disabled={loading}
192 onClick={() => onDelete(email.id)}
193 >
194 Delete
195 </Button>
196 ) : (
197 <span className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-700/60 bg-zinc-800/40 px-2.5 py-1 text-xs font-medium text-zinc-500">
198 <Lock className="h-3 w-3" />
199 Read-only
200 </span>
201 )}
202 </div>
203 </div>
204 <p className="mt-1 text-xs text-zinc-400">
205 <span className="font-medium text-zinc-300">{email.fromName || email.fromAddress}</span>
206 {email.fromName ? <span className="ml-1 text-zinc-500">&lt;{email.fromAddress}&gt;</span> : null}
207 <span className="mx-1.5 text-zinc-500">&middot;</span>
208 {fullDate(email.receivedAt)}
209 <span className="mx-1.5 text-zinc-500">&middot;</span>
210 {formatSize(email.sizeBytes)}
211 </p>
212 {email.recipientAddress ? (
213 <p className="mt-1 text-xs text-zinc-400">
214 Delivered to <span className="font-medium text-zinc-300">{email.recipientAddress}</span>
215 </p>
216 ) : null}
217 </div>
218
219 {/* Attachments */}
220 {email.attachments.length > 0 ? (
221 <div className="mt-4 flex flex-wrap gap-2">
222 {email.attachments.map((attachment) => {
223 const busy = downloadingId === attachment.id;
224 return (
225 <button
226 key={attachment.id}
227 type="button"
228 className="flex items-center gap-3 rounded-lg border border-zinc-700/50 bg-zinc-800/40 px-3 py-2 text-left text-sm hover:border-zinc-600 hover:bg-zinc-800/70 disabled:opacity-50"
229 onClick={() => handleDownload(attachment)}
230 disabled={busy || loading}
231 >
232 {busy ? (
233 <Loader2 className="h-4 w-4 shrink-0 animate-spin text-accent-400" />
234 ) : (
235 <Download className="h-4 w-4 shrink-0 text-zinc-500" />
236 )}
237 <span>
238 <strong className="block text-xs font-medium text-zinc-300">
239 {attachment.filename || "attachment.bin"}
240 </strong>
241 <span className="text-xs text-zinc-400">{formatSize(attachment.sizeBytes)}</span>
242 </span>
243 </button>
244 );
245 })}
246 </div>
247 ) : null}
248
249 {/* Body */}
250 {email.html && preparedHtml ? (
251 <div className="mt-4 flex flex-1 flex-col gap-3">
252 <div className="flex h-8 items-center justify-between gap-3 rounded-lg border border-zinc-800/60 bg-zinc-800/30 px-3">
253 <span className="flex items-center gap-1.5 text-xs text-zinc-500">
254 <Shield className="h-3 w-3 text-zinc-500" />
255 Sandboxed view
256 </span>
257 {preparedHtml.blockedRemoteContent && !allowRemoteContent ? (
258 <button
259 type="button"
260 className="text-xs font-medium text-amber-200 transition-colors hover:text-amber-100"
261 onClick={() => setAllowRemoteContent(true)}
262 >
263 Load remote content
264 </button>
265 ) : preparedHtml.blockedRemoteContent && allowRemoteContent ? (
266 <span className="text-xs text-emerald-400">Remote content loaded</span>
267 ) : null}
268 </div>
269
270 <iframe
271 className="min-h-[520px] w-full flex-1 rounded-lg border border-zinc-800/60 bg-zinc-900"
272 title={`Email ${email.id}`}
273 sandbox="allow-popups allow-popups-to-escape-sandbox"
274 referrerPolicy="no-referrer"
275 srcDoc={buildSrcDoc(preparedHtml.html, preparedHtml.bodyAttributes, preparedHtml.headHtml)}
276 />
277 </div>
278 ) : (
279 <div className="mt-4 flex-1">
280 <div className="mb-2 flex items-center gap-1.5 text-xs text-zinc-500">
281 <FileText className="h-3.5 w-3.5" />
282 Plain text
283 </div>
284 <pre className="overflow-auto rounded-lg border border-zinc-800/60 bg-zinc-800/30 p-5 text-sm leading-relaxed text-zinc-300 whitespace-pre-wrap">
285 {email.text || "This email has no text content."}
286 </pre>
287 </div>
288 )}
289 </section>
290 );
291}