Files
RustBox/src/web/js/50-compose.js
T
2026-10-31 14:36:07 +01:00

1230 lines
56 KiB
JavaScript

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", "<br>");
}
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")}<span>${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
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(`<code>${body}</code>`);
return token;
});
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, rawUrl) => {
let url = rawUrl.replaceAll("&amp;", "&");
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 `<a href="${escapeHtml(url)}" rel="noopener noreferrer">${label}</a>`;
});
html = html
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
.replace(/__([^_\n]+)__/g, "<strong>$1</strong>")
.replace(/~~([^~\n]+)~~/g, "<del>$1</del>")
.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1<em>$2</em>")
.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, "$1<em>$2</em>");
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(`<p>${paragraph.map(markdownInlineToSafeHtml).join("<br>")}</p>`);
paragraph = [];
};
const closeList = () => {
if (!listType) return;
out.push(`</${listType}>`);
listType = null;
};
for (const line of lines) {
if (/^\s*```/.test(line)) {
flushParagraph(); closeList();
if (inCode) { out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`); 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(`<h${level}>${markdownInlineToSafeHtml(heading[2])}</h${level}>`); continue; }
if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) { flushParagraph(); closeList(); out.push("<hr>"); continue; }
const quote = line.match(/^>\s?(.*)$/);
if (quote) { flushParagraph(); closeList(); out.push(`<blockquote>${markdownInlineToSafeHtml(quote[1])}</blockquote>`); 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(`<li>${markdownInlineToSafeHtml((unordered || ordered)[1])}</li>`);
continue;
}
paragraph.push(line);
}
if (inCode) out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
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, "&amp;").replace(/"/g, "&quot;");
return `<div class="rustbox-compose-font" style="font-family:${family}">${html}</div>`;
}
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, "<br>");
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 = `<img src="${escapeHtml(previewUrl)}" data-rustbox-cid="${escapeHtml(attachment.id)}" data-rustbox-align="inline" alt="${escapeHtml(attachment.filename || "image")}" style="max-width:100%;height:auto">`;
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
? `<strong>${escapeHtml(detail.from_address || "")}</strong> · ${escapeHtml(formatDate(detail.date, true))}`
: `On ${escapeHtml(formatDate(detail.date, true))}, ${escapeHtml(detail.from_address || "")} wrote:`;
quote.innerHTML = `<blockquote>${attribution}<br>${quoteTextAsHtml(detail.text || "")}</blockquote>`;
} 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 = "<br>";
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 = `<blockquote><strong>Forwarded message</strong><br>From: ${escapeHtml(detail.from_address || "")}<br>Date: ${escapeHtml(formatDate(detail.date, true))}<br>Subject: ${escapeHtml(detail.subject || "")}<br><br>${quoteTextAsHtml(detail.text || "")}</blockquote>`;
} 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); }
}