v0.2.12
RustBox CI / rustbox (push) Canceled after 0s

This commit is contained in:
Mateusz Gruszczyński committed 2026-11-02 13:54:18 +01:00
1 parent 5dc9db0b1c
commit 83440f20e2
28 files changed
+1925 -213

No files matched your search

+667 -65
View File
@@ -1,6 +1,25 @@
function parseRecipients(raw) {
return raw.split(",").map((value) => value.trim()).filter(Boolean);
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) {
@@ -137,20 +156,86 @@ function updateComposeMessageSize(extraFiles = []) {
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 visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)));
box.classList.toggle("hidden", !visibleAttachments.length);
for (const attachment of visibleAttachments) {
const attachments = mergeComposeAttachments(state.composeAttachments);
state.composeAttachments = attachments;
box.classList.toggle("hidden", !attachments.length);
for (const attachment of attachments) {
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.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
const remove = el("button", "");
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 ${attachment.filename}`);
remove.setAttribute("aria-label", `Remove ${filename}`);
remove.addEventListener("click", () => deleteComposeAttachment(attachment.id));
item.append(remove);
item.append(embed, remove);
box.append(item);
}
if ($("#compose-pgp-mode")) updateComposePgpUi();
@@ -173,6 +258,120 @@ function composeAttachmentContentUrl(draftId, attachmentId) {
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 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 = [];
@@ -277,9 +476,16 @@ function serializeComposeHtml(editor = $("#compose-editor")) {
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;
}
@@ -290,24 +496,51 @@ function hydrateComposeHtml(html, attachments = state.composeAttachments, draftI
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));
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, "");
if (!ids.has(cid)) return;
state.inlineAttachmentIds.add(cid);
image.dataset.rustboxCid = cid;
image.setAttribute("src", composeAttachmentContentUrl(draftId, cid));
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);
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");
return {
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,
@@ -324,6 +557,14 @@ function composeDraftPayload() {
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() {
@@ -363,24 +604,159 @@ function clearLocalDraftSnapshot(draftId = state.composeDraftId) {
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 {
await request("/drafts", { method: "POST", body: JSON.stringify(payload) });
const result = 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");
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();
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);
}
}
@@ -392,6 +768,22 @@ function scheduleDraftSave() {
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");
@@ -407,14 +799,8 @@ 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;
});
// 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)));
@@ -447,14 +833,16 @@ async function uploadComposeFiles(files, options = {}) {
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 || [];
const uploaded = Array.isArray(data.attachments) ? data.attachments : [];
added.push(...uploaded);
state.composeAttachments.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;
@@ -489,6 +877,7 @@ function setComposeMode(mode, persist = false) {
$$(".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);
@@ -632,14 +1021,151 @@ function captureEditorRange(editor = $("#compose-editor")) {
function restoreEditorRange(range, editor = $("#compose-editor")) {
editor.focus();
if (!range) return;
try {
const selection = window.getSelection();
const target = range?.cloneRange?.() || document.createRange();
if (!range) {
target.selectNodeContents(editor);
target.collapse(false);
}
selection.removeAllRanges();
selection.addRange(range);
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");
@@ -648,6 +1174,7 @@ function setSelectedInlineImage(image) {
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) {
@@ -688,6 +1215,7 @@ function applyInlineImageDisplay(image, widthPercent, alignment) {
image.style.marginLeft = "0";
image.style.marginRight = "0";
}
updateInlineImageActions();
}
function imageCropValues() {
@@ -843,26 +1371,11 @@ async function applyInlineImageEditor(event) {
function scheduleUnusedInlineAttachmentCleanup() {
clearTimeout(state.inlineImageCleanupTimer);
state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500);
state.inlineImageCleanupTimer = setTimeout(syncComposeInlineAttachments, 250);
}
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();
syncComposeInlineAttachments();
}
function editorRangeFromPoint(x, y) {
@@ -890,28 +1403,65 @@ async function uploadInlineImage(files, insertionRange = null) {
const uploaded = await uploadComposeFiles(images);
if (!uploaded.length) return;
setComposeMode("html");
const editor = $("#compose-editor");
restoreEditorRange(insertionRange, editor);
const draftId = ensureDraftId();
let lastInsertedId = null;
let range = insertionRange;
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);
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");
state.composeSignatureGeneration += 1;
state.composeDraftId = ensureDraftId();
state.composeRemoteDraft = draft.imap_source || { folder, uid, uid_validity: 0 };
state.composeAttachments = mergeComposeAttachments(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 || "";
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 || "";
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.composeSignatureGeneration += 1;
state.composeDraftId = draft.id || null;
state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : [];
state.composeRemoteDraft = draft.imap_source || null;
state.composeAttachments = mergeComposeAttachments(draft.attachments);
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
state.composeReplyMeta = {
@@ -939,6 +1489,7 @@ function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
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);
@@ -974,7 +1525,17 @@ async function recoverDraft() {
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");
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;
}
@@ -1010,14 +1571,27 @@ function attachmentReminderNeeded() {
}
async function deleteComposeAttachment(id) {
const attachment = state.composeAttachments.find((item) => String(item.id) === String(id));
if (!attachment) return;
if (attachment.source === "imap") {
state.composeAttachments = state.composeAttachments.filter((item) => String(item.id) !== String(id));
state.inlineAttachmentIds.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));
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();
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid], a[data-rustbox-cid]").forEach((node) => {
if (String(node.dataset.rustboxCid) === String(id)) node.remove();
});
renderComposeAttachments();
scheduleDraftSave();
@@ -1058,6 +1632,23 @@ 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") {
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
void forwardAsAttachment();
@@ -1069,15 +1660,18 @@ function openCompose(mode = "new") {
renderComposeIdentities();
if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail);
state.composeDraftId = null;
state.composeRemoteDraft = null;
state.composeAttachments = [];
state.composeSignatureGeneration += 1;
state.inlineAttachmentIds = new Set();
setSelectedInlineImage(null);
setComposeFieldsCollapsed(false);
state.composeDirty = false;
state.composeNeedsRemoteSync = false;
state.composeReplyMeta = null;
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");
@@ -1202,11 +1796,19 @@ async function sendMessage(event) {
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();
setSelectedInlineImage(null);