1230 lines
56 KiB
JavaScript
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("&", "&");
|
|
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, "&").replace(/"/g, """);
|
|
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); }
|
|
}
|