1906 lines
84 KiB
JavaScript
1906 lines
84 KiB
JavaScript
|
|
function parseRecipients(raw) {
|
|
const values = [];
|
|
let current = "";
|
|
let quoted = false;
|
|
let escaped = false;
|
|
let angleDepth = 0;
|
|
for (const char of String(raw || "")) {
|
|
if (escaped) { current += char; escaped = false; continue; }
|
|
if (char === "\\" && quoted) { current += char; escaped = true; continue; }
|
|
if (char === '"') { quoted = !quoted; current += char; continue; }
|
|
if (!quoted && char === "<") angleDepth += 1;
|
|
if (!quoted && char === ">" && angleDepth > 0) angleDepth -= 1;
|
|
if (char === "," && !quoted && angleDepth === 0) {
|
|
if (current.trim()) values.push(current.trim());
|
|
current = "";
|
|
continue;
|
|
}
|
|
current += char;
|
|
}
|
|
if (current.trim()) values.push(current.trim());
|
|
return values;
|
|
}
|
|
|
|
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);
|
|
|
|
const identity = typeof selectedIdentity === "function" ? selectedIdentity() : null;
|
|
const action = state.composeAction || "new";
|
|
const signatureEnabled = identity && (action === "reply"
|
|
? identity.signature_reply !== false
|
|
: action === "forward"
|
|
? identity.signature_forward !== false
|
|
: identity.signature_new !== false);
|
|
if (signatureEnabled && identity.signature_insert_mode !== "visible") {
|
|
const signatureText = identity.signature_text || "";
|
|
bytes += utf8Bytes(signatureText) + 8;
|
|
if (mode === "html") bytes += utf8Bytes(identity.signature_html || "") + 96;
|
|
}
|
|
}
|
|
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 mergeComposeAttachments(...lists) {
|
|
const merged = new Map();
|
|
for (const list of lists) {
|
|
for (const attachment of Array.isArray(list) ? list : []) {
|
|
const id = String(attachment?.id || "");
|
|
if (!id) continue;
|
|
merged.set(id, { ...(merged.get(id) || {}), ...attachment, id: attachment.id || id });
|
|
}
|
|
}
|
|
return [...merged.values()];
|
|
}
|
|
|
|
async function refreshComposeAttachmentsFromServer(draftId = state.composeDraftId) {
|
|
if (!draftId) return state.composeAttachments;
|
|
try {
|
|
const draft = await request(`/drafts/${encodeURIComponent(draftId)}`, { method: "GET" });
|
|
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, draft.attachments);
|
|
renderComposeAttachments();
|
|
} catch (_) {
|
|
// Keep the local list if the draft endpoint is temporarily unavailable.
|
|
}
|
|
return state.composeAttachments;
|
|
}
|
|
|
|
function detachComposeAttachmentFromBody(id) {
|
|
const cid = String(id || "");
|
|
if (!cid) return false;
|
|
let changed = false;
|
|
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
|
|
if (String(node.dataset.rustboxCid || "") !== cid) return;
|
|
if (node === state.composeSelectedImage) setSelectedInlineImage(null);
|
|
node.remove();
|
|
changed = true;
|
|
});
|
|
if (!changed) return false;
|
|
syncComposeInlineAttachments();
|
|
scheduleDraftSave();
|
|
return true;
|
|
}
|
|
|
|
function renderComposeAttachments() {
|
|
const box = $("#compose-attachments");
|
|
box.replaceChildren();
|
|
const attachments = mergeComposeAttachments(state.composeAttachments);
|
|
state.composeAttachments = attachments;
|
|
const visibleAttachments = attachments.filter((attachment) => !state.signatureAttachmentIds.has(String(attachment.id || "")));
|
|
box.classList.toggle("hidden", !visibleAttachments.length);
|
|
for (const attachment of visibleAttachments) {
|
|
const id = String(attachment.id || "");
|
|
const filename = attachment.filename || "attachment";
|
|
const isImage = composeAttachmentIsImage(attachment);
|
|
const isInline = state.inlineAttachmentIds.has(id);
|
|
const item = el("div", "compose-attachment");
|
|
item.dataset.attachmentId = id;
|
|
const kind = isInline ? (isImage ? "Inline image" : "Inline link") : (isImage ? "Image attachment" : "Attachment");
|
|
item.innerHTML = `${svgIcon("paperclip")}<span class="compose-attachment-kind">${kind}</span><span class="compose-attachment-label">${escapeHtml(filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
|
|
|
|
const embed = el("button", "compose-attachment-action");
|
|
embed.type = "button";
|
|
if (isInline) {
|
|
embed.title = `Keep ${filename} as attachment only`;
|
|
embed.setAttribute("aria-label", embed.title);
|
|
embed.innerHTML = svgIcon("paperclip");
|
|
embed.addEventListener("click", () => detachComposeAttachmentFromBody(id));
|
|
} else {
|
|
embed.title = isImage ? `Embed ${filename} in message` : `Insert a link to ${filename} in message`;
|
|
embed.setAttribute("aria-label", embed.title);
|
|
embed.innerHTML = svgIcon(isImage ? "image" : "link");
|
|
embed.addEventListener("mousedown", () => { state.composeInsertRange = captureEditorRange(); });
|
|
embed.addEventListener("click", () => {
|
|
insertComposeAttachment(attachment, state.composeInsertRange);
|
|
state.composeInsertRange = null;
|
|
});
|
|
}
|
|
|
|
const remove = el("button", "compose-attachment-remove");
|
|
remove.innerHTML = svgIcon("x");
|
|
remove.type = "button";
|
|
remove.setAttribute("aria-label", `Remove ${filename}`);
|
|
remove.addEventListener("click", () => deleteComposeAttachment(attachment.id));
|
|
item.append(embed, 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 composeImapAttachmentContentUrl(attachment, source = state.composeRemoteDraft) {
|
|
if (!source || attachment?.source !== "imap") return "";
|
|
const index = Number(attachment.source_index);
|
|
if (!Number.isInteger(index) || index < 0) return "";
|
|
const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : "";
|
|
return `${apiPath(`/messages/${encodeURIComponent(source.folder)}/${source.uid}/attachments/${index}`)}${query}`;
|
|
}
|
|
|
|
function composeAttachmentUrl(attachment, draftId = state.composeDraftId) {
|
|
if (attachment?.source === "imap") return composeImapAttachmentContentUrl(attachment);
|
|
return draftId ? composeAttachmentContentUrl(draftId, attachment.id) : "";
|
|
}
|
|
|
|
function composeAttachmentIsImage(attachment) {
|
|
const type = String(attachment?.content_type || attachment?.contentType || "").toLowerCase();
|
|
const filename = String(attachment?.filename || "").toLowerCase();
|
|
return /^image\/(png|jpeg|gif|webp)$/.test(type) || /\.(png|jpe?g|gif|webp)$/.test(filename);
|
|
}
|
|
|
|
function composeInlineAttachmentIds(editor = $("#compose-editor")) {
|
|
if (!editor) return new Set();
|
|
return new Set(
|
|
[...editor.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]")]
|
|
.map((node) => String(node.dataset.rustboxCid || ""))
|
|
.filter(Boolean),
|
|
);
|
|
}
|
|
|
|
function syncComposeInlineAttachments() {
|
|
const next = composeInlineAttachmentIds();
|
|
const changed = next.size !== state.inlineAttachmentIds.size || [...next].some((id) => !state.inlineAttachmentIds.has(id));
|
|
state.inlineAttachmentIds = next;
|
|
if (changed) renderComposeAttachments();
|
|
}
|
|
|
|
function trackSignatureInlineAttachment(id) {
|
|
const value = String(id || "");
|
|
if (!value) return;
|
|
state.signatureAttachmentIds.add(value);
|
|
}
|
|
|
|
function composeAttachmentInsertionRange(range, editor = $("#compose-editor")) {
|
|
const target = range?.cloneRange?.() || document.createRange();
|
|
try {
|
|
if (range) {
|
|
const container = target.commonAncestorContainer.nodeType === Node.ELEMENT_NODE
|
|
? target.commonAncestorContainer
|
|
: target.commonAncestorContainer.parentElement;
|
|
if (container !== editor && !editor.contains(container)) throw new Error("stale compose range");
|
|
// Never replace selected text or a previously selected inline image when adding another file.
|
|
target.collapse(false);
|
|
} else {
|
|
target.selectNodeContents(editor);
|
|
target.collapse(false);
|
|
}
|
|
} catch (_) {
|
|
target.selectNodeContents(editor);
|
|
target.collapse(false);
|
|
}
|
|
return target;
|
|
}
|
|
|
|
function insertComposeAttachment(attachment, insertionRange = null) {
|
|
if (!attachment?.id) return false;
|
|
if ($("#compose-mode")?.value !== "html") {
|
|
toast("Switch to HTML mode to embed attachments in the message", "warning");
|
|
return false;
|
|
}
|
|
const editor = $("#compose-editor");
|
|
const id = String(attachment.id);
|
|
const filename = attachment.filename || "attachment";
|
|
const contentUrl = composeAttachmentUrl(attachment, ensureDraftId());
|
|
if (!contentUrl) return false;
|
|
let inserted;
|
|
|
|
try {
|
|
editor.focus();
|
|
const range = composeAttachmentInsertionRange(insertionRange, editor);
|
|
if (composeAttachmentIsImage(attachment)) {
|
|
inserted = document.createElement("img");
|
|
inserted.src = contentUrl;
|
|
inserted.dataset.rustboxCid = id;
|
|
inserted.dataset.rustboxAlign = "inline";
|
|
inserted.alt = filename;
|
|
inserted.draggable = true;
|
|
inserted.style.maxWidth = "100%";
|
|
inserted.style.height = "auto";
|
|
} else {
|
|
inserted = document.createElement("a");
|
|
inserted.href = contentUrl;
|
|
inserted.dataset.rustboxCid = id;
|
|
inserted.dataset.rustboxAttachmentLink = "true";
|
|
inserted.textContent = filename;
|
|
}
|
|
|
|
range.insertNode(inserted);
|
|
const spacer = document.createTextNode("\u00a0");
|
|
inserted.after(spacer);
|
|
const after = document.createRange();
|
|
after.setStartAfter(spacer);
|
|
after.collapse(true);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(after);
|
|
|
|
state.inlineAttachmentIds.add(id);
|
|
if (composeAttachmentIsImage(attachment)) setSelectedInlineImage(inserted);
|
|
else setSelectedInlineImage(null);
|
|
} catch (error) {
|
|
state.inlineAttachmentIds.delete(id);
|
|
renderComposeAttachments();
|
|
toast(error?.message || `Could not embed ${filename}`, "error");
|
|
return false;
|
|
}
|
|
|
|
syncComposeInlineAttachments();
|
|
scheduleDraftSave();
|
|
return true;
|
|
}
|
|
|
|
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('[data-rustbox-signature-entry="true"]').forEach((entry) => {
|
|
entry.classList.remove("compose-body-entry");
|
|
entry.removeAttribute("data-rustbox-signature-entry");
|
|
if (!entry.className) entry.removeAttribute("class");
|
|
});
|
|
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.removeAttribute("draggable");
|
|
image.classList.remove("compose-inline-image-selected");
|
|
if (!image.className) image.removeAttribute("class");
|
|
});
|
|
clone.querySelectorAll("a[data-rustbox-cid]").forEach((link) => {
|
|
const cid = link.dataset.rustboxCid || "";
|
|
if (cid) link.setAttribute("href", `cid:${cid}`);
|
|
link.removeAttribute("data-rustbox-cid");
|
|
link.removeAttribute("data-rustbox-attachment-link");
|
|
});
|
|
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;
|
|
}
|
|
const attachmentList = Array.isArray(attachments) ? attachments : [];
|
|
const byCid = new Map();
|
|
for (const attachment of attachmentList) {
|
|
const id = String(attachment?.id || "");
|
|
if (!id) continue;
|
|
byCid.set(id, attachment);
|
|
const sourceCid = String(attachment?.source_content_id || "");
|
|
if (sourceCid) byCid.set(sourceCid, attachment);
|
|
}
|
|
host.querySelectorAll("img[src]").forEach((image) => {
|
|
const src = image.getAttribute("src") || "";
|
|
if (!src.toLowerCase().startsWith("cid:")) return;
|
|
const cid = src.slice(4).replace(/^<|>$/g, "");
|
|
const attachment = byCid.get(cid);
|
|
if (!attachment) return;
|
|
const id = String(attachment.id);
|
|
const contentUrl = composeAttachmentUrl(attachment, draftId);
|
|
if (!contentUrl) return;
|
|
state.inlineAttachmentIds.add(id);
|
|
if (image.closest('.compose-signature, [data-rustbox-signature="true"]')) trackSignatureInlineAttachment(id);
|
|
image.dataset.rustboxCid = id;
|
|
image.draggable = true;
|
|
image.setAttribute("src", contentUrl);
|
|
});
|
|
host.querySelectorAll("a[href]").forEach((link) => {
|
|
const href = link.getAttribute("href") || "";
|
|
if (!href.toLowerCase().startsWith("cid:")) return;
|
|
const cid = href.slice(4).replace(/^<|>$/g, "");
|
|
const attachment = byCid.get(cid);
|
|
if (!attachment) return;
|
|
const id = String(attachment.id);
|
|
const contentUrl = composeAttachmentUrl(attachment, draftId);
|
|
if (!contentUrl) return;
|
|
state.inlineAttachmentIds.add(id);
|
|
link.dataset.rustboxCid = id;
|
|
link.dataset.rustboxAttachmentLink = "true";
|
|
link.setAttribute("href", contentUrl);
|
|
});
|
|
return host.innerHTML;
|
|
}
|
|
|
|
function composeDraftPayload() {
|
|
const editor = $("#compose-editor");
|
|
const payload = {
|
|
id: ensureDraftId(),
|
|
from_identity_id: $("#compose-from")?.value ? Number($("#compose-from").value) : null,
|
|
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_action: state.composeAction || "new",
|
|
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),
|
|
};
|
|
if (state.composeRemoteDraft) {
|
|
payload.imap_source = { ...state.composeRemoteDraft };
|
|
payload.imap_attachments = state.composeAttachments
|
|
.filter((attachment) => attachment?.source === "imap")
|
|
.map((attachment) => ({ id: String(attachment.id), index: Number(attachment.source_index) }))
|
|
.filter((attachment) => attachment.id && Number.isInteger(attachment.index) && attachment.index >= 0);
|
|
}
|
|
return payload;
|
|
}
|
|
|
|
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 refreshRemoteDraftAttachmentSources(folder, uid, uidValidity, freshAttachments = null) {
|
|
const remoteAttachments = state.composeAttachments.filter((attachment) => attachment?.source === "imap");
|
|
state.composeRemoteDraft = { folder, uid: Number(uid), uid_validity: Number(uidValidity) };
|
|
if (!remoteAttachments.length) return;
|
|
try {
|
|
let candidates = Array.isArray(freshAttachments) ? freshAttachments : null;
|
|
if (!candidates?.length) {
|
|
const fresh = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
|
|
candidates = Array.isArray(fresh.attachments) ? fresh.attachments : [];
|
|
}
|
|
const used = new Set();
|
|
for (const attachment of remoteAttachments) {
|
|
let index = candidates.findIndex((candidate, candidateIndex) =>
|
|
!used.has(candidateIndex) && String(candidate.source_content_id || "") === String(attachment.id || ""),
|
|
);
|
|
if (index < 0) {
|
|
index = candidates.findIndex((candidate, candidateIndex) =>
|
|
!used.has(candidateIndex)
|
|
&& candidate.checksum_sha256 === attachment.checksum_sha256
|
|
&& candidate.filename === attachment.filename
|
|
&& Number(candidate.size) === Number(attachment.size),
|
|
);
|
|
}
|
|
if (index < 0) continue;
|
|
used.add(index);
|
|
const candidate = candidates[index];
|
|
attachment.source_index = candidate.source_index;
|
|
attachment.source_content_id = candidate.source_content_id;
|
|
attachment.checksum_sha256 = candidate.checksum_sha256;
|
|
}
|
|
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
|
|
const id = String(node.dataset.rustboxCid || "");
|
|
const attachment = state.composeAttachments.find((item) => String(item.id || "") === id && item.source === "imap");
|
|
if (!attachment) return;
|
|
const url = composeAttachmentUrl(attachment);
|
|
if (!url) return;
|
|
if (node.tagName === "IMG") node.setAttribute("src", url);
|
|
else node.setAttribute("href", url);
|
|
});
|
|
} catch (error) {
|
|
console.warn("Could not refresh IMAP draft attachment sources", error);
|
|
}
|
|
}
|
|
|
|
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);
|
|
const draftsFolder = syncImap
|
|
? (state.composeRemoteDraft?.folder || state.specialFolders?.drafts || (typeof detectSpecialFolder === "function" ? detectSpecialFolder("drafts") : null))
|
|
: null;
|
|
const releaseRealtimeSuppression = syncImap && draftsFolder && typeof suppressRealtimeNotificationsForFolder === "function"
|
|
? suppressRealtimeNotificationsForFolder(draftsFolder)
|
|
: () => { };
|
|
try {
|
|
const result = await request("/drafts", { method: "POST", body: JSON.stringify(payload) });
|
|
state.composeDirty = false;
|
|
if (syncImap) state.composeNeedsRemoteSync = false;
|
|
if (syncImap && result?.remote_folder && result?.remote_uid && result?.remote_uid_validity) {
|
|
await refreshRemoteDraftAttachmentSources(result.remote_folder, result.remote_uid, result.remote_uid_validity, result.remote_attachments);
|
|
}
|
|
if (!syncImap && state.composeRemoteDraft) storeLocalDraftSnapshot();
|
|
else clearLocalDraftSnapshot();
|
|
updateComposeDraftDeleteAction();
|
|
const savedLabel = syncImap ? "Draft saved to IMAP" : "Draft saved";
|
|
$("#compose-status").textContent = `${trText(savedLabel)} ${new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
|
|
if (!silent) toast(savedLabel, "success");
|
|
return true;
|
|
} catch (error) {
|
|
storeLocalDraftSnapshot();
|
|
if (!silent) toast(error.message, "error");
|
|
return false;
|
|
} finally {
|
|
releaseRealtimeSuppression();
|
|
}
|
|
}
|
|
|
|
function updateComposeDraftDeleteAction() {
|
|
const button = $("#compose-delete-draft");
|
|
if (!button) return;
|
|
const canDelete = Boolean(state.composeDraftId || state.composeRemoteDraft);
|
|
button.classList.toggle("hidden", !canDelete);
|
|
button.disabled = Boolean(state.composeUploading);
|
|
}
|
|
|
|
function composeDraftDeleteUrl() {
|
|
if (!state.composeDraftId) return null;
|
|
const base = `/drafts/${encodeURIComponent(state.composeDraftId)}`;
|
|
const source = state.composeRemoteDraft;
|
|
if (!source) return base;
|
|
const params = new URLSearchParams({
|
|
imap_folder: String(source.folder || ""),
|
|
imap_uid: String(Number(source.uid) || 0),
|
|
imap_uid_validity: String(Number(source.uid_validity) || 0),
|
|
});
|
|
return `${base}?${params.toString()}`;
|
|
}
|
|
|
|
async function deleteComposeDraft() {
|
|
if (state.composeUploadController || state.composeUploading) {
|
|
toast("Wait for attachment upload to finish", "warning");
|
|
return;
|
|
}
|
|
const deleteUrl = composeDraftDeleteUrl();
|
|
if (!deleteUrl) return;
|
|
const confirmed = await appConfirm(
|
|
"Delete this draft? The IMAP draft and all locally uploaded draft attachments will be permanently removed.",
|
|
{ title: "Delete draft", confirmLabel: "Delete draft", danger: true },
|
|
);
|
|
if (!confirmed) return;
|
|
|
|
const button = $("#compose-delete-draft");
|
|
clearTimeout(state.draftSaveTimer);
|
|
clearTimeout(state.inlineImageCleanupTimer);
|
|
setBusy(button, true);
|
|
try {
|
|
await request(deleteUrl, { method: "DELETE" });
|
|
const deletedDraftId = state.composeDraftId;
|
|
const deletedRemoteDraft = state.composeRemoteDraft ? { ...state.composeRemoteDraft } : null;
|
|
clearLocalDraftSnapshot(deletedDraftId);
|
|
state.composeDraftId = null;
|
|
state.composeRemoteDraft = null;
|
|
state.composeAttachments = [];
|
|
state.inlineAttachmentIds = new Set();
|
|
state.signatureAttachmentIds = new Set();
|
|
setSelectedInlineImage(null);
|
|
state.composeReplyMeta = null;
|
|
state.composeDirty = false;
|
|
state.composeNeedsRemoteSync = false;
|
|
state.pgpRecipientStatus = [];
|
|
$("#compose-pgp-passphrase").value = "";
|
|
renderComposeAttachments();
|
|
updateComposeMessageSize();
|
|
updateComposeDraftDeleteAction();
|
|
if ($("#compose-dialog").open) $("#compose-dialog").close();
|
|
if (deletedRemoteDraft) {
|
|
const before = state.messages.length;
|
|
state.messages = state.messages.filter((message) => !(
|
|
String(message.folder || "") === String(deletedRemoteDraft.folder || "")
|
|
&& Number(message.uid) === Number(deletedRemoteDraft.uid)
|
|
));
|
|
if (state.messages.length !== before) {
|
|
state.total = Math.max(0, Number(state.total || 0) - 1);
|
|
renderMessages();
|
|
}
|
|
}
|
|
toast("Draft deleted", "success");
|
|
await loadFolders();
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
} finally {
|
|
setBusy(button, 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)));
|
|
}
|
|
|
|
async function saveComposeDraftToImap() {
|
|
const button = $("#compose-save-draft");
|
|
if (state.composeUploadController) {
|
|
toast("Wait for attachment upload to finish", "warning");
|
|
return;
|
|
}
|
|
clearTimeout(state.draftSaveTimer);
|
|
await cleanupUnusedInlineAttachments();
|
|
setBusy(button, true);
|
|
try {
|
|
await saveCurrentDraft(false, true, true);
|
|
} finally {
|
|
setBusy(button, false);
|
|
}
|
|
}
|
|
|
|
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 [];
|
|
// Treat every picker/drop entry as a separate attachment. Files may legitimately share a name and size.
|
|
const list = incoming;
|
|
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 = Array.isArray(data.attachments) ? data.attachments : [];
|
|
if (options.signature) uploaded.forEach((attachment) => trackSignatureInlineAttachment(attachment.id));
|
|
added.push(...uploaded);
|
|
state.composeAttachments = mergeComposeAttachments(state.composeAttachments, uploaded);
|
|
renderComposeAttachments();
|
|
state.composeDirty = true;
|
|
state.composeNeedsRemoteSync = true;
|
|
storeLocalDraftSnapshot();
|
|
}
|
|
await refreshComposeAttachmentsFromServer(id);
|
|
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;
|
|
updateInlineImageActions();
|
|
$("#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();
|
|
try {
|
|
const selection = window.getSelection();
|
|
const target = range?.cloneRange?.() || document.createRange();
|
|
if (!range) {
|
|
target.selectNodeContents(editor);
|
|
target.collapse(false);
|
|
}
|
|
selection.removeAllRanges();
|
|
selection.addRange(target);
|
|
} catch (_) { }
|
|
}
|
|
|
|
function positionInlineImageActions() {
|
|
const image = state.composeSelectedImage;
|
|
const actions = $("#compose-image-actions");
|
|
const editor = $("#compose-editor");
|
|
if (!image?.isConnected || !actions || actions.classList.contains("hidden") || !editor) return;
|
|
|
|
const imageRect = image.getBoundingClientRect();
|
|
const editorRect = editor.getBoundingClientRect();
|
|
if (imageRect.bottom < editorRect.top || imageRect.top > editorRect.bottom) {
|
|
actions.style.visibility = "hidden";
|
|
return;
|
|
}
|
|
actions.style.visibility = "visible";
|
|
const actionsRect = actions.getBoundingClientRect();
|
|
const gap = 8;
|
|
const viewportWidth = window.visualViewport?.width || window.innerWidth;
|
|
const viewportHeight = window.visualViewport?.height || window.innerHeight;
|
|
|
|
let left = imageRect.left;
|
|
left = Math.max(gap, Math.min(left, viewportWidth - actionsRect.width - gap));
|
|
let top = imageRect.top - actionsRect.height - gap;
|
|
if (top < Math.max(gap, editorRect.top + gap)) top = imageRect.bottom + gap;
|
|
top = Math.max(gap, Math.min(top, viewportHeight - actionsRect.height - gap));
|
|
|
|
actions.style.left = `${Math.round(left)}px`;
|
|
actions.style.top = `${Math.round(top)}px`;
|
|
}
|
|
|
|
function positionInlineImageResizeHandles() {
|
|
const image = state.composeSelectedImage;
|
|
const overlay = $("#compose-image-resize");
|
|
const editor = $("#compose-editor");
|
|
if (!overlay || !editor || !image?.isConnected || $("#compose-mode")?.value !== "html") {
|
|
overlay?.classList.add("hidden");
|
|
return;
|
|
}
|
|
const imageRect = image.getBoundingClientRect();
|
|
const editorRect = editor.getBoundingClientRect();
|
|
if (imageRect.bottom < editorRect.top || imageRect.top > editorRect.bottom || imageRect.right < editorRect.left || imageRect.left > editorRect.right) {
|
|
overlay.classList.add("hidden");
|
|
return;
|
|
}
|
|
overlay.classList.remove("hidden");
|
|
overlay.style.left = `${Math.round(imageRect.left)}px`;
|
|
overlay.style.top = `${Math.round(imageRect.top)}px`;
|
|
overlay.style.width = `${Math.max(1, Math.round(imageRect.width))}px`;
|
|
overlay.style.height = `${Math.max(1, Math.round(imageRect.height))}px`;
|
|
}
|
|
|
|
function positionInlineImageUi() {
|
|
positionInlineImageActions();
|
|
positionInlineImageResizeHandles();
|
|
}
|
|
|
|
function beginInlineImageResize(event) {
|
|
const handle = event.target.closest?.("[data-image-resize]");
|
|
const image = state.composeSelectedImage;
|
|
const editor = $("#compose-editor");
|
|
if (!handle || !image?.isConnected || !editor) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const direction = handle.dataset.imageResize || "se";
|
|
const sign = direction.includes("w") ? -1 : 1;
|
|
const startX = event.clientX;
|
|
const startWidth = image.getBoundingClientRect().width;
|
|
const editorWidth = Math.max(1, editor.clientWidth);
|
|
const alignment = inlineImageAlignment(image);
|
|
document.body.classList.add("compose-image-resizing");
|
|
|
|
const move = (moveEvent) => {
|
|
const delta = (moveEvent.clientX - startX) * sign;
|
|
const widthPx = Math.max(editorWidth * 0.10, Math.min(editorWidth, startWidth + delta));
|
|
const widthPercent = Math.max(10, Math.min(100, (widthPx / editorWidth) * 100));
|
|
applyInlineImageDisplay(image, Math.round(widthPercent * 10) / 10, alignment);
|
|
positionInlineImageUi();
|
|
};
|
|
const finish = () => {
|
|
window.removeEventListener("pointermove", move);
|
|
window.removeEventListener("pointerup", finish);
|
|
window.removeEventListener("pointercancel", finish);
|
|
document.body.classList.remove("compose-image-resizing");
|
|
scheduleDraftSave();
|
|
positionInlineImageUi();
|
|
};
|
|
window.addEventListener("pointermove", move);
|
|
window.addEventListener("pointerup", finish, { once: true });
|
|
window.addEventListener("pointercancel", finish, { once: true });
|
|
}
|
|
|
|
function moveInlineImageToRange(image, insertionRange) {
|
|
const editor = $("#compose-editor");
|
|
if (!image?.isConnected || !editor || !insertionRange) return false;
|
|
try {
|
|
const range = composeAttachmentInsertionRange(insertionRange, editor);
|
|
range.insertNode(image);
|
|
const spacer = document.createTextNode("\u00a0");
|
|
image.after(spacer);
|
|
const after = document.createRange();
|
|
after.setStartAfter(spacer);
|
|
after.collapse(true);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(after);
|
|
setSelectedInlineImage(image);
|
|
scheduleDraftSave();
|
|
requestAnimationFrame(positionInlineImageUi);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function updateInlineImageActions() {
|
|
const image = state.composeSelectedImage;
|
|
const actions = $("#compose-image-actions");
|
|
const visible = Boolean(image?.isConnected) && $("#compose-mode")?.value === "html";
|
|
actions?.classList.toggle("hidden", !visible);
|
|
const resize = $("#compose-image-resize");
|
|
resize?.classList.toggle("hidden", !visible);
|
|
if (!visible) return;
|
|
const label = actions?.querySelector(".compose-image-actions-label");
|
|
if (label) {
|
|
const attachment = state.composeAttachments.find((item) => String(item.id) === String(image.dataset.rustboxCid || ""));
|
|
label.textContent = attachment?.filename ? `Inline image · ${attachment.filename}` : "Inline image";
|
|
label.title = label.textContent;
|
|
}
|
|
const width = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : 100;
|
|
const alignment = inlineImageAlignment(image);
|
|
actions.querySelectorAll("[data-image-width]").forEach((button) => button.classList.toggle("active", Number(button.dataset.imageWidth) === Math.round(width)));
|
|
actions.querySelectorAll("[data-image-align]").forEach((button) => button.classList.toggle("active", button.dataset.imageAlign === alignment));
|
|
requestAnimationFrame(positionInlineImageUi);
|
|
}
|
|
|
|
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;
|
|
updateInlineImageActions();
|
|
}
|
|
|
|
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";
|
|
}
|
|
updateInlineImageActions();
|
|
}
|
|
|
|
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(() => { void cleanupUnusedInlineAttachments(); }, 250);
|
|
}
|
|
|
|
async function cleanupUnusedInlineAttachments() {
|
|
if (state.composeUploading) {
|
|
scheduleUnusedInlineAttachmentCleanup();
|
|
return;
|
|
}
|
|
syncComposeInlineAttachments();
|
|
const referenced = composeInlineAttachmentIds();
|
|
const staleSignatureIds = [...state.signatureAttachmentIds].filter((id) => !referenced.has(id));
|
|
for (const id of staleSignatureIds) await deleteComposeAttachment(id);
|
|
}
|
|
|
|
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");
|
|
let range = insertionRange;
|
|
for (const attachment of uploaded) {
|
|
insertComposeAttachment(attachment, range);
|
|
range = captureEditorRange();
|
|
}
|
|
syncComposeInlineAttachments();
|
|
scheduleDraftSave();
|
|
}
|
|
|
|
async function openImapDraftComposer(folder, uid) {
|
|
const draft = await request(`/messages/${encodeURIComponent(folder)}/${uid}/draft-compose`, { method: "GET" });
|
|
openCompose("new", { skipSignature: true });
|
|
state.composeDraftId = ensureDraftId();
|
|
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
|
|
state.composeAttachments = mergeComposeAttachments(draft.attachments);
|
|
state.inlineAttachmentIds = new Set();
|
|
state.signatureAttachmentIds = new Set();
|
|
setSelectedInlineImage(null);
|
|
state.composeReplyMeta = {
|
|
inReplyTo: draft.in_reply_to || null,
|
|
references: draft.references || null,
|
|
};
|
|
state.composeAction = draft.in_reply_to ? "reply" : (/^fwd:/i.test(draft.subject || "") ? "forward" : "new");
|
|
$("#compose-to").value = draft.to || "";
|
|
$("#compose-cc").value = draft.cc || "";
|
|
$("#compose-bcc").value = draft.bcc || "";
|
|
$("#compose-subject").value = draft.subject || "";
|
|
if (draft.from_identity_id && $("#compose-from")) $("#compose-from").value = String(draft.from_identity_id);
|
|
$("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal";
|
|
$("#compose-mdn").checked = Boolean(draft.request_mdn);
|
|
$("#compose-pgp-mode").value = "none";
|
|
$("#compose-pgp-inline").checked = false;
|
|
$("#compose-pgp-attach-key").checked = false;
|
|
$("#compose-pgp-attach-recipient-keys").checked = false;
|
|
$("#compose-pgp-passphrase").value = "";
|
|
const identity = state.identities.find((item) => String(item.email || "").trim().toLowerCase() === String(draft.from_address || "").trim().toLowerCase());
|
|
if (identity && $("#compose-from")) $("#compose-from").value = String(identity.id);
|
|
updateComposePgpUi();
|
|
|
|
const mode = draft.compose_format === "html" && draft.html_body ? "html" : "text";
|
|
setComposeMode(mode);
|
|
const editor = $("#compose-editor");
|
|
if (mode === "html") editor.innerHTML = hydrateComposeHtml(draft.html_body || "", state.composeAttachments, state.composeDraftId);
|
|
else editor.textContent = draft.text_body || "";
|
|
insertIdentitySignature(editor, state.composeAction, { preserveExisting: true });
|
|
renderComposeAttachments();
|
|
state.composeDirty = false;
|
|
state.composeNeedsRemoteSync = false;
|
|
clearLocalDraftSnapshot(state.composeDraftId);
|
|
updateComposeDraftDeleteAction();
|
|
$("#compose-title").textContent = trText("Draft");
|
|
$("#compose-status").textContent = trText("Loaded from IMAP Drafts");
|
|
updateComposeMessageSize();
|
|
setTimeout(() => editor.focus(), 20);
|
|
}
|
|
|
|
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
|
|
state.composeDraftId = draft.id || null;
|
|
state.composeRemoteDraft = draft.imap_source || null;
|
|
state.composeAttachments = mergeComposeAttachments(draft.attachments);
|
|
state.inlineAttachmentIds = new Set();
|
|
state.signatureAttachmentIds = new Set();
|
|
setSelectedInlineImage(null);
|
|
state.composeReplyMeta = {
|
|
inReplyTo: draft.in_reply_to || null,
|
|
references: draft.references || null,
|
|
};
|
|
state.composeAction = draft.compose_action || (draft.in_reply_to ? "reply" : (/^fwd:/i.test(draft.subject || "") ? "forward" : "new"));
|
|
$("#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 || "";
|
|
insertIdentitySignature(editor, state.composeAction, { preserveExisting: true });
|
|
renderComposeAttachments();
|
|
state.composeDirty = sourceLabel === "Browser recovery";
|
|
state.composeNeedsRemoteSync = sourceLabel === "Browser recovery";
|
|
if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot();
|
|
updateComposeDraftDeleteAction();
|
|
$("#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");
|
|
let recovered = local;
|
|
try {
|
|
const server = await request(`/drafts/${encodeURIComponent(id)}`, { method: "GET" });
|
|
recovered = {
|
|
...local,
|
|
attachments: mergeComposeAttachments(local.attachments, server.attachments),
|
|
};
|
|
} catch (_) {
|
|
recovered = { ...local, attachments: mergeComposeAttachments(local.attachments) };
|
|
}
|
|
applyRecoveredDraft(recovered, "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();
|
|
}
|
|
|
|
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) {
|
|
const attachment = state.composeAttachments.find((item) => String(item.id) === String(id));
|
|
if (!attachment) {
|
|
state.signatureAttachmentIds.delete(String(id));
|
|
return;
|
|
}
|
|
if (attachment.source === "imap") {
|
|
state.composeAttachments = state.composeAttachments.filter((item) => String(item.id) !== String(id));
|
|
state.inlineAttachmentIds.delete(String(id));
|
|
state.signatureAttachmentIds.delete(String(id));
|
|
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
|
|
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
|
|
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
|
|
});
|
|
renderComposeAttachments();
|
|
scheduleDraftSave();
|
|
return;
|
|
}
|
|
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));
|
|
state.signatureAttachmentIds.delete(String(id));
|
|
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
|
|
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
|
|
if (String(node.dataset.rustboxCid) === String(id)) node.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 setComposeFieldsCollapsed(collapsed) {
|
|
const dialog = $("#compose-dialog");
|
|
const button = $("#compose-fields-toggle");
|
|
if (!dialog || !button) return;
|
|
const isCollapsed = Boolean(collapsed);
|
|
dialog.classList.toggle("compose-fields-collapsed", isCollapsed);
|
|
button.setAttribute("aria-expanded", isCollapsed ? "false" : "true");
|
|
button.textContent = trText(isCollapsed ? "Show fields" : "Hide fields");
|
|
button.title = trText(isCollapsed ? "Show addressing fields" : "Hide addressing fields");
|
|
}
|
|
|
|
function toggleComposeFields() {
|
|
const collapse = !$("#compose-dialog").classList.contains("compose-fields-collapsed");
|
|
setComposeFieldsCollapsed(collapse);
|
|
if (collapse) $("#compose-editor")?.focus();
|
|
}
|
|
|
|
function openCompose(mode = "new", options = {}) {
|
|
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.composeRemoteDraft = null;
|
|
state.composeAttachments = [];
|
|
state.inlineAttachmentIds = new Set();
|
|
state.signatureAttachmentIds = new Set();
|
|
setSelectedInlineImage(null);
|
|
setComposeFieldsCollapsed(false);
|
|
state.composeDirty = false;
|
|
state.composeNeedsRemoteSync = false;
|
|
state.composeReplyMeta = null;
|
|
state.composeAction = mode === "reply" || mode === "reply-all" ? "reply" : mode === "forward" ? "forward" : "new";
|
|
setComposeUploadProgress(false);
|
|
renderComposeAttachments();
|
|
updateComposeDraftDeleteAction();
|
|
$("#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);
|
|
}
|
|
if (!options.skipSignature) insertIdentitySignature(editor, state.composeAction);
|
|
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;
|
|
}
|
|
|
|
// Undo Send: offer Undo during the hold window, then report the final result.
|
|
function notifyUndoSendStaged(pendingId, windowSecs) {
|
|
const seconds = Math.max(0, windowSecs);
|
|
toast("Sending\u2026", "info", seconds * 1000 || 4000, {
|
|
label: "Undo",
|
|
onClick: async () => {
|
|
try {
|
|
await request(`/messages/send/${encodeURIComponent(pendingId)}`, { method: "DELETE" });
|
|
toast("Send cancelled", "success");
|
|
} catch (error) {
|
|
toast(error?.message || "Too late to cancel", "warning");
|
|
}
|
|
},
|
|
});
|
|
setTimeout(async () => {
|
|
try {
|
|
const final = await request(`/messages/send/${encodeURIComponent(pendingId)}`);
|
|
if (final?.status === "sent") toast("Message sent", "success");
|
|
else if (final?.status === "failed") toast(`Could not send: ${final.error || "unknown error"}`, "error");
|
|
} catch (_) { /* already swept or still dispatching; nothing useful to report */ }
|
|
}, seconds * 1000 + 1500);
|
|
}
|
|
|
|
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 {
|
|
const sendResult = 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",
|
|
compose_action: state.composeAction || "new",
|
|
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),
|
|
imap_source: state.composeRemoteDraft ? { ...state.composeRemoteDraft } : null,
|
|
imap_attachments: state.composeRemoteDraft
|
|
? state.composeAttachments
|
|
.filter((attachment) => attachment?.source === "imap")
|
|
.map((attachment) => ({ id: String(attachment.id), index: Number(attachment.source_index) }))
|
|
.filter((attachment) => attachment.id && Number.isInteger(attachment.index) && attachment.index >= 0)
|
|
: [],
|
|
}),
|
|
});
|
|
$("#compose-dialog").close();
|
|
clearLocalDraftSnapshot(state.composeDraftId);
|
|
state.composeDraftId = null;
|
|
state.composeRemoteDraft = null;
|
|
state.composeAttachments = [];
|
|
state.inlineAttachmentIds = new Set();
|
|
state.signatureAttachmentIds = new Set();
|
|
setSelectedInlineImage(null);
|
|
state.composeReplyMeta = null;
|
|
state.composeDirty = false;
|
|
state.composeNeedsRemoteSync = false;
|
|
state.pgpRecipientStatus = [];
|
|
$("#compose-pgp-passphrase").value = "";
|
|
updateComposeMessageSize();
|
|
if (sendResult?.status === "pending" && sendResult.pending_send_id) {
|
|
notifyUndoSendStaged(sendResult.pending_send_id, Number(sendResult.undo_window_secs) || 0);
|
|
} else {
|
|
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); }
|
|
}
|