/* 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");
}