File
Blob: src/client/components/email-detail.tsx
| 1 | import { useEffect, useMemo, useState } from "react"; |
| 2 | import { Download, FileText, Loader2, Lock, MailOpen, Shield, Trash2 } from "lucide-react"; |
| 3 | import { toast } from "@/client/components/toast"; |
| 4 | import { Button, Card, EmptyState } from "@/client/components/ui"; |
| 5 | import { prepareEmailHtml } from "@/client/lib/email-html"; |
| 6 | import { |
| 7 | downloadAttachment, |
| 8 | getRawEmailSource, |
| 9 | getErrorMessage, |
| 10 | type AuthDescriptor, |
| 11 | type EmailAttachment, |
| 12 | type EmailDetail as EmailDetailType, |
| 13 | } from "@/client/lib/api"; |
| 14 | import { fullDate } from "@/client/lib/time"; |
| 15 | |
| 16 | interface 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 | |
| 26 | function 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 | |
| 36 | function 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 | |
| 77 | export 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"><{email.fromAddress}></span> : null} |
| 207 | <span className="mx-1.5 text-zinc-500">·</span> |
| 208 | {fullDate(email.receivedAt)} |
| 209 | <span className="mx-1.5 text-zinc-500">·</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 | } |