function parseRecipients(raw) { return raw.split(",").map((value) => value.trim()).filter(Boolean); } function recipientToken(input) { const value = input.value || ""; const comma = value.lastIndexOf(","); return { prefix: comma >= 0 ? value.slice(0, comma + 1) : "", query: value.slice(comma + 1).trim() }; } function bindRecipientAutocomplete(selector) { const input = $(selector); if (!input) return; const host = input.closest("label") || input.parentElement; host.classList.add("recipient-field"); const menu = el("div", "recipient-suggestions hidden"); menu.setAttribute("role", "listbox"); host.append(menu); let timer = null; let activeIndex = -1; let suggestions = []; const hide = () => { menu.classList.add("hidden"); menu.replaceChildren(); activeIndex = -1; suggestions = []; }; const select = (item) => { const { prefix } = recipientToken(input); const formatted = item.name?.trim() ? `${item.name.trim()} <${item.email}>` : item.email; input.value = `${prefix}${prefix ? " " : ""}${formatted}, `; hide(); input.focus(); scheduleDraftSave(); input.dispatchEvent(new Event("input", { bubbles: true })); }; const render = () => { menu.replaceChildren(); if (!suggestions.length) { hide(); return; } suggestions.forEach((item, index) => { const button = el("button", `recipient-suggestion ${index === activeIndex ? "active" : ""}`); button.type = "button"; button.setAttribute("role", "option"); button.setAttribute("aria-selected", String(index === activeIndex)); const primary = el("strong", "", item.name?.trim() || item.email); const secondary = el("span", "", item.name?.trim() ? item.email : (item.source || "")); button.append(primary, secondary); button.addEventListener("mousedown", (event) => event.preventDefault()); button.addEventListener("click", () => select(item)); menu.append(button); }); menu.classList.remove("hidden"); }; const load = async () => { const { query } = recipientToken(input); if (query.length < 2 || query.includes(">")) { hide(); return; } try { const data = await request(`/contacts/autocomplete?q=${encodeURIComponent(query)}&limit=8`, { method: "GET" }); suggestions = data.suggestions || []; activeIndex = -1; render(); } catch (_) { hide(); } }; input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 140); }); input.addEventListener("blur", () => setTimeout(hide, 120)); input.addEventListener("keydown", (event) => { if (menu.classList.contains("hidden") || !suggestions.length) return; if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = (activeIndex + 1) % suggestions.length; render(); } else if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length; render(); } else if (event.key === "Enter" && activeIndex >= 0) { event.preventDefault(); select(suggestions[activeIndex]); } else if (event.key === "Escape") { event.preventDefault(); hide(); } }); } function quoteTextAsHtml(text) { return escapeHtml(cleanPlainMessageText(text || "")).replaceAll("\n", "
"); } function ownAddressSet() { return new Set(state.accounts.map((account) => String(account.email || "").trim().toLowerCase()).filter(Boolean)); } function ensureDraftId() { if (!state.composeDraftId) state.composeDraftId = crypto.randomUUID ? crypto.randomUUID() : `draft-${Date.now()}-${Math.random().toString(16).slice(2)}`; return state.composeDraftId; } const composeSizeEncoder = new TextEncoder(); function utf8Bytes(value) { return composeSizeEncoder.encode(String(value || "")).length; } function estimatedMimeAttachmentBytes(rawBytes) { const raw = Math.max(0, Number(rawBytes) || 0); const base64 = 4 * Math.ceil(raw / 3); const wrappedLines = Math.ceil(base64 / 76); return base64 + (wrappedLines * 2) + 512; } function estimateComposeMessageBytes(extraFiles = []) { const editor = $("#compose-editor"); let bytes = 4096; for (const selector of ["#compose-to", "#compose-cc", "#compose-bcc", "#compose-subject"]) bytes += utf8Bytes($(selector)?.value); if (editor) { const plainBytes = utf8Bytes(editor.innerText); bytes += plainBytes; const mode = $("#compose-mode")?.value || "html"; if (mode === "html") bytes += utf8Bytes(editor.innerHTML); else if (mode === "markdown") bytes += Math.ceil(plainBytes * 1.25); } for (const attachment of state.composeAttachments) bytes += estimatedMimeAttachmentBytes(attachment.size); for (const file of extraFiles || []) bytes += estimatedMimeAttachmentBytes(file.size); const pgpMode = $("#compose-pgp-mode")?.value || "none"; if (pgpMode === "sign") bytes += 16 * 1024; if (pgpMode === "encrypt" || pgpMode === "sign-encrypt") bytes = Math.ceil(bytes * 1.40) + (16 * 1024); return bytes; } function formatComposeBytes(bytes) { const mib = Math.max(0, Number(bytes) || 0) / 1024 / 1024; if (mib >= 10) return `${mib.toFixed(1)} MiB`; if (mib >= 1) return `${mib.toFixed(2)} MiB`; return `${Math.max(1, Math.ceil((Number(bytes) || 0) / 1024))} KiB`; } function updateComposeMessageSize(extraFiles = []) { const output = $("#compose-message-size"); if (!output) return 0; const maxBytes = Math.max(1, Number(root.dataset.maxMessageBytes || 50 * 1024 * 1024)); const maxMiB = Math.max(1, Number(root.dataset.maxMessageMb || Math.round(maxBytes / 1024 / 1024))); const estimated = estimateComposeMessageBytes(extraFiles); const ratio = estimated / maxBytes; output.textContent = `~${formatComposeBytes(estimated)} / ${maxMiB} MiB`; output.classList.toggle("warning", ratio >= 0.85 && ratio <= 1); output.classList.toggle("over-limit", ratio > 1); output.title = trText("Estimated size. The exact RFC822/MIME size is checked by the server before SMTP delivery."); return estimated; } function renderComposeAttachments() { const box = $("#compose-attachments"); box.replaceChildren(); const visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id))); box.classList.toggle("hidden", !visibleAttachments.length); for (const attachment of visibleAttachments) { const item = el("div", "compose-attachment"); item.innerHTML = `${svgIcon("paperclip")}${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB`; const remove = el("button", ""); remove.innerHTML = svgIcon("x"); remove.type = "button"; remove.setAttribute("aria-label", `Remove ${attachment.filename}`); remove.addEventListener("click", () => deleteComposeAttachment(attachment.id)); item.append(remove); box.append(item); } if ($("#compose-pgp-mode")) updateComposePgpUi(); updateComposeMessageSize(); } function composeRecoveryKey() { return `rustbox.composeRecovery.${state.accountId || "default"}`; } function draftHasContent() { return Boolean( $("#compose-to")?.value.trim() || $("#compose-cc")?.value.trim() || $("#compose-bcc")?.value.trim() || $("#compose-subject")?.value.trim() || $("#compose-editor")?.innerText.trim() || state.composeAttachments.length ); } function composeAttachmentContentUrl(draftId, attachmentId) { const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : ""; return `${apiPath(`/drafts/${encodeURIComponent(draftId)}/attachments/${encodeURIComponent(attachmentId)}/content`)}${query}`; } function markdownInlineToSafeHtml(value) { const source = String(value ?? ""); const code = []; let html = escapeHtml(source).replace(/`([^`\n]+)`/g, (_, body) => { const token = `@@RUSTBOX_CODE_${code.length}@@`; code.push(`${body}`); return token; }); html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, rawUrl) => { let url = rawUrl.replaceAll("&", "&"); try { const parsed = new URL(url, window.location.origin); if (!["http:", "https:", "mailto:"].includes(parsed.protocol)) return label; url = parsed.protocol === "mailto:" ? url : parsed.href; } catch (_) { return label; } return `${label}`; }); html = html .replace(/\*\*([^*\n]+)\*\*/g, "$1") .replace(/__([^_\n]+)__/g, "$1") .replace(/~~([^~\n]+)~~/g, "$1") .replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1$2") .replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, "$1$2"); code.forEach((snippet, index) => { html = html.replace(`@@RUSTBOX_CODE_${index}@@`, snippet); }); return html; } function markdownToSafeHtml(markdown) { const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n"); const out = []; let paragraph = []; let listType = null; let inCode = false; let codeLines = []; const flushParagraph = () => { if (!paragraph.length) return; out.push(`

${paragraph.map(markdownInlineToSafeHtml).join("
")}

`); paragraph = []; }; const closeList = () => { if (!listType) return; out.push(``); listType = null; }; for (const line of lines) { if (/^\s*```/.test(line)) { flushParagraph(); closeList(); if (inCode) { out.push(`
${escapeHtml(codeLines.join("\n"))}
`); codeLines = []; inCode = false; } else { inCode = true; } continue; } if (inCode) { codeLines.push(line); continue; } if (!line.trim()) { flushParagraph(); closeList(); continue; } const heading = line.match(/^(#{1,6})\s+(.+)$/); if (heading) { flushParagraph(); closeList(); const level = heading[1].length; out.push(`${markdownInlineToSafeHtml(heading[2])}`); continue; } if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) { flushParagraph(); closeList(); out.push("
"); continue; } const quote = line.match(/^>\s?(.*)$/); if (quote) { flushParagraph(); closeList(); out.push(`
${markdownInlineToSafeHtml(quote[1])}
`); continue; } const unordered = line.match(/^\s*[-+*]\s+(.+)$/); const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/); if (unordered || ordered) { flushParagraph(); const wanted = unordered ? "ul" : "ol"; if (listType !== wanted) { closeList(); listType = wanted; out.push(`<${wanted}>`); } out.push(`
  • ${markdownInlineToSafeHtml((unordered || ordered)[1])}
  • `); continue; } paragraph.push(line); } if (inCode) out.push(`
    ${escapeHtml(codeLines.join("\n"))}
    `); flushParagraph(); closeList(); return out.join("\n"); } function composeFontCss(value = state.prefs.composeFontFamily) { return ({ system: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', humanist: '"Trebuchet MS","Segoe UI",sans-serif', serif: 'Georgia,"Times New Roman",serif', mono: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', })[value] || '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'; } function wrapComposeHtmlFont(html) { if (!html) return null; const family = composeFontCss().replace(/&/g, "&").replace(/"/g, """); return `
    ${html}
    `; } function serializeComposeHtml(editor = $("#compose-editor")) { if (!editor) return null; const mode = $("#compose-mode").value; if (mode === "markdown") { const markdown = editor.innerText.trim(); return markdown ? wrapComposeHtmlFont(markdownToSafeHtml(markdown)) : null; } if (mode !== "html") return null; const clone = editor.cloneNode(true); clone.querySelectorAll(".rustbox-compose-font").forEach((wrapper) => wrapper.replaceWith(...wrapper.childNodes)); clone.querySelectorAll("img[data-rustbox-cid]").forEach((image) => { const cid = image.dataset.rustboxCid || ""; if (cid) image.setAttribute("src", `cid:${cid}`); image.removeAttribute("data-rustbox-cid"); image.removeAttribute("data-rustbox-align"); image.classList.remove("compose-inline-image-selected"); if (!image.className) image.removeAttribute("class"); }); const html = clone.innerHTML.trim(); return html ? wrapComposeHtmlFont(html) : null; } function hydrateComposeHtml(html, attachments = state.composeAttachments, draftId = state.composeDraftId) { const host = document.createElement("div"); host.innerHTML = sanitizeMessageHtml(html || ""); if (host.children.length === 1 && host.firstElementChild?.classList.contains("rustbox-compose-font")) { host.innerHTML = host.firstElementChild.innerHTML; } if (!draftId) return host.innerHTML; const ids = new Set((attachments || []).map((attachment) => String(attachment.id || "")).filter(Boolean)); host.querySelectorAll("img[src]").forEach((image) => { const src = image.getAttribute("src") || ""; if (!src.toLowerCase().startsWith("cid:")) return; const cid = src.slice(4).replace(/^<|>$/g, ""); if (!ids.has(cid)) return; state.inlineAttachmentIds.add(cid); image.dataset.rustboxCid = cid; image.setAttribute("src", composeAttachmentContentUrl(draftId, cid)); }); return host.innerHTML; } function composeDraftPayload() { const editor = $("#compose-editor"); return { id: ensureDraftId(), to: $("#compose-to").value, cc: $("#compose-cc").value, bcc: $("#compose-bcc").value, subject: $("#compose-subject").value, text_body: editor.innerText, html_body: serializeComposeHtml(editor), in_reply_to: state.composeReplyMeta?.inReplyTo || null, references: state.composeReplyMeta?.references || null, request_mdn: Boolean($("#compose-mdn")?.checked), priority: $("#compose-priority")?.value || "normal", compose_format: $("#compose-mode")?.value || "html", pgp_mode: $("#compose-pgp-mode")?.value || "none", pgp_inline: Boolean($("#compose-pgp-inline")?.checked), pgp_attach_public_key: Boolean($("#compose-pgp-attach-key")?.checked), pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys")?.checked), }; } function readLocalDraftSnapshots() { try { const raw = localStorage.getItem(composeRecoveryKey()); if (!raw) return []; const parsed = JSON.parse(raw); const drafts = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.drafts) ? parsed.drafts : parsed?.id ? [parsed] : []; return drafts.filter((draft) => draft && draft.id).sort((a, b) => String(b.saved_at || "").localeCompare(String(a.saved_at || ""))); } catch (_) { return []; } } function writeLocalDraftSnapshots(drafts) { const kept = drafts.filter((draft) => draft?.id).slice(0, 10); if (!kept.length) localStorage.removeItem(composeRecoveryKey()); else localStorage.setItem(composeRecoveryKey(), JSON.stringify({ drafts: kept })); } function storeLocalDraftSnapshot() { if (!draftHasContent()) return; try { const payload = composeDraftPayload(); const snapshot = { ...payload, compose_format: $("#compose-mode").value, attachments: state.composeAttachments, saved_at: new Date().toISOString(), }; const drafts = readLocalDraftSnapshots().filter((draft) => draft.id !== payload.id); drafts.unshift(snapshot); writeLocalDraftSnapshots(drafts); } catch (_) { } } function clearLocalDraftSnapshot(draftId = state.composeDraftId) { if (!draftId) return; writeLocalDraftSnapshots(readLocalDraftSnapshots().filter((draft) => draft.id !== draftId)); } async function saveCurrentDraft(silent = false, force = false, syncImap = false) { if (!force && !$("#compose-dialog").open) return false; if (!force && !state.composeDirty) return true; if (!draftHasContent()) { state.composeDirty = false; clearLocalDraftSnapshot(); return true; } const payload = composeDraftPayload(); payload.sync_imap = Boolean(syncImap); try { await request("/drafts", { method: "POST", body: JSON.stringify(payload) }); state.composeDirty = false; if (syncImap) state.composeNeedsRemoteSync = false; clearLocalDraftSnapshot(); $("#compose-status").textContent = `${trText("Draft saved")} ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`; if (!silent) toast("Draft saved", "success"); return true; } catch (error) { storeLocalDraftSnapshot(); if (!silent) toast(error.message, "error"); return false; } } function scheduleDraftSave() { state.composeDirty = true; state.composeNeedsRemoteSync = true; storeLocalDraftSnapshot(); clearTimeout(state.draftSaveTimer); state.draftSaveTimer = setTimeout(() => saveCurrentDraft(true), Math.max(1000, Number(state.prefs.composeAutosaveMs || 3000))); } function setComposeUploadProgress(visible, percent = 0, label = trText("Uploading…")) { const box = $("#compose-upload-progress"); const bar = $("#compose-upload-bar"); const text = $("#compose-upload-label"); if (!box || !bar || !text) return; const value = Math.max(0, Math.min(100, Number(percent) || 0)); box.classList.toggle("hidden", !visible); bar.style.width = `${value}%`; text.textContent = label; } async function uploadComposeFiles(files, options = {}) { const incoming = [...(files || [])]; if (!incoming.length) return []; if (state.composeUploading) return []; const existing = new Set(state.composeAttachments.map((attachment) => `${String(attachment.filename || "")}\u0000${Number(attachment.size || 0)}`)); const selected = new Set(); const list = incoming.filter((file) => { const key = `${String(file.name || "")}\u0000${Number(file.size || 0)}`; if (existing.has(key) || selected.has(key)) return false; selected.add(key); return true; }); if (!list.length) return []; const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024)); const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024))); const oversized = list.find((file) => Number(file.size || 0) > maxBytes); if (oversized) { setComposeUploadProgress(true, 0, `${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`); setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4200); return []; } const maxMessageBytes = Math.max(1, Number(root.dataset.maxMessageBytes || 50 * 1024 * 1024)); const maxMessageMiB = Math.max(1, Number(root.dataset.maxMessageMb || Math.round(maxMessageBytes / 1024 / 1024))); const estimatedAfterUpload = updateComposeMessageSize(list); if (estimatedAfterUpload > maxMessageBytes) { setComposeUploadProgress(true, 0, `${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB)`); } state.composeUploading = true; const controller = new AbortController(); state.composeUploadController = controller; const uploadLabel = list.length === 1 ? trText("Uploading attachment…") : trText("Uploading files…"); setComposeUploadProgress(true, 0, uploadLabel); await saveCurrentDraft(true, true); const id = ensureDraftId(); const added = []; try { for (let index = 0; index < list.length; index += 1) { const file = list[index]; const form = new FormData(); form.append("file", file, file.name); const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => { const overall = Math.round(((index + (percent / 100)) / list.length) * 100); setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`); }, controller.signal); const uploaded = data.attachments || []; added.push(...uploaded); state.composeAttachments.push(...uploaded); renderComposeAttachments(); state.composeDirty = true; state.composeNeedsRemoteSync = true; storeLocalDraftSnapshot(); } setComposeUploadProgress(true, 100, trText("Upload complete")); setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450); return added; } catch (error) { if (error?.name === "AbortError") setComposeUploadProgress(false); else { setComposeUploadProgress(true, 0, error?.message || trText("Upload failed")); setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4800); } return added; } finally { if (state.composeUploadController === controller) state.composeUploadController = null; state.composeUploading = false; } } function previewMarkdown() { const editor = $("#compose-editor"); const preview = $("#markdown-preview-content"); if (!editor || !preview) return; preview.innerHTML = markdownToSafeHtml(editor.innerText); $("#markdown-preview-dialog").showModal(); } function setComposeMode(mode, persist = false) { const value = ["html", "text", "markdown"].includes(mode) ? mode : "html"; const select = $("#compose-mode"); if (select) select.value = value; const editor = $("#compose-editor"); editor.dataset.mode = value; editor.classList.toggle("plain-mode", value !== "html"); $$(".editor-toolbar [data-command], #editor-link, #editor-image, #editor-font, #editor-size, #editor-color").forEach((control) => { control.disabled = value !== "html"; }); const imageEdit = $("#editor-image-edit"); if (imageEdit) imageEdit.disabled = value !== "html" || !state.composeSelectedImage; $("#compose-markdown-preview")?.classList.toggle("hidden", value !== "markdown"); updateComposeMessageSize(); if (persist) savePreference("composeFormat", value); } function responseTemplatePlainText(template) { if (!template) return ""; if (template.format !== "html") return template.body || ""; const doc = new DOMParser().parseFromString(sanitizeMessageHtml(template.body || ""), "text/html"); return doc.body.innerText || doc.body.textContent || ""; } function renderResponseTemplates() { const list = $("#response-template-list"); const select = $("#compose-response-template"); if (select) { const current = select.value; select.replaceChildren(new Option("Response…", "")); for (const template of state.responseTemplates) select.add(new Option(template.name, String(template.id))); select.value = state.responseTemplates.some((template) => String(template.id) === current) ? current : ""; const insert = $("#compose-response-insert"); if (insert) insert.disabled = !select.value; } if (!list) return; list.replaceChildren(); if (!state.responseTemplates.length) { list.append(el("span", "muted", "No saved responses.")); return; } for (const template of state.responseTemplates) { const row = el("div", "response-template-row"); const meta = el("div", "response-template-meta"); meta.append(el("strong", "", template.name), el("span", "muted", template.format === "html" ? "HTML" : "Plain text")); const actions = el("div", "inline-actions"); const edit = el("button", "button quiet small", "Edit"); edit.type = "button"; edit.addEventListener("click", () => editResponseTemplate(template)); const remove = el("button", "button quiet small danger", "Delete"); remove.type = "button"; remove.addEventListener("click", () => deleteResponseTemplate(template)); actions.append(edit, remove); row.append(meta, actions); list.append(row); } } async function loadResponseTemplates() { try { const data = await request("/response-templates", { method: "GET" }); state.responseTemplates = Array.isArray(data.templates) ? data.templates : []; renderResponseTemplates(); } catch (error) { console.warn("Could not load saved responses", error); } } async function editResponseTemplate(template = null) { const name = await appPrompt(template ? "Edit saved response" : "New saved response", { message: "Name shown in the composer response menu.", value: template?.name || "", placeholder: "Response name", required: true, confirmLabel: "Next", }); if (name === null) return; const format = await appSelect("Response format", [ { value: "text", label: "Plain text" }, { value: "html", label: "HTML" }, ], { value: template?.format || "text", confirmLabel: "Next" }); if (format === null) return; const body = await appDialog({ title: template ? "Edit response content" : "Response content", message: format === "html" ? "HTML is sanitized before it is inserted into a message." : "Enter the reusable response text.", input: { multiline: true, value: template?.body || "", required: true }, confirmLabel: template ? "Save" : "Create", }); if (body === null) return; try { const path = template ? `/response-templates/${encodeURIComponent(template.id)}` : "/response-templates"; await request(path, { method: template ? "PUT" : "POST", body: JSON.stringify({ name, body, format }) }); await loadResponseTemplates(); toast(template ? "Saved response updated" : "Saved response created", "success"); } catch (error) { toast(error.message, "error"); } } async function deleteResponseTemplate(template) { if (!template || !await appConfirm(`Delete saved response “${template.name}”?`, { title: "Delete saved response", confirmLabel: "Delete", danger: true })) return; try { await request(`/response-templates/${encodeURIComponent(template.id)}`, { method: "DELETE" }); await loadResponseTemplates(); toast("Saved response deleted", "success"); } catch (error) { toast(error.message, "error"); } } function insertSavedResponse() { const id = Number($("#compose-response-template")?.value || 0); const template = state.responseTemplates.find((item) => Number(item.id) === id); const editor = $("#compose-editor"); if (!template || !editor) return; const mode = $("#compose-mode")?.value || "html"; editor.focus(); let range = captureEditorRange(editor) || state.composeInsertRange; if (!range) { range = document.createRange(); range.selectNodeContents(editor); range.collapse(false); } range.deleteContents(); let node; if (mode === "html") { const html = template.format === "html" ? sanitizeMessageHtml(template.body || "") : escapeHtml(template.body || "").replace(/\r?\n/g, "
    "); const fragment = range.createContextualFragment(html); node = fragment.lastChild; range.insertNode(fragment); } else { node = document.createTextNode(responseTemplatePlainText(template)); range.insertNode(node); } if (node) { const selection = window.getSelection(); const after = document.createRange(); after.setStartAfter(node); after.collapse(true); selection.removeAllRanges(); selection.addRange(after); } state.composeDirty = true; scheduleDraftSave(); } function captureEditorRange(editor = $("#compose-editor")) { const selection = window.getSelection(); if (!editor || !selection || !selection.rangeCount) return null; const range = selection.getRangeAt(0); const container = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement; if (container !== editor && !editor.contains(container)) return null; return range.cloneRange(); } function restoreEditorRange(range, editor = $("#compose-editor")) { editor.focus(); if (!range) return; try { const selection = window.getSelection(); selection.removeAllRanges(); selection.addRange(range); } catch (_) { } } function setSelectedInlineImage(image) { if (state.composeSelectedImage && state.composeSelectedImage !== image) { state.composeSelectedImage.classList?.remove("compose-inline-image-selected"); } state.composeSelectedImage = image && image.isConnected ? image : null; state.composeSelectedImage?.classList.add("compose-inline-image-selected"); const edit = $("#editor-image-edit"); if (edit) edit.disabled = $("#compose-mode")?.value !== "html" || !state.composeSelectedImage; } function inlineImageAlignment(image) { if (!image) return "inline"; if (image.dataset.rustboxAlign) return image.dataset.rustboxAlign; const left = image.style.marginLeft; const right = image.style.marginRight; if (left === "auto" && right === "auto") return "center"; if (left === "auto" && right === "0px") return "right"; if (left === "0px" && right === "auto") return "left"; return "inline"; } function applyInlineImageDisplay(image, widthPercent, alignment) { if (!image) return; const width = Math.max(10, Math.min(100, Number(widthPercent) || 100)); image.style.width = `${width}%`; image.style.maxWidth = "100%"; image.style.height = "auto"; image.style.float = "none"; image.style.marginTop = "0"; image.style.marginBottom = "0"; image.dataset.rustboxAlign = alignment; if (alignment === "center") { image.style.display = "block"; image.style.marginLeft = "auto"; image.style.marginRight = "auto"; } else if (alignment === "right") { image.style.display = "block"; image.style.marginLeft = "auto"; image.style.marginRight = "0px"; } else if (alignment === "left") { image.style.display = "block"; image.style.marginLeft = "0px"; image.style.marginRight = "auto"; } else { image.style.display = "inline-block"; image.style.marginLeft = "0"; image.style.marginRight = "0"; } } function imageCropValues() { const value = (id) => Math.max(0, Math.min(45, Number($(id)?.value) || 0)); return { top: value("#image-crop-top"), right: value("#image-crop-right"), bottom: value("#image-crop-bottom"), left: value("#image-crop-left") }; } function updateImageEditOutputs() { const width = Number($("#image-edit-width")?.value || 100); const widthOut = $("#image-edit-width-value"); if (widthOut) widthOut.textContent = `${width}%`; for (const side of ["top", "right", "bottom", "left"]) { const input = $(`#image-crop-${side}`); const output = $(`#image-crop-${side}-value`); if (input && output) output.textContent = `${input.value}%`; } } function drawInlineImageCropPreview() { const bitmap = state.imageEditorBitmap; const canvas = $("#image-edit-preview"); if (!bitmap || !canvas) return; const crop = imageCropValues(); const sx = Math.round(bitmap.width * crop.left / 100); const sy = Math.round(bitmap.height * crop.top / 100); const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100)); const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100)); const scale = Math.min(1, 760 / sw, 560 / sh); canvas.width = Math.max(1, Math.round(sw * scale)); canvas.height = Math.max(1, Math.round(sh * scale)); const ctx = canvas.getContext("2d"); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height); updateImageEditOutputs(); } function resetImageCropControls() { for (const side of ["top", "right", "bottom", "left"]) { const input = $(`#image-crop-${side}`); if (input) input.value = "0"; } drawInlineImageCropPreview(); } async function decodeInlineImageBlob(blob) { if (typeof createImageBitmap === "function") return createImageBitmap(blob); const url = URL.createObjectURL(blob); try { const image = new Image(); image.decoding = "async"; await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error("Could not decode image")); image.src = url; }); return image; } finally { URL.revokeObjectURL(url); } } async function openInlineImageEditor(image = state.composeSelectedImage) { if (!image?.dataset?.rustboxCid) return toast("Select an inline image first", "info"); setSelectedInlineImage(image); const cid = image.dataset.rustboxCid; const attachment = state.composeAttachments.find((item) => String(item.id) === String(cid)); if (!attachment) return toast("The inline image attachment is no longer available", "error"); try { state.imageEditorBitmap?.close?.(); const response = await fetch(composeAttachmentContentUrl(ensureDraftId(), cid), { credentials: "same-origin", cache: "no-store" }); if (!response.ok) throw new Error(`Could not load image (${response.status})`); state.imageEditorBlob = await response.blob(); state.imageEditorBitmap = await decodeInlineImageBlob(state.imageEditorBlob); const editorWidth = Math.max(1, $("#compose-editor")?.clientWidth || image.parentElement?.clientWidth || image.clientWidth || 1); const stylePercent = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : null; const measuredPercent = Math.round((image.getBoundingClientRect().width / editorWidth) * 100 / 5) * 5; $("#image-edit-width").value = String(Math.max(10, Math.min(100, stylePercent || measuredPercent || 100))); $("#image-edit-align").value = inlineImageAlignment(image); resetImageCropControls(); const cropControls = $("#image-crop-controls"); const isGif = String(attachment.content_type || state.imageEditorBlob.type || "").toLowerCase() === "image/gif"; cropControls?.classList.toggle("image-crop-disabled", isGif); cropControls?.querySelectorAll("input").forEach((input) => { input.disabled = isGif; }); const help = $("#image-edit-help"); if (help) help.textContent = isGif ? "Size and position are available. Cropping animated GIF files is disabled so the animation is not flattened." : "Resizing changes only the displayed size. Cropping creates a new CID image and replaces the previous inline attachment."; drawInlineImageCropPreview(); $("#image-edit-dialog").showModal(); } catch (error) { toast(error.message || "Could not open the image editor", "error"); } } function croppedImageFilename(filename, mime) { const base = String(filename || "image").replace(/\.[^.]+$/, "") || "image"; const ext = mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png"; return `${base}-crop.${ext}`; } async function cropInlineImageToFile(bitmap, sourceBlob, crop, filename) { const sx = Math.round(bitmap.width * crop.left / 100); const sy = Math.round(bitmap.height * crop.top / 100); const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100)); const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100)); if (sw * sh > 40_000_000) throw new Error("The crop is too large to process safely in the browser"); const canvas = document.createElement("canvas"); canvas.width = sw; canvas.height = sh; const ctx = canvas.getContext("2d"); ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, sw, sh); const sourceType = String(sourceBlob?.type || "").toLowerCase(); const outputType = ["image/jpeg", "image/webp", "image/png"].includes(sourceType) ? sourceType : "image/png"; const blob = await new Promise((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error("Could not create cropped image")), outputType, outputType === "image/png" ? undefined : 0.92)); return new File([blob], croppedImageFilename(filename, outputType), { type: outputType, lastModified: Date.now() }); } async function applyInlineImageEditor(event) { event.preventDefault(); const image = state.composeSelectedImage; const bitmap = state.imageEditorBitmap; if (!image?.dataset?.rustboxCid || !bitmap) return; const width = Number($("#image-edit-width").value || 100); const alignment = $("#image-edit-align").value || "inline"; const crop = imageCropValues(); const hasCrop = Object.values(crop).some((value) => value > 0); const oldCid = image.dataset.rustboxCid; const oldAttachment = state.composeAttachments.find((item) => String(item.id) === String(oldCid)); const applyButton = $("#image-edit-apply"); setBusy(applyButton, true); try { if (hasCrop) { const type = String(oldAttachment?.content_type || state.imageEditorBlob?.type || "").toLowerCase(); if (type === "image/gif") throw new Error("Cropping animated GIF files is disabled to preserve animation"); const file = await cropInlineImageToFile(bitmap, state.imageEditorBlob, crop, oldAttachment?.filename || image.alt || "image"); const uploaded = await uploadComposeFiles([file], { announce: false }); const replacement = uploaded[0]; if (!replacement) throw new Error("Could not upload the cropped image"); state.inlineAttachmentIds.add(String(replacement.id)); image.dataset.rustboxCid = replacement.id; image.src = composeAttachmentContentUrl(ensureDraftId(), replacement.id); image.alt = replacement.filename || image.alt || "image"; state.inlineAttachmentIds.delete(String(oldCid)); await deleteComposeAttachment(oldCid); } applyInlineImageDisplay(image, width, alignment); setSelectedInlineImage(image); scheduleDraftSave(); $("#image-edit-dialog").close(); toast(hasCrop ? "Image cropped" : "Image layout updated", "success"); } catch (error) { toast(error.message || "Could not update image", "error"); } finally { setBusy(applyButton, false); } } function scheduleUnusedInlineAttachmentCleanup() { clearTimeout(state.inlineImageCleanupTimer); state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500); } async function cleanupUnusedInlineAttachments() { if (!state.composeDraftId || !state.inlineAttachmentIds.size) return; const used = new Set($$("#compose-editor img[data-rustbox-cid]").map((image) => String(image.dataset.rustboxCid || "")).filter(Boolean)); const unused = [...state.inlineAttachmentIds].filter((id) => !used.has(String(id))); if (!unused.length) return; let changed = false; for (const id of unused) { try { await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" }); state.composeAttachments = state.composeAttachments.filter((attachment) => String(attachment.id) !== String(id)); state.inlineAttachmentIds.delete(String(id)); changed = true; } catch (error) { console.warn("Could not remove unused inline attachment", id, error); } } if (changed) renderComposeAttachments(); } function editorRangeFromPoint(x, y) { const editor = $("#compose-editor"); if (!editor) return null; if (document.caretRangeFromPoint) { const range = document.caretRangeFromPoint(x, y); if (range && editor.contains(range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement)) return range; } if (document.caretPositionFromPoint) { const position = document.caretPositionFromPoint(x, y); if (position?.offsetNode) { const range = document.createRange(); range.setStart(position.offsetNode, position.offset); range.collapse(true); if (editor.contains(position.offsetNode.nodeType === Node.ELEMENT_NODE ? position.offsetNode : position.offsetNode.parentElement)) return range; } } return captureEditorRange(editor); } async function uploadInlineImage(files, insertionRange = null) { const images = [...(files || [])].filter((item) => /^image\/(png|jpeg|gif|webp)$/i.test(item.type)); if (!images.length) return toast("Choose a PNG, JPEG, GIF or WebP image", "error"); const uploaded = await uploadComposeFiles(images); if (!uploaded.length) return; setComposeMode("html"); const editor = $("#compose-editor"); restoreEditorRange(insertionRange, editor); const draftId = ensureDraftId(); let lastInsertedId = null; for (const attachment of uploaded) { const previewUrl = composeAttachmentContentUrl(draftId, attachment.id); state.inlineAttachmentIds.add(String(attachment.id)); lastInsertedId = String(attachment.id); const html = `${escapeHtml(attachment.filename || `; document.execCommand("insertHTML", false, html); } if (lastInsertedId) { const inserted = [...editor.querySelectorAll("img[data-rustbox-cid]")].reverse().find((image) => String(image.dataset.rustboxCid) === lastInsertedId); if (inserted) setSelectedInlineImage(inserted); } scheduleDraftSave(); } function applyRecoveredDraft(draft, sourceLabel = "Saved draft") { state.composeSignatureGeneration += 1; state.composeDraftId = draft.id || null; state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : []; state.inlineAttachmentIds = new Set(); setSelectedInlineImage(null); state.composeReplyMeta = { inReplyTo: draft.in_reply_to || null, references: draft.references || null, }; $("#compose-to").value = draft.to || ""; $("#compose-cc").value = draft.cc || ""; $("#compose-bcc").value = draft.bcc || ""; $("#compose-subject").value = draft.subject || ""; $("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal"; $("#compose-mdn").checked = Boolean(draft.request_mdn); $("#compose-pgp-mode").value = ["none", "sign", "encrypt", "sign-encrypt"].includes(draft.pgp_mode) ? draft.pgp_mode : "none"; $("#compose-pgp-inline").checked = Boolean(draft.pgp_inline); $("#compose-pgp-attach-key").checked = Boolean(draft.pgp_attach_public_key); $("#compose-pgp-attach-recipient-keys").checked = Boolean(draft.pgp_attach_recipient_keys); $("#compose-pgp-passphrase").value = ""; updateComposePgpUi(); const mode = draft.compose_format || (draft.html_body ? "html" : "text"); setComposeMode(mode); const editor = $("#compose-editor"); if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId); else editor.textContent = draft.text_body || ""; renderComposeAttachments(); state.composeDirty = sourceLabel === "Browser recovery"; state.composeNeedsRemoteSync = sourceLabel === "Browser recovery"; if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot(); $("#compose-title").textContent = trText("Recovered draft"); $("#compose-status").textContent = `${sourceLabel}${draft.updated_at ? ` · ${draft.updated_at}` : ""}`; setTimeout(() => editor.focus(), 20); } async function recoverDraft() { if (state.composeDirty && draftHasContent()) { const saved = await saveCurrentDraft(true, true); if (!saved) return toast("Save the current draft before recovering another one", "error"); } const localDrafts = readLocalDraftSnapshots(); let serverDrafts = []; try { const data = await request("/drafts", { method: "GET" }); serverDrafts = data.drafts || []; } catch (error) { if (!localDrafts.length) return toast(error.message, "error"); } const options = []; for (const local of localDrafts) { const localSubject = local.subject || "(no subject)"; options.push({ value: `local:${local.id}`, label: `Browser recovery — ${localSubject}` }); } for (const draft of serverDrafts.slice(0, 50)) { const subject = draft.subject || "(no subject)"; const recipient = draft.to || "no recipient"; options.push({ value: `server:${draft.id}`, label: `${subject} — ${recipient}` }); } if (!options.length) return toast("No recoverable drafts found", "info"); const selected = await appSelect("Recover draft", options, { value: options[0].value, confirmLabel: "Recover" }); if (!selected) return; if (selected.startsWith("local:")) { const id = selected.slice("local:".length); const local = localDrafts.find((draft) => draft.id === id); if (!local) return toast("Browser recovery snapshot is no longer available", "error"); applyRecoveredDraft(local, "Browser recovery"); toast("Recovered unsaved browser draft", "success"); return; } const id = selected.slice("server:".length); try { const draft = await request(`/drafts/${encodeURIComponent(id)}`, { method: "GET" }); applyRecoveredDraft(draft, "Saved draft"); toast("Draft recovered", "success"); } catch (error) { toast(error.message, "error"); } } async function closeComposer() { clearTimeout(state.draftSaveTimer); clearTimeout(state.inlineImageCleanupTimer); if (state.composeUploadController) state.composeUploadController.abort(); await cleanupUnusedInlineAttachments(); if (state.composeNeedsRemoteSync && draftHasContent()) { const saved = await saveCurrentDraft(true, true, true); if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); } else if (state.composeDirty && draftHasContent()) { const saved = await saveCurrentDraft(true, true); if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200); } if ($("#compose-dialog").open) $("#compose-dialog").close(); state.composeSignatureGeneration += 1; } function attachmentReminderNeeded() { if (state.prefs.attachmentReminder === false) return false; if (state.composeAttachments.some((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)))) return false; const text = `${$("#compose-subject").value} ${$("#compose-editor").innerText}`.toLowerCase(); return /\b(attach(ed|ment|ments)?|załącz(am|nik|niki|niku|one|ony)?|zalacz(am|nik|niki|niku|one|ony)?)\b/i.test(text); } async function deleteComposeAttachment(id) { if (!state.composeDraftId) return; try { await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method: "DELETE" }); state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id); state.inlineAttachmentIds.delete(String(id)); if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null); $("#compose-editor")?.querySelectorAll("img[data-rustbox-cid]").forEach((image) => { if (image.dataset.rustboxCid === id) image.remove(); }); renderComposeAttachments(); scheduleDraftSave(); } catch (error) { toast(error.message, "error"); } } function selectIdentityForMessage(detail) { const select = $("#compose-from"); if (!select || !detail || !state.identities.length) return; const addresses = new Set(); for (const entry of [...(detail.to_addresses || []), ...(detail.cc_addresses || [])]) { if (entry?.address) addresses.add(String(entry.address).trim().toLowerCase()); } const raw = String(detail.raw_headers || ""); for (const match of raw.matchAll(/^(?:delivered-to|envelope-to):\s*(.+)$/gim)) { const value = match[1] || ""; const email = value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0]; if (email) addresses.add(email.toLowerCase()); } const identity = state.identities.find((candidate) => addresses.has(String(candidate.email || "").trim().toLowerCase())); if (identity) select.value = String(identity.id); } function setComposeFullscreen(enabled, persist = true) { const dialog = $("#compose-dialog"); const button = $("#compose-fullscreen"); if (!dialog || !button) return; dialog.classList.toggle("compose-fullscreen", Boolean(enabled)); button.setAttribute("aria-pressed", enabled ? "true" : "false"); button.title = enabled ? "Exit full screen" : "Full screen"; button.textContent = enabled ? "↙" : "⛶"; if (persist) { try { localStorage.setItem("rustbox.composeFullscreen", enabled ? "1" : "0"); } catch (_) { } } } function toggleComposeFullscreen() { setComposeFullscreen(!$("#compose-dialog").classList.contains("compose-fullscreen")); } function openCompose(mode = "new") { if (mode === "forward" && state.prefs.forwardMode === "attachment") { void forwardAsAttachment(); return; } const detail = state.currentDetail; clearTimeout(state.draftSaveTimer); $("#compose-form").reset(); renderComposeIdentities(); if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail); state.composeDraftId = null; state.composeAttachments = []; state.composeSignatureGeneration += 1; state.inlineAttachmentIds = new Set(); setSelectedInlineImage(null); state.composeDirty = false; state.composeNeedsRemoteSync = false; state.composeReplyMeta = null; setComposeUploadProgress(false); renderComposeAttachments(); $("#compose-error").classList.add("hidden"); $("#compose-status").textContent = ""; $("#compose-title").textContent = trText(mode === "reply" ? "Reply" : mode === "reply-all" ? "Reply all" : mode === "forward" ? "Forward" : "New message"); const editor = $("#compose-editor"); editor.replaceChildren(); editor.spellcheck = state.prefs.spellcheckEnabled !== false; setComposeMode(state.prefs.composeFormat || "html"); $("#compose-priority").value = ["high", "normal", "low"].includes(state.prefs.defaultPriority) ? state.prefs.defaultPriority : "normal"; $("#compose-mdn").checked = Boolean(state.prefs.requestMdnDefault); state.pgpRecipientStatus = []; $("#compose-pgp-passphrase").value = ""; updateComposePgpUi(true); if (detail && (mode === "reply" || mode === "reply-all")) { state.composeReplyMeta = { inReplyTo: detail.message_id || null, references: buildReferences(detail) }; $("#compose-to").value = detail.from_address || ""; if (mode === "reply-all") { const own = ownAddressSet(); const cc = [...(detail.to_addresses || []), ...(detail.cc_addresses || [])] .map((entry) => entry.address || "") .filter((address) => address && address.toLowerCase() !== String(detail.from_address || "").toLowerCase() && !own.has(address.toLowerCase())); $("#compose-cc").value = [...new Set(cc)].join(", "); } $("#compose-subject").value = /^re:/i.test(detail.subject || "") ? detail.subject : `Re: ${detail.subject || ""}`; const quoteStyle = state.prefs.quoteStyle || "standard"; const quote = quoteStyle === "none" ? null : el("div", "compose-quote"); if (quote) { const compact = quoteStyle === "compact"; if ($("#compose-mode").value === "html") { const attribution = compact ? `${escapeHtml(detail.from_address || "")} · ${escapeHtml(formatDate(detail.date, true))}` : `On ${escapeHtml(formatDate(detail.date, true))}, ${escapeHtml(detail.from_address || "")} wrote:`; quote.innerHTML = `
    ${attribution}
    ${quoteTextAsHtml(detail.text || "")}
    `; } else { const quoted = cleanPlainMessageText(detail.text || "").split("\n").map((line) => `> ${line}`).join("\n"); const attribution = compact ? `${detail.from_address || ""} · ${formatDate(detail.date, true)}` : `On ${formatDate(detail.date, true)}, ${detail.from_address || ""} wrote:`; quote.textContent = `\n\n${attribution}\n${quoted}`; } } const entry = el("div", "compose-reply-entry"); entry.innerHTML = "
    "; if (state.prefs.replyPosition === "below" && quote) { editor.append(quote, entry); } else { editor.append(entry); if (quote) editor.append(quote); } } if (detail && mode === "forward") { $("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`; const quote = el("div", "compose-quote"); if ($("#compose-mode").value === "html") { quote.innerHTML = `
    Forwarded message
    From: ${escapeHtml(detail.from_address || "")}
    Date: ${escapeHtml(formatDate(detail.date, true))}
    Subject: ${escapeHtml(detail.subject || "")}

    ${quoteTextAsHtml(detail.text || "")}
    `; } else { quote.textContent = `\n\nForwarded message\nFrom: ${detail.from_address || ""}\nDate: ${formatDate(detail.date, true)}\nSubject: ${detail.subject || ""}\n\n${cleanPlainMessageText(detail.text || "")}`; } editor.append(quote); } insertIdentitySignature(editor, mode); let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen"); try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) { } setComposeFullscreen(fullScreen, false); updateComposeMessageSize(); $("#compose-dialog").showModal(); setTimeout(() => (mode === "new" ? $("#compose-to") : editor).focus(), 30); } function buildReferences(detail) { if (!detail) return null; const values = [detail.references, detail.message_id].filter(Boolean); return values.length ? values.join(" ") : null; } async function sendMessage(event) { event.preventDefault(); if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning"); clearTimeout(state.inlineImageCleanupTimer); await cleanupUnusedInlineAttachments(); const send = $("#send-btn"); const errorBox = $("#compose-error"); errorBox.classList.add("hidden"); if (attachmentReminderNeeded() && !await appConfirm("The message mentions an attachment but no file is attached. Send anyway?", { title: "Missing attachment", confirmLabel: "Send anyway" })) return; const pgpMode = $("#compose-pgp-mode").value || "none"; const pgpInline = Boolean($("#compose-pgp-inline").checked); if (pgpInline && state.composeAttachments.length) return toast("Inline OpenPGP cannot carry attachments. Disable inline mode to use PGP/MIME.", "warning"); if (pgpInline && $("#compose-mode").value !== "text" && !await appConfirm("Inline OpenPGP sends the plain-text representation and drops HTML/Markdown formatting. Continue?", { title: "Inline OpenPGP", confirmLabel: "Continue" })) return; if (pgpMode === "encrypt" || pgpMode === "sign-encrypt") { const statuses = await checkComposePgpRecipients(false); const missing = statuses.filter((item) => !item.hasKey); const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted"); const notTrusted = state.pgpPrefs.requireTrustedEncryption ? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust)) : []; if (missing.length) return toast(`Missing OpenPGP key for ${missing.map((item) => item.email).join(", ")}`, "warning"); if (untrusted.length) return toast(`OpenPGP key marked untrusted for ${untrusted.map((item) => item.email).join(", ")}`, "warning"); if (notTrusted.length) return toast(`OpenPGP policy requires trusted keys: ${notTrusted.map((item) => item.email).join(", ")}`, "warning"); } if ($("#compose-pgp-attach-recipient-keys").checked) { const visibleRecipients = composePgpVisibleRecipients(); if (!visibleRecipients.length) return toast("Add at least one To/Cc recipient before attaching recipient OpenPGP keys", "warning"); const statuses = await checkComposePgpRecipients(false, visibleRecipients); if (!statuses.some((item) => item.hasKey)) return toast("No OpenPGP public key is available for any To/Cc recipient", "warning"); } setBusy(send, true); const editor = $("#compose-editor"); try { await request("/messages/send", { method: "POST", body: JSON.stringify({ to: parseRecipients($("#compose-to").value), cc: parseRecipients($("#compose-cc").value), bcc: parseRecipients($("#compose-bcc").value), subject: $("#compose-subject").value, text_body: editor.innerText, html_body: serializeComposeHtml(editor), in_reply_to: state.composeReplyMeta?.inReplyTo || null, references: state.composeReplyMeta?.references || null, draft_id: state.composeDraftId, from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null, request_mdn: Boolean($("#compose-mdn").checked), priority: $("#compose-priority").value || "normal", pgp_mode: $("#compose-pgp-mode").value || "none", pgp_inline: Boolean($("#compose-pgp-inline").checked), pgp_passphrase: $("#compose-pgp-passphrase").value || "", pgp_attach_public_key: Boolean($("#compose-pgp-attach-key").checked), pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys").checked), }), }); $("#compose-dialog").close(); clearLocalDraftSnapshot(state.composeDraftId); state.composeDraftId = null; state.composeAttachments = []; state.inlineAttachmentIds = new Set(); setSelectedInlineImage(null); state.composeReplyMeta = null; state.composeDirty = false; state.composeNeedsRemoteSync = false; state.pgpRecipientStatus = []; $("#compose-pgp-passphrase").value = ""; updateComposeMessageSize(); toast("Message sent", "success"); await loadFolders(); } catch (error) { state.composeDirty = true; state.composeNeedsRemoteSync = true; storeLocalDraftSnapshot(); await saveCurrentDraft(true, true, true); errorBox.textContent = error.message; errorBox.classList.remove("hidden"); } finally { setBusy(send, false); } }