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

This commit is contained in:
Mateusz Gruszczyński committed 2026-10-30 12:06:44 +01:00
1 parent 96a32ea8fb
commit a643e7e3ab
106 files changed
+2223 -4669

No files matched your search

+15 -3
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 00-bootstrap.js */
(() => {
"use strict";
@@ -50,13 +50,23 @@
markReadTimer: null,
ws: null,
wsRetryTimer: null,
wsRetryAttempt: 0,
wsWatchdogTimer: null,
wsLastHeartbeatAt: 0,
wsShouldConnect: false,
wsConnectedOnce: false,
realtimeRefreshTimer: null,
realtimeRefreshEvents: [],
realtimePollTimer: null,
realtimeSyncPromise: null,
realtimeSeenEvents: new Map(),
calendarReminderTimer: null,
composeDraftId: null,
composeAttachments: [],
composeDirty: false,
composeNeedsRemoteSync: false,
composeUploading: false,
composeUploadController: null,
composeReplyMeta: null,
composeInsertRange: null,
composeSelectedImage: null,
@@ -79,6 +89,8 @@
quotaWarningLevel: null,
networkOnline: navigator.onLine,
listStateTimer: null,
readerFullscreenHistory: false,
readerScrollTopBeforeFullscreen: 0,
};
const $ = (selector, parent = document) => parent.querySelector(selector);
@@ -105,8 +117,8 @@
function stripRustBoxUiLeak(value) {
let text = String(value ?? "");
// Strip only RustBox's own embedded SVG sprite fragments if malformed MIME
// content captured adjacent UI markup. Legitimate SVG/code in mail remains.
text = text.replace(/<svg\b[^>]*>\s*<use\b[^>]*(?:\/assets\/icons\.svg|assets\/icons\.svg)[^>]*><\/use>\s*<\/svg>/gi, "");
text = text.replace(/&lt;svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?&lt;\/svg&gt;/gi, "");
return text;
+19 -72
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 10-core.js */
function setConnectivityState(online, detail = "") {
state.networkOnline = Boolean(online);
document.body.classList.toggle("network-offline", !state.networkOnline);
@@ -124,9 +124,17 @@ async function request(path, options = {}) {
throw error;
}
function uploadRequest(path, formData, onProgress = null) {
function uploadRequest(path, formData, onProgress = null, signal = null) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
let settled = false;
const finish = (callback, value) => {
if (settled) return;
settled = true;
signal?.removeEventListener("abort", abortUpload);
callback(value);
};
const abortUpload = () => xhr.abort();
xhr.open("POST", apiPath(path), true);
xhr.withCredentials = true;
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
@@ -136,8 +144,8 @@ function uploadRequest(path, formData, onProgress = null) {
if (event.lengthComputable && event.total > 0) onProgress(Math.round((event.loaded / event.total) * 100));
});
}
xhr.addEventListener("error", () => reject(new Error("Upload failed: network error")));
xhr.addEventListener("abort", () => reject(new Error("Upload cancelled")));
xhr.addEventListener("error", () => finish(reject, new Error("Upload failed: network error")));
xhr.addEventListener("abort", () => finish(reject, new DOMException("Upload cancelled", "AbortError")));
xhr.addEventListener("load", () => {
const contentType = xhr.getResponseHeader("content-type") || "";
let payload = xhr.responseText || "";
@@ -148,11 +156,16 @@ function uploadRequest(path, formData, onProgress = null) {
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
const error = new Error(message);
error.status = xhr.status;
reject(error);
finish(reject, error);
return;
}
resolve(payload);
finish(resolve, payload);
});
if (signal?.aborted) {
finish(reject, new DOMException("Upload cancelled", "AbortError"));
return;
}
signal?.addEventListener("abort", abortUpload, { once: true });
xhr.send(formData);
});
}
@@ -251,72 +264,6 @@ function toast(message, kind = "info", duration = null, action = null) {
resume();
}
function progressToast(message) {
const node = el("div", "toast toast-info toast-progress");
node.setAttribute("role", "status");
const icon = el("span", "toast-icon");
icon.innerHTML = svgIcon("paperclip");
const content = el("span", "toast-progress-content");
const text = el("span", "toast-message", message);
const track = el("span", "toast-progress-track");
const bar = el("span", "toast-progress-bar");
track.append(bar);
content.append(text, track);
const value = el("span", "toast-progress-value", "0%");
const close = el("button", "toast-close");
close.type = "button";
close.setAttribute("aria-label", "Dismiss notification");
close.innerHTML = svgIcon("x");
let stack = $("#toast-stack");
const openDialogs = $$("dialog[open]");
if (openDialogs.length) {
const dialog = openDialogs[openDialogs.length - 1];
stack = dialog.querySelector(":scope > .modal-toast-stack");
if (!stack) {
stack = el("div", "toast-stack modal-toast-stack");
stack.setAttribute("aria-live", "polite");
dialog.append(stack);
}
}
node.append(icon, content, value, close);
stack.append(node);
requestAnimationFrame(() => node.classList.add("visible"));
let dismissed = false;
const dismiss = (delay = 0) => {
if (dismissed) return;
dismissed = true;
setTimeout(() => {
node.classList.remove("visible");
setTimeout(() => node.remove(), 160);
}, Math.max(0, delay));
};
close.addEventListener("click", () => dismiss());
return {
update(percent, label = null) {
const normalized = Math.max(0, Math.min(100, Math.round(Number(percent) || 0)));
bar.style.width = `${normalized}%`;
value.textContent = `${normalized}%`;
if (label) text.textContent = label;
},
complete(label = trText("Upload complete")) {
bar.style.width = "100%";
value.textContent = "100%";
text.textContent = label;
node.classList.remove("toast-info");
node.classList.add("toast-success");
icon.innerHTML = svgIcon("check-circle");
dismiss(1200);
},
fail(label) {
text.textContent = label || trText("Upload failed");
node.classList.remove("toast-info");
node.classList.add("toast-error");
icon.innerHTML = svgIcon("circle-alert");
dismiss(4800);
},
dismiss,
};
}
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
return new Promise((resolve) => {
+1 -1
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 20-preferences-auth.js */
function resolveTheme(theme) {
if (theme !== "system") return theme;
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
+437 -437
View File
@@ -1,453 +1,453 @@
/* RustBox UI module: 30-folders-tags.js */
function folderIconName(folder) {
const attrs = (folder.attributes || []).join(" ").toLowerCase();
const name = String(folder.name || "").toLowerCase();
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
return "folder";
}
function orderedFolders() {
return [...state.folders].sort((a, b) => {
if (a.name.toUpperCase() === "INBOX") return -1;
if (b.name.toUpperCase() === "INBOX") return 1;
return a.name.localeCompare(b.name);
function folderIconName(folder) {
const attrs = (folder.attributes || []).join(" ").toLowerCase();
const name = String(folder.name || "").toLowerCase();
if (name === "inbox" || attrs.includes("\\inbox")) return "inbox";
if (attrs.includes("\\sent") || name.includes("sent")) return "send";
if (attrs.includes("\\drafts") || name.includes("draft")) return "file-text";
if (attrs.includes("\\archive") || name.includes("archive")) return "archive";
if (attrs.includes("\\trash") || name.includes("trash") || name.includes("deleted")) return "trash";
if (attrs.includes("\\junk") || name.includes("spam") || name.includes("junk")) return "shield-alert";
return "folder";
}
function orderedFolders() {
return [...state.folders].sort((a, b) => {
if (a.name.toUpperCase() === "INBOX") return -1;
if (b.name.toUpperCase() === "INBOX") return 1;
return a.name.localeCompare(b.name);
});
}
function folderDepth(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return 0;
return Math.min(8, folder.name.split(delimiter).length - 1);
}
function folderLeafName(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
}
function folderHasChildren(folder, folders = state.folders) {
const delimiter = folder.delimiter || "/";
if (!delimiter) return false;
const prefix = `${folder.name}${delimiter}`;
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
}
function folderAncestors(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return [];
const parts = folder.name.split(delimiter);
const result = [];
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
return result;
}
function collapsedFolderStorageKey() {
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
}
function loadCollapsedFolders() {
try {
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
} catch (_) {
state.collapsedFolders = new Set();
}
}
function saveCollapsedFolders() {
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
}
function folderIsHiddenByCollapsedAncestor(folder) {
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
}
function toggleFolderBranch(folder) {
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
else state.collapsedFolders.add(folder.name);
saveCollapsedFolders();
renderFolders();
}
function setFolderDrawerOpen(open) {
const shouldOpen = Boolean(open) && innerWidth <= 820;
$("#folder-pane")?.classList.toggle("open", shouldOpen);
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
}
function toggleFolderDrawer() {
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
}
function renderFolders() {
const list = $("#folder-list");
list.replaceChildren();
const folders = orderedFolders();
for (const folder of folders) {
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
const row = el("div", "folder-row");
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
const hasChildren = folderHasChildren(folder, folders);
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
expander.type = "button";
expander.tabIndex = hasChildren ? 0 : -1;
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
if (hasChildren) {
expander.innerHTML = svgIcon("chevron-right");
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
}
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
button.type = "button";
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
button.addEventListener("dragover", (event) => {
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
button.classList.add("drop-target");
});
}
function folderDepth(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return 0;
return Math.min(8, folder.name.split(delimiter).length - 1);
}
function folderLeafName(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return folder.name;
return folder.name.split(delimiter).filter(Boolean).at(-1) || folder.name;
}
function folderHasChildren(folder, folders = state.folders) {
const delimiter = folder.delimiter || "/";
if (!delimiter) return false;
const prefix = `${folder.name}${delimiter}`;
return folders.some((candidate) => candidate.name !== folder.name && candidate.name.startsWith(prefix));
}
function folderAncestors(folder) {
const delimiter = folder.delimiter || "/";
if (!delimiter || !folder.name.includes(delimiter)) return [];
const parts = folder.name.split(delimiter);
const result = [];
for (let index = 1; index < parts.length; index += 1) result.push(parts.slice(0, index).join(delimiter));
return result;
}
function collapsedFolderStorageKey() {
return `rustbox.collapsedFolders.${state.accountId || "guest"}`;
}
function loadCollapsedFolders() {
try {
const raw = JSON.parse(localStorage.getItem(collapsedFolderStorageKey()) || "[]");
state.collapsedFolders = new Set(Array.isArray(raw) ? raw.filter((value) => typeof value === "string") : []);
} catch (_) {
state.collapsedFolders = new Set();
}
}
function saveCollapsedFolders() {
localStorage.setItem(collapsedFolderStorageKey(), JSON.stringify([...state.collapsedFolders]));
}
function folderIsHiddenByCollapsedAncestor(folder) {
return folderAncestors(folder).some((name) => state.collapsedFolders.has(name));
}
function toggleFolderBranch(folder) {
if (state.collapsedFolders.has(folder.name)) state.collapsedFolders.delete(folder.name);
else state.collapsedFolders.add(folder.name);
saveCollapsedFolders();
renderFolders();
}
function setFolderDrawerOpen(open) {
const shouldOpen = Boolean(open) && innerWidth <= 820;
$("#folder-pane")?.classList.toggle("open", shouldOpen);
$("#folder-drawer-scrim")?.classList.toggle("open", shouldOpen);
$("#mobile-folders")?.setAttribute("aria-expanded", String(shouldOpen));
}
function toggleFolderDrawer() {
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
}
function renderFolders() {
const list = $("#folder-list");
list.replaceChildren();
const folders = orderedFolders();
for (const folder of folders) {
if (folderIsHiddenByCollapsedAncestor(folder)) continue;
const row = el("div", "folder-row");
row.style.setProperty("--folder-depth", String(folderDepth(folder)));
const hasChildren = folderHasChildren(folder, folders);
const expander = el("button", `folder-expander ${hasChildren ? "" : "placeholder"}`);
expander.type = "button";
expander.tabIndex = hasChildren ? 0 : -1;
expander.setAttribute("aria-label", state.collapsedFolders.has(folder.name) ? `Expand ${folder.name}` : `Collapse ${folder.name}`);
expander.setAttribute("aria-expanded", String(hasChildren && !state.collapsedFolders.has(folder.name)));
if (hasChildren) {
expander.innerHTML = svgIcon("chevron-right");
expander.classList.toggle("expanded", !state.collapsedFolders.has(folder.name));
expander.addEventListener("click", (event) => { event.stopPropagation(); toggleFolderBranch(folder); });
}
const button = el("button", `folder-button ${folder.name === state.folder ? "active" : ""}`);
button.type = "button";
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${folder.unread_count ? "unread" : ""}">${folder.unread_count || folder.total_count || ""}</span>`;
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openFolderContextMenu(event.clientX, event.clientY, folder); });
button.addEventListener("dragover", (event) => {
if (!event.dataTransfer?.types?.includes("application/x-rustbox-message")) return;
event.preventDefault();
event.dataTransfer.dropEffect = "move";
button.classList.add("drop-target");
});
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
button.addEventListener("drop", async (event) => {
event.preventDefault();
button.classList.remove("drop-target");
try {
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
} catch (error) { toast(error.message || "Could not move message", "error"); }
});
button.addEventListener("click", async () => {
saveMessageListState();
state.folder = folder.name;
state.selected = null;
state.selectedMessages.clear();
clearActiveSearchContext();
state.currentDetail = null;
renderFolders();
closeReader();
setFolderDrawerOpen(false);
await loadMessages(true, { restoreState: true });
});
row.append(expander, button);
list.append(row);
}
renderMoveOptions();
}
function updateUnreadAppBadge() {
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
try {
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
} catch (_) { }
}
async function loadFolders(restoreView = false) {
$("#connection-label").textContent = "Connecting";
try {
const data = await request("/folders", { method: "GET" });
state.folders = data.folders || [];
updateUnreadAppBadge();
loadCollapsedFolders();
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
if (restoreView) {
const remembered = localStorage.getItem(lastFolderStorageKey());
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
}
if (!state.folders.some((folder) => folder.name === state.folder)) {
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
}
renderFolders();
$("#connection-label").textContent = "Connected";
if (restoreView) {
const restoredSearch = readActiveSearchContext();
if (restoredSearch) {
state.searchContext = restoredSearch;
const restoredParams = new URLSearchParams(restoredSearch.params);
$("#search-input").value = restoredParams.get("q") || "";
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
return;
}
}
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true, { restoreState: restoreView });
} catch (error) {
$("#connection-label").textContent = "Connection error";
if (error.status === 401) return showLogin();
toast(error.message, "error");
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
function renderMoveOptions() {
const selects = [
["#move-select", "Move to…"],
["#copy-select", "Copy to…"],
["#bulk-move-select", "Move to…"],
["#bulk-copy-select", "Copy to…"],
];
for (const [selector, placeholder] of selects) {
const select = $(selector);
if (!select) continue;
select.replaceChildren(new Option(placeholder, ""));
for (const folder of orderedFolders()) {
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
select.add(new Option(folder.name, folder.name));
}
}
}
function messageKey(message) {
return `${message.folder || state.folder}\u0000${message.uid}`;
}
function activeSearchHighlightTerms() {
if (!state.searchContext?.params) return [];
const params = new URLSearchParams(state.searchContext.params);
const terms = [];
for (const key of ["from", "to", "subject"]) {
const value = (params.get(key) || "").trim();
if (value) terms.push(value);
}
const query = (params.get("q") || "").trim();
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
for (const token of tokens) {
const cleaned = token.replace(/^"|"$/g, "");
const colon = cleaned.indexOf(":");
if (colon > 0) {
const op = cleaned.slice(0, colon).toLowerCase();
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
} else if (cleaned.length >= 2) {
terms.push(cleaned);
}
}
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
.sort((a, b) => b.length - a.length)
.slice(0, 12);
}
function renderHighlightedText(node, value, terms) {
const text = String(value || "");
node.replaceChildren();
if (!terms.length || !text) {
node.textContent = text;
return;
}
const lower = text.toLocaleLowerCase();
let offset = 0;
while (offset < text.length) {
let matchIndex = -1;
let matchTerm = "";
for (const term of terms) {
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
matchIndex = index;
matchTerm = term;
}
}
if (matchIndex < 0) {
node.append(document.createTextNode(text.slice(offset)));
break;
}
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
node.append(mark);
offset = matchIndex + matchTerm.length;
}
}
function selectedMessages() {
const source = state.searchAllResults || state.messages;
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
}
function renderBulkToolbar() {
const selected = selectedMessages();
const toolbar = $("#bulk-toolbar");
toolbar.classList.toggle("hidden", !state.selectionMode);
$("#bulk-count").textContent = `${selected.length} selected`;
const selectionUniverse = state.searchAllResults || state.messages;
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
$("#bulk-select-all").checked = allLoaded;
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
const selectSearch = $("#bulk-select-search");
if (selectSearch) {
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
selectSearch.disabled = !state.searchContext;
}
const junkButton = $("#bulk-junk");
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
const toggle = $("#select-mode-btn");
if (toggle) {
toggle.classList.toggle("active", state.selectionMode);
toggle.setAttribute("aria-pressed", String(state.selectionMode));
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
toggle.setAttribute("aria-label", toggle.title);
}
}
function setSelectionMode(enabled) {
state.selectionMode = Boolean(enabled);
if (!state.selectionMode) {
state.selectedMessages.clear();
state.searchAllResults = null;
}
renderMessages();
}
function setMessageSelected(message, selected) {
if (!state.selectionMode) state.selectionMode = true;
const key = messageKey(message);
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
renderMessages();
}
function clearBulkSelection() {
state.selectedMessages.clear();
state.searchAllResults = null;
renderMessages();
}
async function selectAllSearchResults() {
if (!state.searchContext) return;
const button = $("#bulk-select-search");
setBusy(button, true, "Selecting…");
try {
const params = new URLSearchParams(state.searchContext.params);
params.set("all", "true");
params.delete("offset");
const data = await request(`/search?${params.toString()}`, { method: "GET" });
state.searchAllResults = data.results || [];
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
state.total = state.searchAllResults.length;
renderMessages();
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
function renderTagOptions() {
const select = $("#bulk-tag-select");
if (!select) return;
select.replaceChildren(new Option("Add label…", ""));
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
}
function renderTags() {
const list = $("#tag-list");
if (!list) return;
list.replaceChildren();
for (const tag of state.tags) {
const button = el("button", "tag-item");
button.type = "button";
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
button.addEventListener("click", () => loadTagMessages(tag));
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
list.append(button);
}
renderTagOptions();
}
async function loadTags() {
try {
const data = await request("/tags", { method: "GET" });
state.tags = data.tags || [];
renderTags();
} catch (error) { toast(error.message, "error"); }
}
async function loadTagMessages(tag) {
try {
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
state.messages = data.messages || [];
state.total = data.total_count || state.messages.length;
button.addEventListener("dragleave", () => button.classList.remove("drop-target"));
button.addEventListener("drop", async (event) => {
event.preventDefault();
button.classList.remove("drop-target");
try {
const payload = JSON.parse(event.dataTransfer.getData("application/x-rustbox-message"));
if (!payload?.folder || !payload?.uid || payload.folder === folder.name) return;
await moveMessageByDrag(payload.folder, Number(payload.uid), folder.name);
} catch (error) { toast(error.message || "Could not move message", "error"); }
});
button.addEventListener("click", async () => {
saveMessageListState();
state.folder = folder.name;
state.selected = null;
state.selectedMessages.clear();
clearActiveSearchContext();
renderMessages();
$("#folder-title").textContent = `Label: ${tag.name}`;
$("#message-count").textContent = `${state.total} results`;
} catch (error) { toast(error.message, "error"); }
state.currentDetail = null;
renderFolders();
closeReader();
setFolderDrawerOpen(false);
await loadMessages(true, { restoreState: true });
});
row.append(expander, button);
list.append(row);
}
renderMoveOptions();
}
function openTagDialog(tag = null) {
$("#tag-id").value = tag?.id || "";
$("#tag-name").value = tag?.name || "";
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
$("#tag-delete").disabled = !tag;
$("#tag-dialog").showModal();
setTimeout(() => $("#tag-name").focus(), 20);
function updateUnreadAppBadge() {
const unread = state.folders.reduce((sum, folder) => sum + Math.max(0, Number(folder.unread_count || 0)), 0);
document.title = unread > 0 ? `(${unread}) RustBox` : "RustBox";
try {
if (unread > 0 && typeof navigator.setAppBadge === "function") navigator.setAppBadge(unread);
else if (unread === 0 && typeof navigator.clearAppBadge === "function") navigator.clearAppBadge();
} catch (_) { }
}
async function loadFolders(restoreView = false) {
$("#connection-label").textContent = "Connecting";
try {
const data = await request("/folders", { method: "GET" });
state.folders = data.folders || [];
updateUnreadAppBadge();
loadCollapsedFolders();
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
if (restoreView) {
const remembered = localStorage.getItem(lastFolderStorageKey());
if (remembered && state.folders.some((folder) => folder.name === remembered)) state.folder = remembered;
}
if (!state.folders.some((folder) => folder.name === state.folder)) {
state.folder = inbox?.name || state.folders[0]?.name || "INBOX";
}
renderFolders();
$("#connection-label").textContent = "Connected";
if (restoreView) {
const restoredSearch = readActiveSearchContext();
if (restoredSearch) {
state.searchContext = restoredSearch;
const restoredParams = new URLSearchParams(restoredSearch.params);
$("#search-input").value = restoredParams.get("q") || "";
await runSearch(restoredParams, restoredSearch.label || "Search", { restoreScrollTop: restoredSearch.scrollTop });
return;
}
}
if (state.searchContext && !restoreView) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true, { restoreState: restoreView });
} catch (error) {
$("#connection-label").textContent = "Connection error";
if (error.status === 401) return showLogin();
toast(error.message, "error");
$("#message-list").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
}
}
async function saveTag(event) {
event.preventDefault();
const id = $("#tag-id").value;
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value };
if (!payload.name) return;
try {
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
$("#tag-dialog").close();
await loadTags();
toast("Label saved");
} catch (error) { toast(error.message, "error"); }
function renderMoveOptions() {
const selects = [
["#move-select", "Move to…"],
["#copy-select", "Copy to…"],
["#bulk-move-select", "Move to…"],
["#bulk-copy-select", "Copy to…"],
];
for (const [selector, placeholder] of selects) {
const select = $(selector);
if (!select) continue;
select.replaceChildren(new Option(placeholder, ""));
for (const folder of orderedFolders()) {
if ((selector === "#move-select" || selector === "#copy-select") && folder.name === state.currentDetail?.folder) continue;
select.add(new Option(folder.name, folder.name));
}
}
}
async function deleteTag() {
const id = $("#tag-id").value;
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return;
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
catch (error) { toast(error.message, "error"); }
function messageKey(message) {
return `${message.folder || state.folder}\u0000${message.uid}`;
}
function activeSearchHighlightTerms() {
if (!state.searchContext?.params) return [];
const params = new URLSearchParams(state.searchContext.params);
const terms = [];
for (const key of ["from", "to", "subject"]) {
const value = (params.get(key) || "").trim();
if (value) terms.push(value);
}
async function promptTag(label, tags = state.tags) {
if (!tags.length) { toast("Create a label first", "error"); return null; }
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" });
return tags.find((tag) => String(tag.id) === String(answer)) || null;
const query = (params.get("q") || "").trim();
const tokens = query.match(/(?:[^\s"]+|"[^"]*")+/g) || [];
for (const token of tokens) {
const cleaned = token.replace(/^"|"$/g, "");
const colon = cleaned.indexOf(":");
if (colon > 0) {
const op = cleaned.slice(0, colon).toLowerCase();
const value = cleaned.slice(colon + 1).replace(/^"|"$/g, "").trim();
if (["from", "to", "subject"].includes(op) && value) terms.push(value);
} else if (cleaned.length >= 2) {
terms.push(cleaned);
}
}
return [...new Set(terms.map((term) => term.trim()).filter((term) => term.length >= 2))]
.sort((a, b) => b.length - a.length)
.slice(0, 12);
}
async function addTagToMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) });
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
function renderHighlightedText(node, value, terms) {
const text = String(value || "");
node.replaceChildren();
if (!terms.length || !text) {
node.textContent = text;
return;
}
const lower = text.toLocaleLowerCase();
let offset = 0;
while (offset < text.length) {
let matchIndex = -1;
let matchTerm = "";
for (const term of terms) {
const index = lower.indexOf(term.toLocaleLowerCase(), offset);
if (index >= 0 && (matchIndex < 0 || index < matchIndex || (index === matchIndex && term.length > matchTerm.length))) {
matchIndex = index;
matchTerm = term;
}
}
if (matchIndex < 0) {
node.append(document.createTextNode(text.slice(offset)));
break;
}
if (matchIndex > offset) node.append(document.createTextNode(text.slice(offset, matchIndex)));
const mark = el("mark", "search-highlight", text.slice(matchIndex, matchIndex + matchTerm.length));
node.append(mark);
offset = matchIndex + matchTerm.length;
}
}
function selectedMessages() {
const source = state.searchAllResults || state.messages;
return source.filter((message) => state.selectedMessages.has(messageKey(message)));
}
function renderBulkToolbar() {
const selected = selectedMessages();
const toolbar = $("#bulk-toolbar");
toolbar.classList.toggle("hidden", !state.selectionMode);
$("#bulk-count").textContent = `${selected.length} selected`;
const selectionUniverse = state.searchAllResults || state.messages;
const allLoaded = selectionUniverse.length > 0 && selectionUniverse.every((message) => state.selectedMessages.has(messageKey(message)));
$("#bulk-select-all").checked = allLoaded;
$("#bulk-select-all").indeterminate = selected.length > 0 && !allLoaded;
toolbar.querySelectorAll("button, select").forEach((control) => { if (control.id !== "selection-done" && control.id !== "bulk-select-search") control.disabled = selected.length === 0; });
const selectSearch = $("#bulk-select-search");
if (selectSearch) {
selectSearch.classList.toggle("hidden", !state.searchContext || Boolean(state.searchAllResults));
selectSearch.disabled = !state.searchContext;
}
const junkButton = $("#bulk-junk");
if (junkButton) junkButton.textContent = selected.length > 0 && selected.every((message) => isJunkFolder(message.folder || state.folder)) ? tr("not_spam", "Not spam") : tr("spam", "Spam");
const toggle = $("#select-mode-btn");
if (toggle) {
toggle.classList.toggle("active", state.selectionMode);
toggle.setAttribute("aria-pressed", String(state.selectionMode));
toggle.title = state.selectionMode ? "Exit selection mode" : "Select messages";
toggle.setAttribute("aria-label", toggle.title);
}
}
function setSelectionMode(enabled) {
state.selectionMode = Boolean(enabled);
if (!state.selectionMode) {
state.selectedMessages.clear();
state.searchAllResults = null;
}
renderMessages();
}
function setMessageSelected(message, selected) {
if (!state.selectionMode) state.selectionMode = true;
const key = messageKey(message);
selected ? state.selectedMessages.add(key) : state.selectedMessages.delete(key);
renderMessages();
}
function clearBulkSelection() {
state.selectedMessages.clear();
state.searchAllResults = null;
renderMessages();
}
async function selectAllSearchResults() {
if (!state.searchContext) return;
const button = $("#bulk-select-search");
setBusy(button, true, "Selecting…");
try {
const params = new URLSearchParams(state.searchContext.params);
params.set("all", "true");
params.delete("offset");
const data = await request(`/search?${params.toString()}`, { method: "GET" });
state.searchAllResults = data.results || [];
for (const message of state.searchAllResults) state.selectedMessages.add(messageKey(message));
state.total = state.searchAllResults.length;
renderMessages();
toast(`Selected all ${state.searchAllResults.length} search result${state.searchAllResults.length === 1 ? "" : "s"}`, "success");
} catch (error) {
toast(error.message, "error");
} finally {
setBusy(button, false);
}
}
function renderTagOptions() {
const select = $("#bulk-tag-select");
if (!select) return;
select.replaceChildren(new Option("Add label…", ""));
for (const tag of state.tags) select.add(new Option(tag.name, tag.id));
}
function renderTags() {
const list = $("#tag-list");
if (!list) return;
list.replaceChildren();
for (const tag of state.tags) {
const button = el("button", "tag-item");
button.type = "button";
const dot = el("span", "tag-dot"); dot.style.setProperty("--tag-color", tag.color || "#6b7280");
button.append(dot, el("span", "", tag.name), el("span", "tag-count", String(tag.message_count || 0)));
button.addEventListener("click", () => loadTagMessages(tag));
button.addEventListener("contextmenu", (event) => { event.preventDefault(); openTagDialog(tag); });
list.append(button);
}
renderTagOptions();
}
async function loadTags() {
try {
const data = await request("/tags", { method: "GET" });
state.tags = data.tags || [];
renderTags();
} catch (error) { toast(error.message, "error"); }
}
async function loadTagMessages(tag) {
try {
const data = await request(`/tags/${encodeURIComponent(tag.id)}/messages?page=0&per_page=250`, { method: "GET" });
state.messages = data.messages || [];
state.total = data.total_count || state.messages.length;
state.selectedMessages.clear();
clearActiveSearchContext();
renderMessages();
$("#folder-title").textContent = `Label: ${tag.name}`;
$("#message-count").textContent = `${state.total} results`;
} catch (error) { toast(error.message, "error"); }
}
function openTagDialog(tag = null) {
$("#tag-id").value = tag?.id || "";
$("#tag-name").value = tag?.name || "";
$("#tag-color").value = /^#[0-9a-f]{6}$/i.test(tag?.color || "") ? tag.color : "#6b7280";
$("#tag-delete").disabled = !tag;
$("#tag-dialog").showModal();
setTimeout(() => $("#tag-name").focus(), 20);
}
async function saveTag(event) {
event.preventDefault();
const id = $("#tag-id").value;
const payload = { name: $("#tag-name").value.trim(), color: $("#tag-color").value };
if (!payload.name) return;
try {
await request(id ? `/tags/${encodeURIComponent(id)}` : "/tags", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
$("#tag-dialog").close();
await loadTags();
toast("Label saved");
} catch (error) { toast(error.message, "error"); }
}
async function deleteTag() {
const id = $("#tag-id").value;
if (!id || !await appConfirm("Delete this label? Messages will not be deleted.", { title: "Delete label", confirmLabel: "Delete", danger: true })) return;
try { await request(`/tags/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#tag-dialog").close(); await loadTags(); toast("Label deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function promptTag(label, tags = state.tags) {
if (!tags.length) { toast("Create a label first", "error"); return null; }
const answer = await appSelect(label, tags.map((tag) => ({ value: tag.id, label: tag.name })), { value: tags[0].id, confirmLabel: "Choose label" });
return tags.find((tag) => String(tag.id) === String(answer)) || null;
}
async function addTagToMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages`, { method: "POST", body: JSON.stringify({ message_uid: message.uid, message_folder: folder }) });
message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
renderMessages();
await loadTags();
}
async function removeTagFromMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
renderMessages();
await loadTags();
}
async function bulkAddTag(tagId) {
const items = selectedMessages();
const tag = state.tags.find((item) => item.id === tagId);
if (!items.length || !tag) return;
try {
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
renderMessages();
await loadTags();
}
async function removeTagFromMessage(message, tag) {
const folder = message.folder || state.folder;
await request(`/tags/${encodeURIComponent(tag.id)}/messages/${encodeURIComponent(folder)}/${message.uid}`, { method: "DELETE" });
message.tags = (message.tags || []).filter((item) => item.id !== tag.id);
renderMessages();
await loadTags();
}
async function bulkAddTag(tagId) {
const items = selectedMessages();
const tag = state.tags.find((item) => item.id === tagId);
if (!items.length || !tag) return;
try {
await request(`/tags/${encodeURIComponent(tag.id)}/messages/bulk`, { method: "POST", body: JSON.stringify({ messages: items.map((message) => ({ uid: message.uid, folder: message.folder || state.folder })) }) });
for (const message of items) message.tags = [...(message.tags || []).filter((item) => item.id !== tag.id), { id: tag.id, name: tag.name, color: tag.color }];
renderMessages();
await loadTags();
toast(`Label “${tag.name}” added`);
} catch (error) { toast(error.message, "error"); }
}
toast(`Label “${tag.name}” added`);
} catch (error) { toast(error.message, "error"); }
}
+91 -2
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 40-messages-reader.js */
async function loadMessages(reset = false, options = {}) {
const list = $("#message-list");
const restoreState = Boolean(options.restoreState);
@@ -117,7 +117,17 @@ function renderMessages() {
flagButton.setAttribute("aria-label", flagButton.title);
flagButton.innerHTML = svgIcon("star");
flagButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Flagged", !flagged, flagged ? "Flag removed" : "Message flagged"); });
quick.append(readButton, flagButton);
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
fullscreenButton.type = "button";
fullscreenButton.title = "Open full screen";
fullscreenButton.setAttribute("aria-label", fullscreenButton.title);
fullscreenButton.innerHTML = svgIcon("external-link");
fullscreenButton.addEventListener("click", async (event) => {
event.stopPropagation();
await openMessage(folder, message.uid);
enterReaderFullscreen();
});
quick.append(readButton, flagButton, fullscreenButton);
icons.append(quick);
if (Array.isArray(message.tags) && message.tags.length) {
const wrap = el("span", "message-tags");
@@ -177,6 +187,84 @@ async function senderIsSavedContact(email) {
}
}
function readerFullscreenUrl(detail = state.currentDetail) {
const url = new URL(location.href);
if (detail?.folder && Number.isFinite(Number(detail.uid))) {
url.searchParams.set("mail_folder", detail.folder);
url.searchParams.set("mail_uid", String(detail.uid));
url.searchParams.set("mail_fullscreen", "1");
}
return url;
}
function restoreReaderScroll() {
const reader = $("#reader-content");
if (!reader) return;
const top = Math.max(0, Number(state.readerScrollTopBeforeFullscreen) || 0);
requestAnimationFrame(() => { reader.scrollTop = top; });
}
function setReaderFullscreen(enabled, options = {}) {
const reader = $("#reader");
const button = $("#reader-fullscreen");
if (!reader || !button) return;
const next = Boolean(enabled);
if (next === reader.classList.contains("reader-fullscreen")) return;
if (next) {
state.readerScrollTopBeforeFullscreen = $("#reader-content")?.scrollTop || 0;
reader.classList.add("reader-fullscreen");
document.body.classList.add("reader-fullscreen-open");
button.setAttribute("aria-pressed", "true");
button.title = "Exit full screen";
button.setAttribute("aria-label", "Exit message full screen");
if (options.pushHistory !== false && state.currentDetail) {
history.pushState({ rustboxMailFullscreen: true, folder: state.currentDetail.folder, uid: state.currentDetail.uid }, "", readerFullscreenUrl());
state.readerFullscreenHistory = true;
}
} else {
reader.classList.remove("reader-fullscreen");
document.body.classList.remove("reader-fullscreen-open");
button.setAttribute("aria-pressed", "false");
button.title = "Open full screen";
button.setAttribute("aria-label", "Open message full screen");
state.readerFullscreenHistory = false;
if (options.keepUrl !== true && new URL(location.href).searchParams.get("mail_fullscreen") === "1") {
const url = new URL(location.href);
url.searchParams.delete("mail_fullscreen");
history.replaceState(history.state && history.state.rustboxMailFullscreen ? null : history.state, "", url);
}
restoreReaderScroll();
}
}
function enterReaderFullscreen() {
if (!state.currentDetail) return;
setReaderFullscreen(true);
}
function exitReaderFullscreen() {
if (!$("#reader")?.classList.contains("reader-fullscreen")) return;
if (state.readerFullscreenHistory) history.back();
else setReaderFullscreen(false, { pushHistory: false });
}
function toggleReaderFullscreen() {
if ($("#reader")?.classList.contains("reader-fullscreen")) exitReaderFullscreen();
else enterReaderFullscreen();
}
async function openMessageTarget(accountId, folder, uid, fullscreen = false) {
if (accountId && accountId !== state.accountId) await switchAccount(accountId);
if (folder && folder !== state.folder) {
state.folder = folder;
renderFolders();
await loadMessages(true);
}
if (folder && Number.isFinite(Number(uid))) await openMessage(folder, Number(uid));
if (fullscreen && state.currentDetail) setReaderFullscreen(true, { pushHistory: false });
}
async function openMessage(folder, uid) {
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
@@ -756,6 +844,7 @@ async function importVcardAttachment(folder, uid, attachment) {
function closeReader() {
clearTimeout(state.markReadTimer);
state.markReadTimer = null;
if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false });
$("#reader").classList.remove("mobile-open");
$("#reader-empty").classList.remove("hidden");
$("#reader-content").classList.add("hidden");
+36 -25
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 50-compose.js */
function parseRecipients(raw) {
return raw.split(",").map((value) => value.trim()).filter(Boolean);
}
@@ -98,7 +98,7 @@ function estimatedMimeAttachmentBytes(rawBytes) {
function estimateComposeMessageBytes(extraFiles = []) {
const editor = $("#compose-editor");
let bytes = 4096; // envelope headers, MIME boundaries and normal metadata
let bytes = 4096;
for (const selector of ["#compose-to", "#compose-cc", "#compose-bcc", "#compose-subject"]) bytes += utf8Bytes($(selector)?.value);
if (editor) {
const plainBytes = utf8Bytes(editor.innerText);
@@ -404,27 +404,37 @@ function setComposeUploadProgress(visible, percent = 0, label = trText("Uploadin
}
async function uploadComposeFiles(files, options = {}) {
const list = [...(files || [])];
const announce = options.announce !== false;
const incoming = [...(files || [])];
if (!incoming.length) return [];
if (state.composeUploading) return [];
const existing = new Set(state.composeAttachments.map((attachment) => `${String(attachment.filename || "")}\u0000${Number(attachment.size || 0)}`));
const selected = new Set();
const list = incoming.filter((file) => {
const key = `${String(file.name || "")}\u0000${Number(file.size || 0)}`;
if (existing.has(key) || selected.has(key)) return false;
selected.add(key);
return true;
});
if (!list.length) return [];
if (state.composeUploading) { if (announce) toast(trText("Another upload is already in progress"), "warning"); return []; }
const maxBytes = Math.max(1, Number(root.dataset.maxAttachmentBytes || 10 * 1024 * 1024));
const maxMiB = Math.max(1, Number(root.dataset.maxAttachmentMb || Math.round(maxBytes / 1024 / 1024)));
const oversized = list.find((file) => Number(file.size || 0) > maxBytes);
if (oversized) {
toast(`${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`, "error");
setComposeUploadProgress(true, 0, `${oversized.name}: ${trText("maximum attachment size is")} ${maxMiB} MiB`);
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4200);
return [];
}
const maxMessageBytes = Math.max(1, Number(root.dataset.maxMessageBytes || 50 * 1024 * 1024));
const maxMessageMiB = Math.max(1, Number(root.dataset.maxMessageMb || Math.round(maxMessageBytes / 1024 / 1024)));
const estimatedAfterUpload = updateComposeMessageSize(list);
if (estimatedAfterUpload > maxMessageBytes) {
toast(`${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB). ${trText("The exact size will be checked before sending.")}`, "warning");
setComposeUploadProgress(true, 0, `${trText("Estimated message size exceeds the configured limit")} (${formatComposeBytes(estimatedAfterUpload)} / ${maxMessageMiB} MiB)`);
}
state.composeUploading = true;
const controller = new AbortController();
state.composeUploadController = controller;
const uploadLabel = list.length === 1 ? trText("Uploading attachment…") : trText("Uploading files…");
setComposeUploadProgress(true, 0, uploadLabel);
const progress = progressToast(uploadLabel);
await saveCurrentDraft(true, true);
const id = ensureDraftId();
const added = [];
@@ -435,27 +445,28 @@ async function uploadComposeFiles(files, options = {}) {
form.append("file", file, file.name);
const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => {
const overall = Math.round(((index + (percent / 100)) / list.length) * 100);
const label = `${trText("Uploading attachment…")} ${file.name}`;
setComposeUploadProgress(true, overall, `${label} · ${overall}%`);
progress.update(overall, label);
});
added.push(...(data.attachments || []));
setComposeUploadProgress(true, overall, `${trText("Uploading attachment…")} ${file.name} · ${overall}%`);
}, controller.signal);
const uploaded = data.attachments || [];
added.push(...uploaded);
state.composeAttachments.push(...uploaded);
renderComposeAttachments();
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
}
state.composeAttachments.push(...added);
renderComposeAttachments();
state.composeDirty = true;
state.composeNeedsRemoteSync = true;
storeLocalDraftSnapshot();
setComposeUploadProgress(true, 100, trText("Upload complete"));
progress.complete(added.length === 1 ? trText("Attachment uploaded") : `${added.length} ${trText("attachments uploaded")}`);
if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success");
setTimeout(() => setComposeUploadProgress(false), 450);
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 450);
return added;
} catch (error) {
setComposeUploadProgress(false);
progress.fail(error.message);
return [];
if (error?.name === "AbortError") setComposeUploadProgress(false);
else {
setComposeUploadProgress(true, 0, error?.message || trText("Upload failed"));
setTimeout(() => { if (!state.composeUploading) setComposeUploadProgress(false); }, 4800);
}
return added;
} finally {
if (state.composeUploadController === controller) state.composeUploadController = null;
state.composeUploading = false;
}
}
@@ -978,8 +989,8 @@ async function recoverDraft() {
async function closeComposer() {
clearTimeout(state.draftSaveTimer);
clearTimeout(state.inlineImageCleanupTimer);
if (state.composeUploadController) state.composeUploadController.abort();
await cleanupUnusedInlineAttachments();
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
if (state.composeNeedsRemoteSync && draftHasContent()) {
const saved = await saveCurrentDraft(true, true, true);
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
+2 -1
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 60-mail-actions-search.js */
async function updateFlag(flag, add) {
if (!state.currentDetail) return;
const detail = state.currentDetail;
@@ -485,6 +485,7 @@ function openMessageContextMenu(x, y, message) {
const flagged = flags.includes("\\Flagged");
showContextMenu(x, y, [
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
{ label: "Open full screen", action: async () => { await ensureMessageOpen(message); enterReaderFullscreen(); } },
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
+274 -274
View File
@@ -1,294 +1,294 @@
/* RustBox UI module: 70-contacts.js */
async function openContacts() {
$("#contacts-dialog").showModal();
await loadContactGroups();
await loadContacts();
}
async function loadContactGroups() {
try {
const data = await request("/contact-groups", { method: "GET" });
state.contactGroups = data.groups || [];
renderContactGroupFilter();
renderContactGroupMembership($("#contact-id").value || null, []);
} catch (error) { toast(error.message, "error"); }
}
async function openContacts() {
$("#contacts-dialog").showModal();
await loadContactGroups();
await loadContacts();
}
function renderContactGroupFilter() {
const select = $("#contact-group-filter");
const current = select.value;
select.replaceChildren(new Option("All contacts", ""));
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
if ([...select.options].some((option) => option.value === current)) select.value = current;
const hasGroup = Boolean(select.value);
$("#contact-group-rename").disabled = !hasGroup;
$("#contact-group-delete").disabled = !hasGroup;
}
async function loadContactGroups() {
try {
const data = await request("/contact-groups", { method: "GET" });
state.contactGroups = data.groups || [];
renderContactGroupFilter();
renderContactGroupMembership($("#contact-id").value || null, []);
} catch (error) { toast(error.message, "error"); }
}
async function downloadContactExport() {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
if (!response.ok) throw new Error(`Export failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "contacts.vcf";
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("Calendar exported", "success");
} catch (error) { toast(error.message, "error"); }
}
function renderContactGroupFilter() {
const select = $("#contact-group-filter");
const current = select.value;
select.replaceChildren(new Option("All contacts", ""));
for (const group of state.contactGroups) select.add(new Option(`${group.name} (${group.member_count || 0})`, group.id));
if ([...select.options].some((option) => option.value === current)) select.value = current;
const hasGroup = Boolean(select.value);
$("#contact-group-rename").disabled = !hasGroup;
$("#contact-group-delete").disabled = !hasGroup;
}
async function importContactVcard(files) {
const file = files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
const form = new FormData();
form.append("file", file, file.name || "contacts.vcf");
try {
const data = await request("/contacts/import", { method: "POST", body: form });
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
await loadContacts($("#contact-search").value.trim());
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-import-file").value = ""; }
}
async function downloadContactExport() {
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath("/contacts/export"), { credentials: "include", headers });
if (!response.ok) throw new Error(`Export failed (${response.status})`);
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "contacts.vcf";
document.body.append(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("Calendar exported", "success");
} catch (error) { toast(error.message, "error"); }
}
async function showContactDuplicates() {
try {
const data = await request("/contacts/duplicates", { method: "GET" });
const groups = data.duplicates || [];
const list = $("#contact-duplicate-list");
list.replaceChildren();
if (!groups.length) {
list.append(el("div", "empty-list", "No duplicate contacts found"));
}
for (const group of groups) {
const card = el("section", "duplicate-contact-group");
card.append(el("strong", "", group.email));
const rows = el("div", "duplicate-contact-options");
for (const contact of group.contacts || []) {
const row = el("div", "duplicate-contact-row");
const summary = el("div", "duplicate-contact-summary");
summary.append(el("strong", "", contact.name || contact.email));
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
const keep = el("button", "button quiet", "Keep this");
keep.type = "button";
keep.addEventListener("click", async () => {
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
if (!sources.length) return;
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
try {
let target = contact;
for (const source of sources) {
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
}
toast("Contacts merged");
await loadContacts($("#contact-search").value.trim());
await showContactDuplicates();
editContact(target);
} catch (error) { toast(error.message, "error"); }
});
row.append(summary, keep); rows.append(row);
}
card.append(rows); list.append(card);
}
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
} catch (error) { toast(error.message, "error"); }
}
async function importContactVcard(files) {
const file = files?.[0];
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("vCard file is too large", "error");
const form = new FormData();
form.append("file", file, file.name || "contacts.vcf");
try {
const data = await request("/contacts/import", { method: "POST", body: form });
toast(`vCard import: ${data.created || 0} created, ${data.updated || 0} updated, ${data.skipped || 0} skipped`);
await loadContacts($("#contact-search").value.trim());
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-import-file").value = ""; }
}
async function loadContacts(query = "") {
try {
const groupId = $("#contact-group-filter")?.value || "";
let contacts;
if (groupId) {
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
contacts = data.members || [];
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
} else {
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
contacts = data.contacts || [];
}
state.contacts = contacts;
renderContacts();
} catch (error) { toast(error.message, "error"); }
}
async function createContactGroup() {
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
if (!name?.trim()) return;
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
catch (error) { toast(error.message, "error"); }
}
async function renameContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group) return;
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
if (!name?.trim() || name.trim() === group.name) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function loadContactMembership(contactId) {
if (!contactId) return renderContactGroupMembership(null, []);
try {
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
} catch (error) { toast(error.message, "error"); }
}
function renderContactGroupMembership(contactId, selectedIds) {
const list = $("#contact-group-membership");
if (!list) return;
const selected = new Set(selectedIds || []);
async function showContactDuplicates() {
try {
const data = await request("/contacts/duplicates", { method: "GET" });
const groups = data.duplicates || [];
const list = $("#contact-duplicate-list");
list.replaceChildren();
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
for (const group of state.contactGroups) {
const label = el("label", "checkbox-row");
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
input.addEventListener("change", async () => {
try {
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
await loadContactGroups();
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
});
label.append(input, el("span", "", group.name)); list.append(label);
if (!groups.length) {
list.append(el("div", "empty-list", "No duplicate contacts found"));
}
}
function renderContacts() {
const list = $("#contact-list");
list.replaceChildren();
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
for (const contact of state.contacts) {
const button = el("button", "contact-item");
button.type = "button";
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
button.addEventListener("click", () => editContact(contact));
list.append(button);
for (const group of groups) {
const card = el("section", "duplicate-contact-group");
card.append(el("strong", "", group.email));
const rows = el("div", "duplicate-contact-options");
for (const contact of group.contacts || []) {
const row = el("div", "duplicate-contact-row");
const summary = el("div", "duplicate-contact-summary");
summary.append(el("strong", "", contact.name || contact.email));
summary.append(el("small", "muted", [contact.email, contact.company, contact.phone].filter(Boolean).join(" · ")));
const keep = el("button", "button quiet", "Keep this");
keep.type = "button";
keep.addEventListener("click", async () => {
const sources = (group.contacts || []).filter((item) => item.id !== contact.id);
if (!sources.length) return;
if (!await appConfirm(`Merge ${sources.length} duplicate contact(s) into ${contact.name || contact.email}?`, { title: "Merge duplicate contacts", confirmLabel: "Merge" })) return;
try {
let target = contact;
for (const source of sources) {
target = await request("/contacts/merge", { method: "POST", body: JSON.stringify({ target_id: target.id, source_id: source.id }) });
}
toast("Contacts merged");
await loadContacts($("#contact-search").value.trim());
await showContactDuplicates();
editContact(target);
} catch (error) { toast(error.message, "error"); }
});
row.append(summary, keep); rows.append(row);
}
card.append(rows); list.append(card);
}
}
if (!$("#contact-duplicates-dialog").open) $("#contact-duplicates-dialog").showModal();
} catch (error) { toast(error.message, "error"); }
}
function editContact(contact = null) {
$("#contact-id").value = contact?.id || "";
$("#contact-name").value = contact?.name || "";
$("#contact-email").value = contact?.email || "";
$("#contact-emails").value = (contact?.emails || []).join("\n");
$("#contact-company").value = contact?.company || "";
$("#contact-phone").value = contact?.phone || "";
$("#contact-phones").value = (contact?.phones || []).join("\n");
$("#contact-job-title").value = contact?.job_title || "";
$("#contact-website").value = contact?.website || "";
$("#contact-birthday").value = contact?.birthday || "";
$("#contact-address").value = contact?.address || "";
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
$("#contact-notes").value = contact?.notes || "";
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
$("#contact-delete").disabled = !contact;
$("#contact-compose").disabled = !contact?.email;
$("#contact-photo-upload").disabled = !contact?.id;
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
renderContactPhoto(contact);
loadContactMembership(contact?.id || null);
}
async function loadContacts(query = "") {
try {
const groupId = $("#contact-group-filter")?.value || "";
let contacts;
if (groupId) {
const data = await request(`/contact-groups/${encodeURIComponent(groupId)}/members`, { method: "GET" });
contacts = data.members || [];
if (query) { const needle = query.toLowerCase(); contacts = contacts.filter((contact) => `${contact.name} ${contact.email} ${contact.company}`.toLowerCase().includes(needle)); }
} else {
const data = await request(`/contacts?limit=500&offset=0${query ? `&q=${encodeURIComponent(query)}` : ""}`, { method: "GET" });
contacts = data.contacts || [];
}
state.contacts = contacts;
renderContacts();
} catch (error) { toast(error.message, "error"); }
}
function clearContactPhotoObjectUrl() {
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
state.contactPhotoUrl = null;
}
async function createContactGroup() {
const name = await appPrompt("New contact group", { placeholder: "Group name", confirmLabel: "Create", required: true });
if (!name?.trim()) return;
try { await request("/contact-groups", { method: "POST", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group created"); }
catch (error) { toast(error.message, "error"); }
}
async function renderContactPhoto(contact) {
clearContactPhotoObjectUrl();
const image = $("#contact-photo-preview");
const placeholder = $("#contact-photo-placeholder");
image.classList.add("hidden");
image.removeAttribute("src");
placeholder.classList.remove("hidden");
if (!contact?.id || !contact?.has_photo) return;
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
if (!response.ok) return;
const blob = await response.blob();
state.contactPhotoUrl = URL.createObjectURL(blob);
image.src = state.contactPhotoUrl;
image.classList.remove("hidden");
placeholder.classList.add("hidden");
} catch (_) { }
}
async function renameContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group) return;
const name = await appPrompt("Rename contact group", { value: group.name, confirmLabel: "Rename", required: true });
if (!name?.trim() || name.trim() === group.name) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify({ name: name.trim() }) }); await loadContactGroups(); toast("Group renamed"); }
catch (error) { toast(error.message, "error"); }
}
async function uploadContactPhoto(files) {
const id = $("#contact-id").value;
const file = files?.[0];
if (!id || !file) return;
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
const form = new FormData();
form.append("file", file, file.name || "photo");
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = true;
$("#contact-photo-delete").disabled = false;
await renderContactPhoto({ id, has_photo: true });
toast("Contact photo saved");
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-photo-file").value = ""; }
}
async function deleteContactGroup() {
const id = $("#contact-group-filter").value;
const group = state.contactGroups.find((item) => item.id === id);
if (!group || !await appConfirm(`Delete group “${group.name}”? Contacts will be kept.`, { title: "Delete contact group", confirmLabel: "Delete", danger: true })) return;
try { await request(`/contact-groups/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#contact-group-filter").value = ""; await loadContactGroups(); await loadContacts($("#contact-search").value.trim()); toast("Group deleted"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContactPhoto() {
const id = $("#contact-id").value;
if (!id) return;
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = false;
$("#contact-photo-delete").disabled = true;
await renderContactPhoto({ id, has_photo: false });
toast("Contact photo removed");
} catch (error) { toast(error.message, "error"); }
}
async function loadContactMembership(contactId) {
if (!contactId) return renderContactGroupMembership(null, []);
try {
const data = await request(`/contacts/${encodeURIComponent(contactId)}/groups`, { method: "GET" });
renderContactGroupMembership(contactId, (data.groups || []).map((group) => group.id));
} catch (error) { toast(error.message, "error"); }
}
async function saveContact(event) {
event.preventDefault();
const payload = {
id: $("#contact-id").value || null,
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
is_favorite: $("#contact-favorite").checked, source: "manual",
};
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
catch (error) { toast(error.message, "error"); }
function renderContactGroupMembership(contactId, selectedIds) {
const list = $("#contact-group-membership");
if (!list) return;
const selected = new Set(selectedIds || []);
list.replaceChildren();
if (!state.contactGroups.length) { list.append(el("span", "muted", "No groups")); return; }
for (const group of state.contactGroups) {
const label = el("label", "checkbox-row");
const input = document.createElement("input"); input.type = "checkbox"; input.checked = selected.has(group.id); input.disabled = !contactId;
input.addEventListener("change", async () => {
try {
if (input.checked) await request(`/contact-groups/${encodeURIComponent(group.id)}/members`, { method: "POST", body: JSON.stringify({ contact_id: contactId }) });
else await request(`/contact-groups/${encodeURIComponent(group.id)}/members/${encodeURIComponent(contactId)}`, { method: "DELETE" });
await loadContactGroups();
} catch (error) { input.checked = !input.checked; toast(error.message, "error"); }
});
label.append(input, el("span", "", group.name)); list.append(label);
}
}
async function deleteContact() {
const id = $("#contact-id").value;
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
catch (error) { toast(error.message, "error"); }
function renderContacts() {
const list = $("#contact-list");
list.replaceChildren();
if (!state.contacts.length) list.append(el("div", "empty-list", "No contacts"));
for (const contact of state.contacts) {
const button = el("button", "contact-item");
button.type = "button";
button.innerHTML = `<strong>${escapeHtml(contact.name || contact.email)}</strong><small>${escapeHtml(contact.email)}${contact.company ? ` · ${escapeHtml(contact.company)}` : ""}${contact.phone ? ` · ${escapeHtml(contact.phone)}` : ""}</small>`;
button.addEventListener("click", () => editContact(contact));
list.append(button);
}
}
function composeToContact() {
const email = $("#contact-email").value.trim();
if (!email) return;
$("#contacts-dialog").close();
openCompose("new");
$("#compose-to").value = email;
}
function editContact(contact = null) {
$("#contact-id").value = contact?.id || "";
$("#contact-name").value = contact?.name || "";
$("#contact-email").value = contact?.email || "";
$("#contact-emails").value = (contact?.emails || []).join("\n");
$("#contact-company").value = contact?.company || "";
$("#contact-phone").value = contact?.phone || "";
$("#contact-phones").value = (contact?.phones || []).join("\n");
$("#contact-job-title").value = contact?.job_title || "";
$("#contact-website").value = contact?.website || "";
$("#contact-birthday").value = contact?.birthday || "";
$("#contact-address").value = contact?.address || "";
$("#contact-addresses").value = (contact?.addresses || []).join("\n");
$("#contact-notes").value = contact?.notes || "";
$("#contact-favorite").checked = Boolean(contact?.is_favorite);
$("#contact-delete").disabled = !contact;
$("#contact-compose").disabled = !contact?.email;
$("#contact-photo-upload").disabled = !contact?.id;
$("#contact-photo-delete").disabled = !contact?.id || !contact?.has_photo;
renderContactPhoto(contact);
loadContactMembership(contact?.id || null);
}
function clearContactPhotoObjectUrl() {
if (state.contactPhotoUrl) URL.revokeObjectURL(state.contactPhotoUrl);
state.contactPhotoUrl = null;
}
async function renderContactPhoto(contact) {
clearContactPhotoObjectUrl();
const image = $("#contact-photo-preview");
const placeholder = $("#contact-photo-placeholder");
image.classList.add("hidden");
image.removeAttribute("src");
placeholder.classList.remove("hidden");
if (!contact?.id || !contact?.has_photo) return;
try {
const headers = new Headers();
if (state.accountId) headers.set("X-Active-Account", state.accountId);
const response = await fetch(apiPath(`/contacts/${encodeURIComponent(contact.id)}/photo`), { credentials: "include", headers });
if (!response.ok) return;
const blob = await response.blob();
state.contactPhotoUrl = URL.createObjectURL(blob);
image.src = state.contactPhotoUrl;
image.classList.remove("hidden");
placeholder.classList.add("hidden");
} catch (_) { }
}
async function uploadContactPhoto(files) {
const id = $("#contact-id").value;
const file = files?.[0];
if (!id || !file) return;
if (file.size > 2 * 1024 * 1024) { toast("Contact photo exceeds the 2 MB limit", "error"); $("#contact-photo-file").value = ""; return; }
const form = new FormData();
form.append("file", file, file.name || "photo");
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "POST", body: form });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = true;
$("#contact-photo-delete").disabled = false;
await renderContactPhoto({ id, has_photo: true });
toast("Contact photo saved");
} catch (error) { toast(error.message, "error"); }
finally { $("#contact-photo-file").value = ""; }
}
async function deleteContactPhoto() {
const id = $("#contact-id").value;
if (!id) return;
try {
await request(`/contacts/${encodeURIComponent(id)}/photo`, { method: "DELETE" });
const contact = state.contacts.find((item) => item.id === id);
if (contact) contact.has_photo = false;
$("#contact-photo-delete").disabled = true;
await renderContactPhoto({ id, has_photo: false });
toast("Contact photo removed");
} catch (error) { toast(error.message, "error"); }
}
async function saveContact(event) {
event.preventDefault();
const payload = {
id: $("#contact-id").value || null,
name: $("#contact-name").value.trim(), email: $("#contact-email").value.trim(), emails: $("#contact-emails").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), company: $("#contact-company").value.trim(),
phone: $("#contact-phone").value.trim(), phones: $("#contact-phones").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), job_title: $("#contact-job-title").value.trim(), website: $("#contact-website").value.trim(),
birthday: $("#contact-birthday").value, address: $("#contact-address").value.trim(), addresses: $("#contact-addresses").value.split(/\r?\n/).map((value) => value.trim()).filter(Boolean), notes: $("#contact-notes").value,
is_favorite: $("#contact-favorite").checked, source: "manual",
};
try { const saved = await request("/contacts", { method: "POST", body: JSON.stringify(payload) }); await loadContacts($("#contact-search").value.trim()); editContact(saved); toast("Contact saved"); }
catch (error) { toast(error.message, "error"); }
}
async function deleteContact() {
const id = $("#contact-id").value;
if (!id || !await appConfirm("Delete this contact?", { title: "Delete contact", confirmLabel: "Delete", danger: true })) return;
try { await request(`/contacts/${encodeURIComponent(id)}`, { method: "DELETE" }); editContact(null); await loadContacts($("#contact-search").value.trim()); toast("Contact deleted"); }
catch (error) { toast(error.message, "error"); }
}
function composeToContact() {
const email = $("#contact-email").value.trim();
if (!email) return;
$("#contacts-dialog").close();
openCompose("new");
$("#compose-to").value = email;
}
+1 -1
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 71-calendar.js */
function startOfCalendarDay(date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
+1 -1
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 72-openpgp-identities.js */
async function loadOpenPgp() {
try {
const [keys, prefs] = await Promise.all([
+117 -8
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 80-settings-realtime.js */
async function loadQuota() {
const summary = $("#quota-summary span");
const bar = $("#quota-bar");
@@ -158,17 +158,33 @@ function playNotificationSound() {
} catch (_) { }
}
async function openRealtimeNotificationTarget(accountId, data) {
window.focus();
const folder = data?.folder || "INBOX";
const uid = Number(data?.latest_uid);
if (accountId && accountId !== state.accountId) await switchAccount(accountId);
if (folder !== state.folder) {
state.folder = folder;
renderFolders();
await loadMessages(true);
}
if (Number.isFinite(uid) && uid > 0) await openMessage(folder, uid);
}
function notifyNewMail(data, accountId) {
const prefs = state.notificationPrefs || {};
if (!prefs.enabled || dndActive(prefs)) return;
const folders = prefs.folders || [];
if (folders.length && !folders.includes(data.folder)) return;
const uid = Number(data.latest_uid);
const alreadyOpen = Number(data.count || 1) === 1 && accountId === state.accountId && data.folder === state.currentDetail?.folder && Number.isFinite(uid) && uid === Number(state.currentDetail?.uid) && document.visibilityState === "visible" && document.hasFocus();
if (alreadyOpen) return;
if ("Notification" in window && Notification.permission === "granted") {
const account = state.accounts.find((item) => item.id === accountId);
const title = data.latest_sender || account?.email || "New mail";
const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`;
const notification = new Notification(title, { body, tag: `rustbox-${accountId}-${data.folder}`, renotify: true });
notification.onclick = () => { window.focus(); notification.close(); };
notification.onclick = () => { notification.close(); void openRealtimeNotificationTarget(accountId, data); };
}
if (prefs.sound) playNotificationSound();
}
@@ -216,48 +232,141 @@ function startCalendarReminderWatcher() {
state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000);
}
function realtimeEventKey(payload) {
if (payload.eventId) return String(payload.eventId);
const event = payload.event || {};
return `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
}
function isDuplicateRealtimeEvent(payload) {
const now = Date.now();
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
const event = payload.event || {};
const fingerprint = `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
const idKey = payload.eventId ? `id:${payload.eventId}` : null;
const fingerprintKey = `event:${fingerprint}`;
const duplicate = Boolean((idKey && state.realtimeSeenEvents.has(idKey)) || (state.realtimeSeenEvents.get(fingerprintKey) && now - state.realtimeSeenEvents.get(fingerprintKey) < 1500));
if (idKey) state.realtimeSeenEvents.set(idKey, now);
state.realtimeSeenEvents.set(fingerprintKey, now);
return duplicate;
}
async function resyncRealtimeState() {
if (!state.accountId) return;
if (state.realtimeSyncPromise) return state.realtimeSyncPromise;
state.realtimeSyncPromise = (async () => {
await refreshFolderListOnly();
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true);
})().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; });
return state.realtimeSyncPromise;
}
function scheduleRealtimeRefresh(event, accountId) {
if (accountId !== state.accountId) return;
state.realtimeRefreshEvents.push(event);
clearTimeout(state.realtimeRefreshTimer);
state.realtimeRefreshTimer = setTimeout(async () => {
const events = state.realtimeRefreshEvents.splice(0);
await refreshFolderListOnly();
if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) {
const refreshCurrent = events.some((item) => ["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder);
if (refreshCurrent) {
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
else await loadMessages(true);
}
}, 250);
}, 180);
}
function startRealtimeFallbackPolling() {
if (state.realtimePollTimer) return;
state.realtimePollTimer = setInterval(() => {
if (!state.accounts.length || document.visibilityState === "hidden") return;
const open = state.ws?.readyState === WebSocket.OPEN;
const stale = !state.wsLastHeartbeatAt || Date.now() - state.wsLastHeartbeatAt > 55000;
if (!open || stale) void resyncRealtimeState();
}, 30000);
}
function startRealtimeWatchdog(socket) {
clearInterval(state.wsWatchdogTimer);
state.wsWatchdogTimer = setInterval(() => {
if (state.ws !== socket || socket.readyState !== WebSocket.OPEN) return;
if (Date.now() - state.wsLastHeartbeatAt > 60000) socket.close(4000, "Heartbeat timeout");
}, 10000);
}
function scheduleRealtimeReconnect() {
if (!state.wsShouldConnect || !state.accounts.length || state.wsRetryTimer) return;
const attempt = Math.min(state.wsRetryAttempt, 6);
const base = Math.min(30000, 1000 * (2 ** attempt));
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
state.wsRetryAttempt += 1;
state.wsRetryTimer = setTimeout(() => {
state.wsRetryTimer = null;
connectRealtime();
}, delay);
}
function connectRealtime() {
state.wsShouldConnect = true;
startRealtimeFallbackPolling();
if (!state.accounts.length) return;
if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return;
clearTimeout(state.wsRetryTimer);
state.wsRetryTimer = null;
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`);
state.ws = socket;
socket.onopen = () => {
if (state.ws !== socket) return;
const reconnect = state.wsConnectedOnce;
state.wsConnectedOnce = true;
state.wsRetryAttempt = 0;
state.wsLastHeartbeatAt = Date.now();
startRealtimeWatchdog(socket);
if (reconnect) void resyncRealtimeState();
};
socket.onmessage = (message) => {
if (state.ws !== socket) return;
state.wsLastHeartbeatAt = Date.now();
try {
const payload = JSON.parse(message.data);
if (payload.type === "heartbeat") {
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "heartbeat_ack", timestamp: payload.timestamp || Date.now() }));
return;
}
if (isDuplicateRealtimeEvent(payload)) return;
const event = payload.event || {};
if (event.type === "NewMessages") notifyNewMail(event.data || {}, payload.accountId);
if (["NewMessages", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
} catch (error) { console.warn("Invalid realtime event", error); }
};
socket.onclose = () => {
if (state.ws === socket) state.ws = null;
if (state.accounts.length) state.wsRetryTimer = setTimeout(connectRealtime, 2500);
if (state.ws !== socket) return;
state.ws = null;
clearInterval(state.wsWatchdogTimer);
state.wsWatchdogTimer = null;
scheduleRealtimeReconnect();
};
socket.onerror = () => socket.close();
socket.onerror = () => { if (socket.readyState < WebSocket.CLOSING) socket.close(); };
}
function disconnectRealtime() {
state.wsShouldConnect = false;
clearTimeout(state.wsRetryTimer);
clearTimeout(state.realtimeRefreshTimer);
clearInterval(state.wsWatchdogTimer);
clearInterval(state.realtimePollTimer);
state.wsRetryTimer = null;
state.realtimeRefreshTimer = null;
state.wsWatchdogTimer = null;
state.realtimePollTimer = null;
state.realtimeRefreshEvents = [];
state.wsRetryAttempt = 0;
state.wsLastHeartbeatAt = 0;
const socket = state.ws;
state.ws = null;
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
}
function capabilityBadge(label, supported) {
+2 -2
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 90-sieve.js */
async function loadSieve() {
const status = $("#sieve-status");
status.textContent = "Connecting…";
@@ -960,7 +960,7 @@ async function restoreSieveBackup(file) {
);
if (!confirmed) return;
// Validate the complete backup against the current server before writing any script.
for (const script of scripts) {
const result = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: script.content }) });
if (!result.valid) throw new Error(`Script “${script.name}” is not valid on this server: ${result.error || "validation failed"}`);
+27 -8
View File
@@ -1,4 +1,4 @@
/* RustBox UI module: 95-bindings-boot.js */
function bindEditor() {
$$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => {
$("#compose-editor").focus();
@@ -66,15 +66,12 @@ function positionToolbarMenu(menu) {
const popover = menu.querySelector(".toolbar-menu-popover");
const summary = menu.querySelector("summary");
if (!popover || !summary) return;
if (matchMedia("(max-width: 560px)").matches) {
Object.assign(popover.style, { left: "8px", right: "8px", top: "auto", bottom: "8px" });
return;
}
Object.assign(popover.style, { right: "auto", bottom: "auto" });
const rect = summary.getBoundingClientRect();
const width = Math.max(164, popover.offsetWidth || 164);
const height = popover.offsetHeight || 80;
const margin = 8;
const mobile = matchMedia("(max-width: 560px)").matches;
const width = Math.min(Math.max(mobile ? 180 : 164, popover.offsetWidth || 164), window.innerWidth - (margin * 2));
const height = popover.offsetHeight || 80;
let left = Math.min(rect.left, window.innerWidth - width - margin);
left = Math.max(margin, left);
let top = rect.bottom + 5;
@@ -256,6 +253,7 @@ function bindEvents() {
$("#reader-back").addEventListener("click", closeReader);
$("#reader-prev").addEventListener("click", () => navigateMessage(-1));
$("#reader-next").addEventListener("click", () => navigateMessage(1));
$("#reader-fullscreen").addEventListener("click", toggleReaderFullscreen);
$("#thread-toggle").addEventListener("click", () => {
state.threadExpanded = !state.threadExpanded;
localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));
@@ -484,6 +482,11 @@ function bindEvents() {
else if (event.key === "!") { event.preventDefault(); junkCurrent(); }
else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); }
}
if (event.key === "Escape" && $("#reader")?.classList.contains("reader-fullscreen")) {
event.preventDefault();
exitReaderFullscreen();
return;
}
if (event.key === "Escape" && innerWidth <= 820) {
if ($("#folder-pane")?.classList.contains("open")) setFolderDrawerOpen(false);
else closeReader();
@@ -494,6 +497,16 @@ function bindEvents() {
else if (state.prefs.theme === "system") applyPreferences();
});
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
window.addEventListener("popstate", async (event) => {
const full = event.state?.rustboxMailFullscreen === true;
if (!full) {
if ($("#reader")?.classList.contains("reader-fullscreen")) setReaderFullscreen(false, { pushHistory: false });
return;
}
const folder = event.state.folder;
const uid = Number(event.state.uid);
if (folder && Number.isFinite(uid)) await openMessageTarget(state.accountId, folder, uid, true);
});
}
async function boot() {
@@ -501,7 +514,13 @@ async function boot() {
bindEvents();
await loadAuthPolicy();
const authenticated = await loadAccounts();
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
if (authenticated) {
await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime();
const params = new URLSearchParams(location.search);
const folder = params.get("mail_folder");
const uid = Number(params.get("mail_uid"));
if (folder && Number.isFinite(uid) && uid > 0) await openMessageTarget(state.accountId, folder, uid, params.get("mail_fullscreen") === "1");
} else { await loadTranslations(state.prefs.language); }
}
boot();