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

+21 -1
View File
@@ -241,8 +241,23 @@
height: 12px;
}
.message-load-actions {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px;
padding: 7px;
box-sizing: border-box;
}
.message-load-actions.hidden {
display: none;
}
.load-more {
margin: 7px;
width: 100%;
min-width: 0;
margin: 0;
}
.reader-pane {
@@ -428,6 +443,11 @@
grid-template-columns: 12px 5px;
}
.recipient-more>span {
color: var(--muted);
font-weight: 650;
}
.sender span,
.message-meta time {
color: var(--muted);
+131 -5
View File
@@ -52,6 +52,7 @@
display: grid;
grid-template-rows: auto auto auto minmax(240px, 1fr) auto auto auto auto;
overflow: hidden;
position: relative;
container-name: compose-card;
container-type: inline-size;
}
@@ -66,6 +67,18 @@
overflow: auto;
}
.compose-dialog.compose-fields-collapsed .compose-card {
grid-template-rows: auto auto minmax(240px, 1fr) auto auto auto auto;
}
.compose-dialog.compose-fields-collapsed .compose-addresses {
display: none;
}
#compose-fields-toggle {
white-space: nowrap;
}
.compose-addresses>label {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
@@ -283,17 +296,55 @@
background: var(--field);
}
.compose-attachment-kind {
flex: 0 0 auto;
padding: 1px 5px;
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
background: var(--panel-2);
font-size: 9px;
font-weight: 700;
line-height: 1.4;
}
.compose-attachment-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.compose-attachment button {
width: 20px;
height: 20px;
display: grid;
place-items: center;
padding: 0;
min-width: 20px;
height: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 4px;
border: 0;
border-radius: var(--radius-control);
background: transparent;
color: var(--muted);
}
.compose-attachment button:hover,
.compose-attachment button:focus-visible {
background: var(--hover);
color: var(--text);
}
.compose-attachment button svg {
width: 13px;
height: 13px;
}
.compose-attachment-remove {
width: 22px;
padding: 0 !important;
}
.compose-mode-control {
flex: 0 0 auto;
justify-self: start;
@@ -441,6 +492,81 @@
outline-offset: 2px;
}
.compose-image-actions {
position: fixed;
z-index: 140;
display: flex;
align-items: center;
flex-wrap: wrap;
width: max-content;
max-width: min(560px, calc(100vw - 16px));
gap: 3px;
padding: 5px;
border: 1px solid var(--line-strong);
border-radius: var(--radius-panel);
background: var(--panel);
box-shadow: 0 8px 26px var(--shadow);
}
.compose-image-actions.hidden {
display: none;
}
.compose-image-resize {
position: fixed;
z-index: 139;
pointer-events: none;
border: 1px solid var(--accent);
box-sizing: border-box;
}
.compose-image-resize.hidden {
display: none;
}
.compose-image-resize-handle {
position: absolute;
width: 11px;
height: 11px;
border: 2px solid var(--panel);
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
pointer-events: auto;
touch-action: none;
}
.compose-image-resize-handle.nw { left: -6px; top: -6px; cursor: nwse-resize; }
.compose-image-resize-handle.ne { right: -6px; top: -6px; cursor: nesw-resize; }
.compose-image-resize-handle.sw { left: -6px; bottom: -6px; cursor: nesw-resize; }
.compose-image-resize-handle.se { right: -6px; bottom: -6px; cursor: nwse-resize; }
.compose-image-resizing,
.compose-image-resizing * {
user-select: none !important;
}
.compose-image-actions-label {
max-width: 180px;
margin-right: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--muted);
font-size: 10px;
font-weight: 700;
}
.compose-image-actions .button.active {
border-color: var(--accent);
background: var(--selected);
color: var(--text);
}
.compose-image-actions .toolbar-separator {
height: 20px;
}
.image-edit-dialog {
width: min(840px, calc(100vw - 28px));
max-width: none;
+1 -1
View File
@@ -52,7 +52,7 @@
"reply_all": "Reply all",
"forward": "Forward",
"headers": "Headers",
"message_details": "Message details",
"message_details": "Details",
"move_copy": "Move / copy",
"move_to": "Move to…",
"copy_to": "Copy to…",
+11 -1
View File
@@ -599,6 +599,12 @@
"Copy address": "Kopiuj adres",
"Search messages": "Szukaj wiadomości",
"Draft saved": "Szkic zapisany",
"Save draft": "Zapisz szkic",
"Draft saved to IMAP": "Szkic zapisany na IMAP",
"Delete draft": "Usuń szkic",
"Draft deleted": "Szkic usunięty",
"Delete this draft? The IMAP draft and all locally uploaded draft attachments will be permanently removed.": "Usunąć ten szkic? Szkic z IMAP oraz wszystkie lokalnie przesłane załączniki szkicu zostaną trwale usunięte.",
"Wait for attachment upload to finish": "Poczekaj na zakończenie wysyłania załącznika",
"Uploading files…": "Wysyłanie plików…",
"Upload complete": "Wysyłanie zakończone",
"Allow editing predefined style": "Zezwól na edycję gotowego stylu",
@@ -629,7 +635,11 @@
"Discord inspired": "Inspirowany Discord",
"Slack inspired": "Inspirowany Slack",
"Reddit inspired": "Inspirowany Reddit",
"Notion inspired": "Inspirowany Notion"
"Notion inspired": "Inspirowany Notion",
"Hide fields": "Ukryj pola",
"Show fields": "Pokaż pola",
"Hide addressing fields": "Ukryj pola adresowe",
"Show addressing fields": "Pokaż pola adresowe"
},
"fixed_interface_style_note": "gotowy styl interfejsu. Włącz edycję presetu poniżej, aby przy pierwszej zmianie utworzyć jego edytowalną kopię.",
"custom_interface_style_note": "RustBox można w pełni dostosować: kolory, odstępy, panele, typografię i zaawansowane ustawienia interfejsu.",
+31 -4
View File
@@ -180,7 +180,10 @@
<button id="selection-done" class="button quiet" type="button">Done</button>
</div>
<div id="message-list" class="message-list" aria-live="polite"></div>
<button id="load-more" class="button quiet load-more hidden" type="button">Load more</button>
<div id="message-load-actions" class="message-load-actions hidden">
<button id="load-more" class="button quiet load-more" type="button">Load more</button>
<button id="load-all" class="button quiet load-more" type="button">Load all</button>
</div>
<div id="message-resizer" class="pane-resizer" role="separator" aria-orientation="vertical"
aria-label="Resize message list"></div>
</section>
@@ -221,7 +224,7 @@
<details id="message-details-menu" class="toolbar-menu">
<summary class="button quiet"><svg aria-hidden="true">
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg><span data-i18n="message_details">Message details</span></summary>
</svg><span data-i18n="message_details">Details</span></summary>
<div class="toolbar-menu-popover" role="menu">
<button id="headers-btn" type="button" role="menuitem" data-i18n="headers">Headers</button>
<button id="source-btn" type="button" role="menuitem" data-i18n="source">Source</button>
@@ -307,7 +310,8 @@
<dialog id="compose-dialog" class="dialog compose-dialog" data-i18n-scope>
<form id="compose-form" class="dialog-card compose-card" method="dialog">
<header class="dialog-header"><strong id="compose-title">New message</strong>
<div class="compose-header-actions"><button type="button" id="compose-recover" class="button quiet small"
<div class="compose-header-actions"><button type="button" id="compose-fields-toggle" class="button quiet small"
aria-expanded="true" title="Hide addressing fields">Hide fields</button><button type="button" id="compose-recover" class="button quiet small"
title="Recover a saved draft">Recover draft</button><button type="button" id="compose-fullscreen"
class="icon-button compose-fullscreen-toggle" aria-label="Toggle full screen composer" aria-pressed="false"
title="Full screen">⛶</button><button type="button" class="icon-button close-dialog"
@@ -440,6 +444,27 @@
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-file-text"></use>
</svg></button>
</div>
<div id="compose-image-actions" class="compose-image-actions hidden" role="toolbar" aria-label="Inline image actions">
<span class="compose-image-actions-label">Inline image</span>
<button type="button" class="button quiet small" data-image-width="25">25%</button>
<button type="button" class="button quiet small" data-image-width="50">50%</button>
<button type="button" class="button quiet small" data-image-width="75">75%</button>
<button type="button" class="button quiet small" data-image-width="100">100%</button>
<span class="toolbar-separator"></span>
<button type="button" class="button quiet small" data-image-align="inline">Inline</button>
<button type="button" class="button quiet small" data-image-align="left">Left</button>
<button type="button" class="button quiet small" data-image-align="center">Center</button>
<button type="button" class="button quiet small" data-image-align="right">Right</button>
<span class="toolbar-separator"></span>
<button type="button" id="compose-image-more" class="button quiet small">More…</button>
<button type="button" id="compose-image-detach" class="button quiet small">Keep as attachment</button>
</div>
<div id="compose-image-resize" class="compose-image-resize hidden" aria-hidden="true">
<span class="compose-image-resize-handle nw" data-image-resize="nw"></span>
<span class="compose-image-resize-handle ne" data-image-resize="ne"></span>
<span class="compose-image-resize-handle sw" data-image-resize="sw"></span>
<span class="compose-image-resize-handle se" data-image-resize="se"></span>
</div>
<div id="compose-editor" class="compose-editor" contenteditable="true" role="textbox" aria-multiline="true"
data-placeholder="Write a message"></div>
<div id="compose-upload-progress" class="compose-upload-progress hidden" role="status" aria-live="polite">
@@ -457,7 +482,9 @@
<span id="compose-status" class="muted"></span><span id="compose-message-size"
class="muted compose-message-size" title="Estimated final message size">~0 / __RUSTBOX_MAX_MESSAGE_MB__
MiB</span>
<div class="compose-submit-actions"><button type="button"
<div class="compose-submit-actions"><button id="compose-delete-draft" type="button"
class="button quiet danger hidden">Delete draft</button><button id="compose-save-draft" type="button"
class="button quiet">Save draft</button><button type="button"
class="button quiet close-dialog">Cancel</button><button id="send-btn" type="submit"
class="button primary"><svg>
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-send"></use>
+1
View File
@@ -75,6 +75,7 @@
imapSyncInventoryPending: 0,
calendarReminderTimer: null,
composeDraftId: null,
composeRemoteDraft: null,
composeAttachments: [],
composeDirty: false,
composeNeedsRemoteSync: false,
+88 -24
View File
@@ -65,6 +65,31 @@ async function loadMessages(reset = false, options = {}) {
}
}
async function loadAllMessages() {
if (state.searchContext || state.messages.length >= state.total) return;
const loadAll = $("#load-all");
const loadMore = $("#load-more");
setBusy(loadAll, true);
if (loadMore) loadMore.disabled = true;
try {
let guard = 0;
while (state.messages.length < state.total && guard < 10000) {
const before = state.messages.length;
state.page += 1;
await loadMessages(false, { background: true });
guard += 1;
if (state.messages.length <= before) {
state.page = Math.max(0, state.page - 1);
break;
}
}
saveMessageListState();
} finally {
setBusy(loadAll, false);
if (loadMore) loadMore.disabled = false;
}
}
function normalizedMessageFlags(value) {
const source = Array.isArray(value) ? value : String(value || "").split(/[,\s]+/).filter(Boolean);
return [...new Set(source.map((flag) => String(flag).trim()).filter(Boolean))].sort();
@@ -276,7 +301,8 @@ function renderMessages() {
row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); });
list.append(row);
}
$("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total);
const canLoadMore = !state.searchContext && state.messages.length < state.total;
$("#message-load-actions")?.classList.toggle("hidden", !canLoadMore);
renderBulkToolbar();
}
@@ -387,7 +413,26 @@ async function openMessageTarget(accountId, folder, uid, fullscreen = false) {
if (fullscreen && state.currentDetail) setReaderFullscreen(true, { pushHistory: false });
}
function messageLooksLikeImapDraft(folder, uid) {
if (state.specialFolders?.drafts && state.specialFolders.drafts === folder) return true;
const folderMeta = state.folders.find((item) => item.name === folder);
const attrs = Array.isArray(folderMeta?.attributes) ? folderMeta.attributes : String(folderMeta?.attributes || "").split(/\s+/);
if (attrs.some((value) => String(value).toLowerCase() === "\\drafts")) return true;
const message = state.messages.find((item) => item.folder === folder && Number(item.uid) === Number(uid))
|| state.messages.find((item) => Number(item.uid) === Number(uid));
const flags = Array.isArray(message?.flags) ? message.flags : String(message?.flags || "").split(",");
return flags.some((value) => String(value).trim().toLowerCase() === "\\draft");
}
async function openMessage(folder, uid) {
if (messageLooksLikeImapDraft(folder, uid)) {
try {
await openImapDraftComposer(folder, uid);
return;
} catch (error) {
toast(error.message || "Could not open IMAP draft", "error");
}
}
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
state.selected = { folder, uid };
@@ -460,40 +505,59 @@ function addressLabel(entry) {
return entry.name ? `${entry.name} <${entry.address}>` : entry.address;
}
function readerRecipientDisplayLimit(host, total) {
if (total <= 3) return total;
const width = host?.closest(".sender")?.getBoundingClientRect().width || host?.clientWidth || 0;
if (width >= 560) return 3;
if (width >= 320) return 2;
return 1;
}
function appendReaderRecipientRow(host, prefix, entry) {
const row = el("div", "recipient-line");
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
const add = el("button", "address-contact-button");
add.type = "button";
add.title = `Add ${entry.address} to contacts`;
add.setAttribute("aria-label", add.title);
add.innerHTML = svgIcon("user-round");
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
row.append(add);
host.append(row);
}
function renderReaderRecipients(detail) {
const host = $("#reader-to");
host.replaceChildren();
const groups = [
["to", detail.to_addresses || []],
["cc", detail.cc_addresses || []],
];
let added = 0;
for (const [prefix, entries] of groups) {
for (const entry of entries) {
if (!entry?.address) continue;
const row = el("div", "recipient-line");
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
const add = el("button", "address-contact-button");
add.type = "button";
add.title = `Add ${entry.address} to contacts`;
add.setAttribute("aria-label", add.title);
add.innerHTML = svgIcon("user-round");
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
row.append(add);
host.append(row);
added += 1;
}
const recipients = [];
for (const [prefix, entries] of [["to", detail.to_addresses || []], ["cc", detail.cc_addresses || []]]) {
for (const entry of entries) if (entry?.address) recipients.push({ prefix, entry });
}
if (!recipients.length) {
host.append(el("div", "recipient-line", "to me"));
return;
}
const expanded = host.getAttribute("aria-expanded") === "true";
const limit = expanded ? recipients.length : readerRecipientDisplayLimit(host, recipients.length);
recipients.slice(0, limit).forEach(({ prefix, entry }) => appendReaderRecipientRow(host, prefix, entry));
const hidden = recipients.length - limit;
if (hidden > 0) {
const more = el("div", "recipient-line recipient-more");
more.append(el("span", "", `+${hidden}`));
host.append(more);
host.title = tr("show_full_addresses", "Show full addresses");
}
if (!added) host.append(el("div", "recipient-line", "to me"));
}
function setReaderAddressExpansion(expanded) {
function setReaderAddressExpansion(expanded, rerender = true) {
const host = $("#reader-to");
const meta = host?.closest(".message-meta");
if (!host || !meta) return;
meta.classList.toggle("addresses-expanded", Boolean(expanded));
host.setAttribute("aria-expanded", String(Boolean(expanded)));
host.title = expanded ? tr("collapse_addresses", "Collapse addresses") : tr("show_full_addresses", "Show full addresses");
if (rerender && state.currentDetail) renderReaderRecipients(state.currentDetail);
}
function toggleReaderAddressExpansion() {
@@ -508,7 +572,7 @@ function renderMessageDetail(detail) {
$("#reader-from").tabIndex = detail.from_address ? 0 : -1;
if (detail.from_address) $("#reader-from").setAttribute("role", "button");
else $("#reader-from").removeAttribute("role");
setReaderAddressExpansion(false);
setReaderAddressExpansion(false, false);
renderReaderRecipients(detail);
$("#reader-add-sender-contact").classList.toggle("hidden", !detail.from_address);
$("#reader-date").textContent = formatDate(detail.date, true);
+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);
+13
View File
@@ -239,8 +239,21 @@ function suppressRealtimeNotificationsForFolder(folder, accountId = state.accoun
};
}
function realtimeFolderIsDrafts(folderName) {
const name = String(folderName || "");
if (!name) return false;
const configured = state.specialFolders?.drafts;
if (configured && name.toLowerCase() === String(configured).toLowerCase()) return true;
const meta = state.folders.find((folder) => String(folder?.name || "").toLowerCase() === name.toLowerCase());
const attrs = Array.isArray(meta?.attributes) ? meta.attributes : String(meta?.attributes || "").split(/\s+/);
if (attrs.some((value) => String(value).trim().toLowerCase() === "\\drafts")) return true;
const leaf = name.split(/[\/.]/).filter(Boolean).at(-1)?.toLowerCase();
return leaf === "draft" || leaf === "drafts";
}
function realtimeNewMailNotificationsSuppressed(data, accountId) {
if (!state.realtimeNotificationsReady) return true;
if (realtimeFolderIsDrafts(data?.folder)) return true;
return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0;
}
+73 -7
View File
@@ -132,11 +132,49 @@ function bindEvents() {
$("#logout-btn").addEventListener("click", logout);
$("#compose-btn").addEventListener("click", () => openCompose("new"));
$("#compose-fullscreen").addEventListener("click", toggleComposeFullscreen);
$("#compose-fields-toggle").addEventListener("click", toggleComposeFields);
$("#compose-to").addEventListener("invalid", () => setComposeFieldsCollapsed(false));
$("#compose-form").addEventListener("submit", sendMessage);
$("#compose-attach").addEventListener("click", () => $("#compose-files").click());
$("#compose-files").addEventListener("change", async (event) => { await uploadComposeFiles(event.target.files); event.target.value = ""; });
$("#compose-delete-draft").addEventListener("click", deleteComposeDraft);
$("#compose-save-draft").addEventListener("click", saveComposeDraftToImap);
$("#compose-attach").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-files").click(); });
$("#compose-files").addEventListener("change", async (event) => {
const files = [...event.target.files];
const htmlMode = $("#compose-mode").value === "html";
const images = htmlMode ? files.filter((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : [];
const attachments = htmlMode ? files.filter((file) => !/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : files;
if (images.length) await uploadInlineImage(images, state.composeInsertRange);
if (attachments.length) await uploadComposeFiles(attachments);
state.composeInsertRange = null;
event.target.value = "";
});
$("#editor-image").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-images").click(); });
$("#editor-image-edit").addEventListener("click", () => openInlineImageEditor());
$("#compose-image-actions").addEventListener("click", (event) => {
const image = state.composeSelectedImage;
if (!image) return;
const widthButton = event.target.closest("[data-image-width]");
const alignButton = event.target.closest("[data-image-align]");
if (widthButton) {
applyInlineImageDisplay(image, Number(widthButton.dataset.imageWidth), inlineImageAlignment(image));
scheduleDraftSave();
return;
}
if (alignButton) {
const currentWidth = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : 100;
applyInlineImageDisplay(image, currentWidth, alignButton.dataset.imageAlign);
scheduleDraftSave();
}
});
$("#compose-image-more").addEventListener("click", () => openInlineImageEditor());
$("#compose-image-detach").addEventListener("click", () => {
const image = state.composeSelectedImage;
if (!image) return;
image.remove();
setSelectedInlineImage(null);
syncComposeInlineAttachments();
scheduleDraftSave();
});
$("#compose-images").addEventListener("change", async (event) => { await uploadInlineImage(event.target.files, state.composeInsertRange); state.composeInsertRange = null; event.target.value = ""; });
$("#image-edit-form").addEventListener("submit", applyInlineImageEditor);
$("#image-edit-reset").addEventListener("click", resetImageCropControls);
@@ -173,6 +211,9 @@ function bindEvents() {
updateComposeMessageSize();
}));
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); updateComposeMessageSize(); });
$("#compose-editor").addEventListener("scroll", positionInlineImageUi, { passive: true });
window.addEventListener("resize", positionInlineImageUi, { passive: true });
$("#compose-image-resize").addEventListener("pointerdown", beginInlineImageResize);
$("#compose-editor").addEventListener("click", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
setSelectedInlineImage(image || null);
@@ -192,14 +233,38 @@ function bindEvents() {
const range = captureEditorRange();
await uploadInlineImage(images, range);
});
$("#compose-editor").addEventListener("dragover", (event) => { if ([...event.dataTransfer.types].includes("Files")) event.preventDefault(); });
$("#compose-editor").addEventListener("dragstart", (event) => {
const image = event.target.closest?.("img[data-rustbox-cid]");
if (!image || !event.dataTransfer) return;
setSelectedInlineImage(image);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("application/x-rustbox-inline-image", image.dataset.rustboxCid || "");
});
$("#compose-editor").addEventListener("dragover", (event) => {
const types = [...(event.dataTransfer?.types || [])];
if (types.includes("Files") || types.includes("application/x-rustbox-inline-image")) {
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = types.includes("Files") ? "copy" : "move";
}
});
$("#compose-editor").addEventListener("drop", async (event) => {
const files = [...event.dataTransfer.files];
const inlineCid = event.dataTransfer?.getData("application/x-rustbox-inline-image") || "";
const range = editorRangeFromPoint(event.clientX, event.clientY);
if (inlineCid) {
event.preventDefault();
const image = [...$("#compose-editor").querySelectorAll("img[data-rustbox-cid]")]
.find((candidate) => String(candidate.dataset.rustboxCid || "") === inlineCid);
if (image && range) moveInlineImageToRange(image, range);
return;
}
const files = [...(event.dataTransfer?.files || [])];
if (!files.length) return;
event.preventDefault();
const allImages = files.every((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type));
if ($("#compose-mode").value === "html" && allImages) await uploadInlineImage(files, editorRangeFromPoint(event.clientX, event.clientY));
else await uploadComposeFiles(files);
const htmlMode = $("#compose-mode").value === "html";
const images = htmlMode ? files.filter((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : [];
const attachments = htmlMode ? files.filter((file) => !/^image\/(png|jpeg|gif|webp)$/i.test(file.type)) : files;
if (images.length) await uploadInlineImage(images, range);
if (attachments.length) await uploadComposeFiles(attachments);
});
$("#reply-btn").addEventListener("click", () => openCompose("reply"));
$("#reply-all-btn").addEventListener("click", () => openCompose("reply-all"));
@@ -259,6 +324,7 @@ function bindEvents() {
} catch (error) { toast(error.message, "error"); }
});
$("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); });
$("#load-all").addEventListener("click", loadAllMessages);
$("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive: true });
window.addEventListener("offline", () => setConnectivityState(false));
window.addEventListener("online", async () => {