/* RustBox UI module: 40-messages-reader.js */ async function loadMessages(reset = false, options = {}) { const list = $("#message-list"); const restoreState = Boolean(options.restoreState); const savedState = restoreState ? readMessageListState(state.folder) : null; const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null; if (reset) { state.page = 0; state.messages = []; state.selectedMessages.clear(); clearActiveSearchContext(); list.innerHTML = `
Loading messages…
`; } $("#folder-title").textContent = state.folder; try { const sort = encodeURIComponent(state.mailPrefs.default_sort || "date_desc"); const maxRestorePage = Math.max(0, Math.ceil(500 / Math.max(20, state.perPage)) - 1); const targetPage = reset && savedState ? Math.min(savedState.page, maxRestorePage) : state.page; const pages = reset && targetPage > 0 ? Array.from({ length: targetPage + 1 }, (_, index) => index) : [state.page]; let loaded = []; let totalCount = 0; let flaggedOnTop = state.currentFolderFlaggedOnTop; let lastLoadedPage = state.page; for (const page of pages) { const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}`, { method: "GET" }); totalCount = data.total_count || 0; flaggedOnTop = Boolean(data.flagged_on_top); loaded.push(...(data.messages || [])); lastLoadedPage = page; if (loaded.length >= totalCount) break; } state.page = reset ? lastLoadedPage : state.page; state.total = totalCount; state.currentFolderFlaggedOnTop = flaggedOnTop; state.messages = reset ? loaded : [...state.messages, ...loaded]; $("#connection-label").textContent = "Connected"; renderMessages(); const desiredScroll = savedState?.scrollTop ?? previousScrollTop; if (Number.isFinite(desiredScroll) && desiredScroll > 0) { requestAnimationFrame(() => { list.scrollTop = Math.min(desiredScroll, Math.max(0, list.scrollHeight - list.clientHeight)); }); } saveMessageListState(); } catch (error) { $("#connection-label").textContent = error.network ? (navigator.onLine ? "Reconnecting" : "Offline") : "Connection error"; if (!state.messages.length) list.innerHTML = `
${escapeHtml(error.message)}
`; $("#message-count").textContent = state.messages.length ? `${state.total} messages · cached view` : "Unavailable"; } } async function quickUpdateMessageFlag(message, flag, add, successMessage) { const folder = message.folder || state.folder; try { await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) }); const flags = new Set(Array.isArray(message.flags) ? message.flags : String(message.flags || "").split(/[,\s]+/).filter(Boolean)); add ? flags.add(flag) : flags.delete(flag); message.flags = [...flags].join(" "); if (state.currentDetail?.uid === message.uid && state.currentDetail?.folder === folder) { const detailFlags = new Set(state.currentDetail.flags || []); add ? detailFlags.add(flag) : detailFlags.delete(flag); state.currentDetail.flags = [...detailFlags]; renderMessageDetail(state.currentDetail); } if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true); else renderMessages(); toast(successMessage, "success"); } catch (error) { toast(error.message, "error"); } } function renderMessages() { $("#folder-title").textContent = state.folder; $("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`; const list = $("#message-list"); const highlightTerms = activeSearchHighlightTerms(); list.classList.toggle("selection-mode", state.selectionMode); list.replaceChildren(); if (!state.messages.length) list.innerHTML = `
${state.searchContext ? "No matching messages." : "No messages in this folder."}
`; for (const message of state.messages) { const folder = message.folder || state.folder; const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || ""); const unread = !flags.includes("\\Seen"); const flagged = flags.includes("\\Flagged"); const checked = state.selectedMessages.has(messageKey(message)); const active = state.selected?.uid === message.uid && state.selected?.folder === folder; const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`); row.tabIndex = 0; row.setAttribute("role", "button"); row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`); const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.className = "message-select"; checkbox.checked = checked; checkbox.disabled = !state.selectionMode; checkbox.setAttribute("aria-label", "Select message"); checkbox.addEventListener("click", (event) => { event.stopPropagation(); }); checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked)); row.innerHTML = `
`; renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms); renderHighlightedText(row.querySelector(".message-subject"), message.subject || "(no subject)", highlightTerms); renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms); const icons = row.querySelector(".message-icons"); if (message.has_attachments) { const attachmentIcon = el("span", "message-static-icon attachment-indicator"); attachmentIcon.innerHTML = svgIcon("paperclip"); attachmentIcon.title = "Has attachments"; icons.append(attachmentIcon); } const quick = el("span", "message-quick-actions"); const readButton = el("button", "message-quick-action"); readButton.type = "button"; readButton.title = unread ? "Mark read" : "Mark unread"; readButton.setAttribute("aria-label", readButton.title); readButton.innerHTML = svgIcon("mail"); readButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Seen", unread, unread ? "Marked read" : "Marked unread"); }); const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`); flagButton.type = "button"; flagButton.title = flagged ? "Unflag" : "Flag"; flagButton.setAttribute("aria-label", flagButton.title); flagButton.innerHTML = svgIcon("star"); flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); }); quick.append(readButton, flagButton); icons.append(quick); if (Array.isArray(message.tags) && message.tags.length) { const wrap = el("span", "message-tags"); for (const tag of message.tags) { const chip = el("span", "message-tag", tag.name); chip.style.setProperty("--tag-color", tag.color || "#6b7280"); wrap.append(chip); } row.querySelector(".message-subject").after(wrap); } row.prepend(checkbox); row.draggable = true; row.addEventListener("dragstart", (event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-rustbox-message", JSON.stringify({ folder, uid: message.uid })); row.classList.add("dragging"); }); row.addEventListener("dragend", () => row.classList.remove("dragging")); row.addEventListener("click", (event) => { if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); openMessage(folder, message.uid); }); row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } }); row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); }); list.append(row); } $("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total); renderBulkToolbar(); } function navigateMessage(delta) { if (!state.selected) return; const index = state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder); if (index < 0) return; const next = state.messages[index + delta]; if (!next) return toast(delta > 0 ? "No next loaded message" : "No previous loaded message"); openMessage(next.folder || state.folder, next.uid); } function updateMessageNavigation() { const index = state.selected ? state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder) : -1; $("#reader-prev").disabled = index <= 0; $("#reader-next").disabled = index < 0 || index >= state.messages.length - 1; } async function senderIsSavedContact(email) { if (!email) return false; try { const data = await request(`/contacts?q=${encodeURIComponent(email)}&limit=10`, { method: "GET" }); const wanted = String(email).trim().toLowerCase(); return (data.contacts || []).some((contact) => { const addresses = [contact.email, ...(contact.emails || [])].filter(Boolean).map((value) => String(value).trim().toLowerCase()); return addresses.includes(wanted); }); } catch (_) { return false; } } async function openMessage(folder, uid) { clearTimeout(state.markReadTimer); state.markReadTimer = null; state.selected = { folder, uid }; renderMessages(); updateMessageNavigation(); $("#reader-empty").classList.add("hidden"); $("#reader-content").classList.remove("hidden"); $("#reader-subject").textContent = "Loading…"; $("#message-body").replaceChildren(); $("#reader").classList.add("mobile-open"); try { const detail = await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "GET" }); if (state.mailPrefs.remote_images === "contacts" && detail.from_address) { detail._senderInContacts = await senderIsSavedContact(detail.from_address); } if (detail.from_address) detail._senderTrusted = state.trustedSenders.has(String(detail.from_address).trim().toLowerCase()); state.currentDetail = detail; renderMessageDetail(detail); renderMoveOptions(); scheduleMarkRead(detail); } catch (error) { $("#reader-subject").textContent = "Unable to open message"; $("#message-body").innerHTML = `
${escapeHtml(error.message)}
`; } } function scheduleMarkRead(detail) { clearTimeout(state.markReadTimer); state.markReadTimer = null; if (!detail || (detail.flags || []).includes("\\Seen")) return; const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0)); const run = async () => { if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return; try { await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) }); detail.flags = [...new Set([...(detail.flags || []), "\\Seen"])]; const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder); if (item) { const flags = Array.isArray(item.flags) ? item.flags : String(item.flags || "").split(/[,\s]+/).filter(Boolean); item.flags = [...new Set([...flags, "\\Seen"])].join(" "); } renderMessages(); } catch (_) { } }; if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay); } function renderThread(detail) { const thread = detail.thread || []; const section = $("#thread-section"); const list = $("#thread-list"); list.replaceChildren(); const enabled = state.mailPrefs.thread_mode !== "flat"; section.classList.toggle("hidden", !enabled || thread.length <= 1); if (!enabled || thread.length <= 1) return; $("#thread-title").textContent = `Thread (${thread.length})`; list.classList.toggle("hidden", !state.threadExpanded); $("#thread-toggle").classList.toggle("expanded", state.threadExpanded); $("#thread-toggle").setAttribute("aria-expanded", String(state.threadExpanded)); for (const item of thread) { const button = el("button", `thread-item ${item.uid === detail.uid && item.folder === detail.folder ? "current" : ""}`); button.type = "button"; button.innerHTML = `${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}`; button.addEventListener("click", () => openMessage(item.folder, item.uid)); list.append(button); } } function addressLabel(entry) { if (!entry?.address) return ""; return entry.name ? `${entry.name} <${entry.address}>` : entry.address; } function renderReaderRecipients(detail) { const host = $("#reader-to"); host.replaceChildren(); const groups = [ ["to", detail.to_addresses || []], ["cc", detail.cc_addresses || []], ]; let added = 0; for (const [prefix, entries] of groups) { for (const entry of entries) { if (!entry?.address) continue; const row = el("div", "recipient-line"); row.append(el("span", "", `${prefix} ${addressLabel(entry)}`)); const add = el("button", "address-contact-button"); add.type = "button"; add.title = `Add ${entry.address} to contacts`; add.setAttribute("aria-label", add.title); add.innerHTML = svgIcon("user-round"); add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); }); row.append(add); host.append(row); added += 1; } } if (!added) host.append(el("div", "recipient-line", "to me")); } function setReaderAddressExpansion(expanded) { const host = $("#reader-to"); const meta = host?.closest(".message-meta"); if (!host || !meta) return; meta.classList.toggle("addresses-expanded", Boolean(expanded)); host.setAttribute("aria-expanded", String(Boolean(expanded))); host.title = expanded ? tr("collapse_addresses", "Collapse addresses") : tr("show_full_addresses", "Show full addresses"); } function toggleReaderAddressExpansion() { const host = $("#reader-to"); setReaderAddressExpansion(host?.getAttribute("aria-expanded") !== "true"); } function renderMessageDetail(detail) { $("#reader-subject").textContent = detail.subject || "(no subject)"; $("#reader-from").textContent = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address; $("#reader-from").title = detail.from_address ? "Sender actions" : ""; $("#reader-from").tabIndex = detail.from_address ? 0 : -1; if (detail.from_address) $("#reader-from").setAttribute("role", "button"); else $("#reader-from").removeAttribute("role"); setReaderAddressExpansion(false); renderReaderRecipients(detail); $("#reader-add-sender-contact").classList.toggle("hidden", !detail.from_address); $("#reader-date").textContent = formatDate(detail.date, true); $("#reader-avatar").textContent = initial(detail.from_name || detail.from_address); $("#flag-btn").classList.toggle("active", (detail.flags || []).includes("\\Flagged")); const spamButton = $("#spam-btn"); const inJunk = isJunkFolder(detail.folder); spamButton.classList.toggle("active", inJunk); spamButton.title = inJunk ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"); spamButton.setAttribute("aria-label", spamButton.title); renderThread(detail); renderPgpReaderState(detail); const attachments = $("#attachment-list"); attachments.replaceChildren(); attachments.classList.toggle("hidden", !(detail.attachments || []).length); for (const attachment of detail.attachments || []) { const item = el("div", "attachment-item"); const button = el("button", "attachment-button"); button.type = "button"; button.innerHTML = `${svgIcon("paperclip")}${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB`; button.addEventListener("click", () => attachmentCanPreview(attachment) ? openAttachment(detail.folder, detail.uid, attachment) : downloadAttachment(detail.folder, detail.uid, attachment)); button.addEventListener("contextmenu", (event) => { event.preventDefault(); const filename = String(attachment.filename || "").toLowerCase(); const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase(); const actions = [ { label: "Open", action: () => openAttachment(detail.folder, detail.uid, attachment) }, { label: "Download", action: () => downloadAttachment(detail.folder, detail.uid, attachment) }, ]; if (contentType.includes("text/calendar") || filename.endsWith(".ics")) actions.push("separator", { label: "Add to calendar", action: () => importCalendarAttachment(detail.folder, detail.uid, attachment) }); if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) actions.push("separator", { label: "Import contact", action: () => importVcardAttachment(detail.folder, detail.uid, attachment) }); if ((detail.attachments || []).length > 1) actions.push("separator", { label: "Download all as ZIP", action: () => downloadAllAttachmentsZip(detail) }); showContextMenu(event.clientX, event.clientY, actions); }); item.append(button); const filename = String(attachment.filename || "").toLowerCase(); const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase(); if (contentType.includes("text/calendar") || filename.endsWith(".ics")) { const add = el("button", "icon-button small"); add.innerHTML = svgIcon("calendar-days"); add.type = "button"; add.title = "Add to calendar"; add.setAttribute("aria-label", "Add calendar attachment"); add.addEventListener("click", () => importCalendarAttachment(detail.folder, detail.uid, attachment)); item.append(add); } if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) { const add = el("button", "icon-button small"); add.innerHTML = svgIcon("user-round"); add.type = "button"; add.title = "Import contact"; add.setAttribute("aria-label", "Import vCard attachment"); add.addEventListener("click", () => importVcardAttachment(detail.folder, detail.uid, attachment)); item.append(add); } attachments.append(item); } if ((detail.attachments || []).length > 1) { const all = el("button", "attachment-download-all"); all.type = "button"; all.innerHTML = `${svgIcon("package")}Download all as ZIP`; all.addEventListener("click", () => downloadAllAttachmentsZip(detail)); attachments.prepend(all); } renderMessageBody(detail); } function pgpMessageKind(detail) { const headers = String(detail?.raw_headers || "").toLowerCase(); const text = String(detail?.text || "").toLowerCase(); const encrypted = (headers.includes("multipart/encrypted") && headers.includes("application/pgp-encrypted")) || text.includes("-----begin pgp message-----"); const signed = (headers.includes("multipart/signed") && headers.includes("application/pgp-signature")) || text.includes("-----begin pgp signed message-----"); return { pgp: encrypted || signed, encrypted, signed }; } function renderPgpReaderState(detail) { const bar = $("#pgp-reader-bar"); const status = $("#pgp-reader-status"); const title = $("#pgp-reader-title"); const find = $("#pgp-reader-discover"); const kind = pgpMessageKind(detail); const configured = state.pgpKeys.length > 0; bar.classList.toggle("hidden", !kind.pgp || !configured); find.classList.add("hidden"); if (!kind.pgp || !configured) return; const processed = detail?._pgpProcessed; if (!processed) { title.textContent = kind.encrypted ? "OpenPGP encrypted message" : "OpenPGP signed message"; status.textContent = kind.encrypted ? "Decrypt to read and verify the protected MIME payload." : "Signature has not been verified yet."; $("#pgp-reader-process").textContent = kind.encrypted ? "Decrypt / verify" : "Verify signature"; return; } const signer = processed.signerFingerprint ? ` · ${pgpFingerprintLabel(processed.signerFingerprint)}` : ""; const signerIdentity = processed.signerEmail ? ` · ${processed.signerEmail}` : ""; const trust = processed.signerTrust ? ` · trust: ${processed.signerTrust}` : ""; if (processed.encrypted && processed.verified) { title.textContent = "OpenPGP decrypted + verified"; status.textContent = `Encrypted content was decrypted and its signature is valid${signer}${signerIdentity}${trust}`; } else if (processed.encrypted) { title.textContent = "OpenPGP decrypted"; status.textContent = processed.signed ? `Content decrypted; embedded signature could not be verified${signer}${signerIdentity}${trust}` : "Content decrypted successfully"; } else if (processed.verified) { title.textContent = "OpenPGP signature verified"; status.textContent = `Valid signature${signer}${signerIdentity}${trust}`; } else { title.textContent = "OpenPGP signature not verified"; status.textContent = "No matching public key verified this signature."; if (detail?.from_address && (state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled)) find.classList.remove("hidden"); } $("#pgp-reader-process").textContent = "Reprocess"; } async function processCurrentPgpMessage() { const detail = state.currentDetail; if (!detail) return; const kind = pgpMessageKind(detail); let passphrase = ""; if (kind.encrypted) { const value = await appPrompt("Decrypt OpenPGP message", { message: "Enter the private-key passphrase if this key is protected. It is used only for this request and is not stored.", type: "password", confirmLabel: "Decrypt" }); if (value === null) return; passphrase = value; } const button = $("#pgp-reader-process"); setBusy(button, true); try { const result = await request(`/pgp/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/process`, { method: "POST", body: JSON.stringify({ passphrase }) }); detail._pgpProcessed = result; renderPgpReaderState(detail); if (result.text != null || result.html != null) renderMessageBody({ ...detail, text: result.text, html: result.html }); renderPgpProcessedAttachments(result.attachments || []); if (result.verified && result.signerTrust === "untrusted") toast("OpenPGP signature is cryptographically valid, but this key is marked untrusted", "warning"); else if (result.verified) toast("OpenPGP signature verified", "success"); else if (result.decrypted) toast("OpenPGP message decrypted", "success"); else if (result.signed) toast("OpenPGP signature could not be verified", "warning"); } catch (error) { toast(error.message, "error"); } finally { setBusy(button, false); } } function renderPgpProcessedAttachments(items) { if (!items.length) return; const list = $("#attachment-list"); list.replaceChildren(); list.classList.remove("hidden"); for (const attachment of items) { const item = el("div", "attachment-item"); const button = el("button", "attachment-button"); button.type = "button"; button.innerHTML = `${svgIcon("paperclip")}${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB`; button.addEventListener("click", () => { try { const binary = atob(attachment.dataBase64 || ""); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); const blob = new Blob([bytes], { type: attachment.contentType || "application/octet-stream" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = attachment.filename || "attachment"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 30000); } catch (_) { toast("Unable to decode decrypted attachment", "error"); } }); item.append(button); list.append(item); } } async function discoverCurrentPgpSigner() { const email = state.currentDetail?.from_address; if (!email) return; const key = await discoverPgpEmail(email, false); if (key) await processCurrentPgpMessage(); } function renderMessageBody(detail, forceRemote = false) { const body = $("#message-body"); const remoteBar = $("#remote-image-bar"); body.replaceChildren(); remoteBar.classList.add("hidden"); const preferredBody = state.mailPrefs.preferred_body || "auto"; if (preferredBody === "text" || !detail?.html) { body.append(el("pre", "", cleanPlainMessageText(detail?.text || "(empty message)"))); return; } const hasRemote = /(?:src|background)\s*=\s*["']https?:\/\//i.test(detail.html) || /url\(\s*["']?https?:\/\//i.test(detail.html); const policy = state.mailPrefs.remote_images || "ask"; const allowRemote = forceRemote || policy === "always" || (policy !== "never" && detail?._senderTrusted === true) || (policy === "contacts" && detail?._senderInContacts === true); if (hasRemote && !allowRemote && (policy === "ask" || policy === "contacts")) remoteBar.classList.remove("hidden"); const iframe = document.createElement("iframe"); iframe.setAttribute("sandbox", ""); iframe.setAttribute("referrerpolicy", "no-referrer"); const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:"; const csp = ``; const quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}"; const safeHtml = sanitizeMessageHtml(detail.html); iframe.srcdoc = `${csp}${safeHtml}`; body.append(iframe); } async function downloadAttachment(folder, uid, attachment) { try { const headers = new Headers(); if (state.accountId) headers.set("X-Active-Account", state.accountId); const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers }); if (!response.ok) throw new Error(`Download failed (${response.status})`); const blob = await response.blob(); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = attachment.filename || "attachment"; link.click(); setTimeout(() => URL.revokeObjectURL(url), 30000); toast(`Downloading ${attachment.filename || "attachment"}`, "info"); } catch (error) { toast(error.message, "error"); } } async function fetchAttachmentBlob(folder, uid, attachment) { const headers = new Headers(); if (state.accountId) headers.set("X-Active-Account", state.accountId); const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers }); if (!response.ok) throw new Error(`Attachment request failed (${response.status})`); return response.blob(); } function attachmentMime(attachment, blob = null) { return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase(); } function attachmentCanPreview(attachment) { const mime = attachmentMime(attachment); const filename = String(attachment?.filename || "").toLowerCase(); return mime.startsWith("image/") || mime === "application/pdf" || mime.startsWith("text/") || mime.startsWith("video/") || /\.(pdf|png|jpe?g|gif|webp|txt|log|md|csv|html?|xhtml|mp4|webm|ogv|mov)$/i.test(filename); } async function openAttachment(folder, uid, attachment) { try { const blob = await fetchAttachmentBlob(folder, uid, attachment); const mime = attachmentMime(attachment, blob); const filename = String(attachment?.filename || "").toLowerCase(); const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename); const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(filename); const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(filename); const safe = attachmentCanPreview({ ...attachment, content_type: mime }); if (!safe) return toast("Preview is not available for this attachment type", "warning"); if (blob.size > 30 * 1024 * 1024) return toast("Attachment is too large for preview", "warning"); const dialog = el("dialog", "dialog attachment-preview-dialog"); const card = el("div", "dialog-card attachment-preview-card"); const header = el("header", "dialog-header"); header.append(el("strong", "", attachment.filename || "Attachment preview")); const close = el("button", "icon-button"); close.type = "button"; close.setAttribute("aria-label", "Close preview"); close.innerHTML = svgIcon("x"); header.append(close); const body = el("div", "attachment-preview-body"); let objectUrl = null; if (isImage) { objectUrl = URL.createObjectURL(blob); const image = document.createElement("img"); image.src = objectUrl; image.alt = attachment.filename || "Attachment image"; body.append(image); } else if (mime === "application/pdf" || filename.endsWith(".pdf")) { objectUrl = URL.createObjectURL(blob); const frame = document.createElement("iframe"); frame.src = objectUrl; frame.title = attachment.filename || "PDF preview"; frame.referrerPolicy = "no-referrer"; body.append(frame); } else if (isVideo) { objectUrl = URL.createObjectURL(blob); const video = document.createElement("video"); video.src = objectUrl; video.controls = true; video.playsInline = true; video.preload = "metadata"; body.append(video); } else if (isHtml) { if (blob.size > 5 * 1024 * 1024) return toast("HTML attachment is too large for inline preview", "warning"); const frame = document.createElement("iframe"); frame.setAttribute("sandbox", ""); frame.referrerPolicy = "no-referrer"; const safeHtml = sanitizeAttachmentHtml(await blob.text()); const csp = ``; frame.srcdoc = `${csp}${safeHtml}`; body.append(frame); } else { if (blob.size > 5 * 1024 * 1024) return toast("Text attachment is too large for inline preview", "warning"); const pre = document.createElement("pre"); pre.textContent = await blob.text(); body.append(pre); } const footer = el("footer", "dialog-actions"); const download = el("button", "button quiet", "Download"); download.type = "button"; download.addEventListener("click", () => downloadAttachment(folder, uid, attachment)); const done = el("button", "button primary", "Close"); done.type = "button"; const dismiss = () => { if (dialog.open) dialog.close(); }; close.addEventListener("click", dismiss); done.addEventListener("click", dismiss); footer.append(download, done); card.append(header, body, footer); dialog.append(card); document.body.append(dialog); dialog.addEventListener("close", () => { if (objectUrl) URL.revokeObjectURL(objectUrl); dialog.remove(); }, { once: true }); dialog.showModal(); } catch (error) { toast(error.message, "error"); } } function safeZipFilename(value, index) { const cleaned = String(value || `attachment-${index + 1}`) .replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_") .replace(/^\.+$/, "attachment") .slice(0, 180); return cleaned || `attachment-${index + 1}`; } let crcTable = null; function crc32(bytes) { if (!crcTable) { crcTable = new Uint32Array(256); for (let n = 0; n < 256; n += 1) { let c = n; for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1); crcTable[n] = c >>> 0; } } let crc = 0xffffffff; for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8); return (crc ^ 0xffffffff) >>> 0; } function zipDosDateTime(date = new Date()) { const year = Math.max(1980, date.getFullYear()); const dosTime = ((date.getHours() & 31) << 11) | ((date.getMinutes() & 63) << 5) | ((Math.floor(date.getSeconds() / 2)) & 31); const dosDate = (((year - 1980) & 127) << 9) | (((date.getMonth() + 1) & 15) << 5) | (date.getDate() & 31); return { dosTime, dosDate }; } function zipRecord(size, fill) { const bytes = new Uint8Array(size); const view = new DataView(bytes.buffer); fill(view); return bytes; } function concatBytes(parts) { const total = parts.reduce((sum, part) => sum + part.byteLength, 0); const out = new Uint8Array(total); let offset = 0; for (const part of parts) { out.set(part, offset); offset += part.byteLength; } return out; } async function downloadAllAttachmentsZip(detail = state.currentDetail) { const attachments = detail?.attachments || []; if (!attachments.length) return toast("This message has no attachments", "warning"); if (attachments.length > 100) return toast("Too many attachments for a browser ZIP", "warning"); try { toast(`Preparing ZIP with ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}…`, "info"); const encoder = new TextEncoder(); const localParts = []; const centralParts = []; let localOffset = 0; let totalPayload = 0; for (let index = 0; index < attachments.length; index += 1) { const attachment = attachments[index]; const blob = await fetchAttachmentBlob(detail.folder, detail.uid, attachment); totalPayload += blob.size; if (totalPayload > 120 * 1024 * 1024) throw new Error("Attachments exceed the 120 MB browser ZIP limit"); const data = new Uint8Array(await blob.arrayBuffer()); const name = encoder.encode(safeZipFilename(attachment.filename, index)); const checksum = crc32(data); const { dosTime, dosDate } = zipDosDateTime(); const local = zipRecord(30, (view) => { view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); view.setUint16(6, 0x0800, true); view.setUint16(8, 0, true); view.setUint16(10, dosTime, true); view.setUint16(12, dosDate, true); view.setUint32(14, checksum, true); view.setUint32(18, data.length, true); view.setUint32(22, data.length, true); view.setUint16(26, name.length, true); view.setUint16(28, 0, true); }); const central = zipRecord(46, (view) => { view.setUint32(0, 0x02014b50, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); view.setUint16(8, 0x0800, true); view.setUint16(10, 0, true); view.setUint16(12, dosTime, true); view.setUint16(14, dosDate, true); view.setUint32(16, checksum, true); view.setUint32(20, data.length, true); view.setUint32(24, data.length, true); view.setUint16(28, name.length, true); view.setUint16(30, 0, true); view.setUint16(32, 0, true); view.setUint16(34, 0, true); view.setUint16(36, 0, true); view.setUint32(38, 0, true); view.setUint32(42, localOffset, true); }); localParts.push(local, name, data); centralParts.push(central, name); localOffset += local.byteLength + name.byteLength + data.byteLength; } const centralDirectory = concatBytes(centralParts); const eocd = zipRecord(22, (view) => { view.setUint32(0, 0x06054b50, true); view.setUint16(4, 0, true); view.setUint16(6, 0, true); view.setUint16(8, attachments.length, true); view.setUint16(10, attachments.length, true); view.setUint32(12, centralDirectory.byteLength, true); view.setUint32(16, localOffset, true); view.setUint16(20, 0, true); }); const zip = new Blob([...localParts, centralDirectory, eocd], { type: "application/zip" }); const url = URL.createObjectURL(zip); const link = document.createElement("a"); const stem = String(detail.subject || "attachments").replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_").slice(0, 80) || "attachments"; link.href = url; link.download = `${stem}-attachments.zip`; document.body.append(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(url), 30000); toast(`Downloaded ${attachments.length} attachment${attachments.length === 1 ? "" : "s"} as ZIP`, "success"); } catch (error) { toast(error.message, "error"); } } async function importCalendarAttachment(folder, uid, attachment) { try { const blob = await fetchAttachmentBlob(folder, uid, attachment); if (blob.size > 10 * 1024 * 1024) throw new Error("ICS attachment is too large"); const text = await blob.text(); const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" }); if (state.accountId) headers.set("X-Active-Account", state.accountId); const response = await fetch(apiPath("/calendar/events/import-ics"), { method: "POST", credentials: "include", headers, body: text }); const data = await response.json().catch(() => ({})); if (!response.ok) throw new Error(data?.error?.message || `Calendar import failed (${response.status})`); toast(`Calendar: ${data.created || 0} event(s) imported`); } catch (error) { toast(error.message, "error"); } } async function importVcardAttachment(folder, uid, attachment) { try { const blob = await fetchAttachmentBlob(folder, uid, attachment); if (blob.size > 10 * 1024 * 1024) throw new Error("vCard attachment is too large"); const form = new FormData(); form.append("file", blob, attachment.filename || "contact.vcf"); const result = await request("/contacts/import", { method: "POST", body: form }); toast(`Contacts: ${result.created || 0} created, ${result.updated || 0} updated`); } catch (error) { toast(error.message, "error"); } } function closeReader() { clearTimeout(state.markReadTimer); state.markReadTimer = null; $("#reader").classList.remove("mobile-open"); $("#reader-empty").classList.remove("hidden"); $("#reader-content").classList.add("hidden"); }