735 lines
39 KiB
JavaScript
735 lines
39 KiB
JavaScript
/* 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 = `<div class="empty-list">Loading messages…</div>`;
|
|
}
|
|
$("#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 = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
|
$("#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 = `<div class="empty-list">${state.searchContext ? "No matching messages." : "No messages in this folder."}</div>`;
|
|
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 = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
|
|
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 = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
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 = `<span>${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}</span><time>${escapeHtml(formatDate(item.date))}</time>`;
|
|
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")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
|
|
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")}<span>Download all as ZIP</span>`;
|
|
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")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
|
|
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 = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
|
|
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 = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}</style></head><body>${safeHtml}</body></html>`;
|
|
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 = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
|
|
frame.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:20px;color:#202622;background:#fff;font:14px/1.55 system-ui,sans-serif}img{max-width:100%;height:auto}pre{white-space:pre-wrap}</style></head><body>${safeHtml}</body></html>`;
|
|
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");
|
|
}
|
|
|