1 parent
d55640d37a
commit
fff8521a17
67 files changed
+11016
-16763
No files matched your search
@@ -0,0 +1,153 @@
|
||||
/* RustBox UI module: 00-bootstrap.js */
|
||||
(() => {
|
||||
"use strict";
|
||||
|
||||
const root = document.documentElement;
|
||||
const configuredBase = root.dataset.basePath || "/";
|
||||
const BASE = configuredBase === "/" ? "" : configuredBase.replace(/\/$/, "");
|
||||
const API = `${BASE}/api`;
|
||||
const ICONS = `${BASE}/assets/icons.svg`;
|
||||
|
||||
const state = {
|
||||
accounts: [],
|
||||
accountId: localStorage.getItem("rustbox.activeAccount") || null,
|
||||
folders: [],
|
||||
folder: "INBOX",
|
||||
collapsedFolders: new Set(),
|
||||
page: 0,
|
||||
perPage: 50,
|
||||
total: 0,
|
||||
messages: [],
|
||||
selected: null,
|
||||
selectedMessages: new Set(),
|
||||
selectionMode: false,
|
||||
searchContext: null,
|
||||
searchAllResults: null,
|
||||
currentDetail: null,
|
||||
specialFolders: { sent: null, drafts: null, trash: null, junk: null, archive: null },
|
||||
tags: [],
|
||||
sieveScripts: [],
|
||||
sieveInfo: null,
|
||||
sieveEditingRuleId: null,
|
||||
identities: [],
|
||||
pgpKeys: [],
|
||||
pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false },
|
||||
pgpRecipientStatus: [],
|
||||
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24 },
|
||||
contacts: [],
|
||||
trustedSenders: new Set(),
|
||||
contactGroups: [],
|
||||
contactPhotoUrl: null,
|
||||
calendarCursor: new Date(),
|
||||
calendarView: localStorage.getItem("rustbox.calendarView") || "month",
|
||||
threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false",
|
||||
calendarEvents: [],
|
||||
savedSearches: [],
|
||||
responseTemplates: [],
|
||||
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
|
||||
mailPrefs: { messages_per_page: 50, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false },
|
||||
currentFolderFlaggedOnTop: false,
|
||||
markReadTimer: null,
|
||||
ws: null,
|
||||
wsRetryTimer: null,
|
||||
realtimeRefreshTimer: null,
|
||||
calendarReminderTimer: null,
|
||||
composeDraftId: null,
|
||||
composeAttachments: [],
|
||||
composeDirty: false,
|
||||
composeNeedsRemoteSync: false,
|
||||
composeUploading: false,
|
||||
composeReplyMeta: null,
|
||||
composeInsertRange: null,
|
||||
composeSelectedImage: null,
|
||||
composeSignatureGeneration: 0,
|
||||
identitySignatureRange: null,
|
||||
inlineAttachmentIds: new Set(),
|
||||
inlineImageCleanupTimer: null,
|
||||
imageEditorBitmap: null,
|
||||
imageEditorBlob: null,
|
||||
draftSaveTimer: null,
|
||||
prefs: {
|
||||
theme: "system", interfaceSkin: "rustbox", density: "compact", preview: "right", scale: "normal", language: "en", composeFormat: "html",
|
||||
composeAutosaveMs: 3000, defaultPriority: "normal", requestMdnDefault: false, attachmentReminder: true, composeFontFamily: "system", replyPosition: "above", quoteStyle: "standard", forwardMode: "inline", spellcheckEnabled: true,
|
||||
colorPreset: "sage", accentColor: "#6f9c86", backgroundColor: "#121513", panelColor: "#181c19", textColor: "#edf0ec", fontFamily: "system", fontSize: 13,
|
||||
rowHeight: 58, folderPaneWidth: 228, messagePaneWidth: 420, borderRadius: 4,
|
||||
contrast: "normal", showAvatars: true, showAttachmentIcons: true, showFlags: true, reduceMotion: false, dateFormat: "locale", timeFormat: "locale",
|
||||
iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 29, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#28352e", unreadFontWeight: 730, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false, folderBackground: "", messageBackground: "", readerBackground: "",
|
||||
},
|
||||
translations: {},
|
||||
quotaWarningLevel: null,
|
||||
networkOnline: navigator.onLine,
|
||||
listStateTimer: null,
|
||||
};
|
||||
|
||||
const $ = (selector, parent = document) => parent.querySelector(selector);
|
||||
const $$ = (selector, parent = document) => [...parent.querySelectorAll(selector)];
|
||||
const el = (tag, className, text) => {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text;
|
||||
return node;
|
||||
};
|
||||
|
||||
function svgIcon(name) {
|
||||
return `<svg aria-hidden="true"><use href="${ICONS}#icon-${name}"></use></svg>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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(/<svg\b[\s\S]*?(?:\/assets\/icons\.svg|assets\/icons\.svg)[\s\S]*?<\/svg>/gi, "");
|
||||
return text;
|
||||
}
|
||||
|
||||
function cleanPlainMessageText(value) {
|
||||
return stripRustBoxUiLeak(value);
|
||||
}
|
||||
|
||||
function sanitizeMessageHtml(html) {
|
||||
const doc = new DOMParser().parseFromString(stripRustBoxUiLeak(html), "text/html");
|
||||
doc.querySelectorAll("script,iframe,frame,frameset,object,embed,applet,form,input,button,select,textarea,base,meta,link,svg,math").forEach((node) => node.remove());
|
||||
doc.querySelectorAll("*").forEach((node) => {
|
||||
for (const attr of [...node.attributes]) {
|
||||
const name = attr.name.toLowerCase();
|
||||
const value = attr.value.trim();
|
||||
if (name.startsWith("on") || ["srcdoc", "formaction"].includes(name)) node.removeAttribute(attr.name);
|
||||
if (["href", "src", "background", "action", "poster"].includes(name) && /^\s*javascript:/i.test(value)) node.removeAttribute(attr.name);
|
||||
}
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function sanitizeAttachmentHtml(html) {
|
||||
const doc = new DOMParser().parseFromString(sanitizeMessageHtml(html), "text/html");
|
||||
doc.querySelectorAll("a[href]").forEach((node) => node.removeAttribute("href"));
|
||||
doc.querySelectorAll("[src],[background],[poster]").forEach((node) => {
|
||||
for (const name of ["src", "background", "poster"]) {
|
||||
if (!node.hasAttribute(name)) continue;
|
||||
const value = String(node.getAttribute(name) || "").trim();
|
||||
if (!/^data:/i.test(value)) node.removeAttribute(name);
|
||||
}
|
||||
});
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
function apiPath(path) {
|
||||
return `${API}${path.startsWith("/") ? path : `/${path}`}`;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
/* RustBox UI module: 10-core.js */
|
||||
function setConnectivityState(online, detail = "") {
|
||||
state.networkOnline = Boolean(online);
|
||||
document.body.classList.toggle("network-offline", !state.networkOnline);
|
||||
const banner = $("#offline-banner");
|
||||
if (banner) {
|
||||
banner.classList.toggle("hidden", state.networkOnline);
|
||||
banner.textContent = detail || (navigator.onLine ? "RustBox cannot reach the server. Read-only cached content may still be visible." : "You are offline. RustBox will reconnect when the network returns.");
|
||||
}
|
||||
const label = $("#connection-label");
|
||||
if (!label) return;
|
||||
if (!state.networkOnline) label.textContent = navigator.onLine ? "Reconnecting" : "Offline";
|
||||
else if (["Offline", "Reconnecting", "Connection error"].includes(label.textContent)) label.textContent = "Connected";
|
||||
}
|
||||
|
||||
function listStateStorageKey(folder = state.folder) {
|
||||
return `rustbox.listState.${state.accountId || "anonymous"}.${encodeURIComponent(folder || "INBOX")}`;
|
||||
}
|
||||
|
||||
function lastFolderStorageKey() {
|
||||
return `rustbox.lastFolder.${state.accountId || "anonymous"}`;
|
||||
}
|
||||
|
||||
function activeSearchStorageKey() {
|
||||
return `rustbox.activeSearch.${state.accountId || "anonymous"}`;
|
||||
}
|
||||
|
||||
function saveActiveSearchContext() {
|
||||
if (!state.searchContext) return sessionStorage.removeItem(activeSearchStorageKey());
|
||||
sessionStorage.setItem(activeSearchStorageKey(), JSON.stringify(state.searchContext));
|
||||
}
|
||||
|
||||
function readActiveSearchContext() {
|
||||
try {
|
||||
const parsed = JSON.parse(sessionStorage.getItem(activeSearchStorageKey()) || "null");
|
||||
return parsed && typeof parsed.params === "string" ? parsed : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearActiveSearchContext() {
|
||||
state.searchContext = null;
|
||||
state.searchAllResults = null;
|
||||
sessionStorage.removeItem(activeSearchStorageKey());
|
||||
}
|
||||
|
||||
function readMessageListState(folder = state.folder) {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(listStateStorageKey(folder)) || "null");
|
||||
if (!parsed || typeof parsed !== "object") return null;
|
||||
return {
|
||||
scrollTop: Math.max(0, Number(parsed.scrollTop) || 0),
|
||||
page: Math.max(0, Number(parsed.page) || 0),
|
||||
};
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveMessageListState() {
|
||||
if (!state.accountId) return;
|
||||
const list = $("#message-list");
|
||||
if (!list) return;
|
||||
if (state.searchContext) {
|
||||
state.searchContext.scrollTop = list.scrollTop;
|
||||
saveActiveSearchContext();
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(listStateStorageKey(), JSON.stringify({ scrollTop: list.scrollTop, page: state.page }));
|
||||
localStorage.setItem(lastFolderStorageKey(), state.folder);
|
||||
}
|
||||
|
||||
function scheduleMessageListStateSave() {
|
||||
clearTimeout(state.listStateTimer);
|
||||
state.listStateTimer = setTimeout(saveMessageListState, 120);
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const headers = new Headers(options.headers || {});
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
if (options.body && !(options.body instanceof FormData)) headers.set("Content-Type", "application/json");
|
||||
if (options.method && options.method !== "GET") headers.set("X-Requested-With", "XMLHttpRequest");
|
||||
|
||||
const method = String(options.method || "GET").toUpperCase();
|
||||
const maxAttempts = method === "GET" || method === "HEAD" ? 3 : 1;
|
||||
let lastNetworkError = null;
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(apiPath(path), {
|
||||
credentials: "include",
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
if ([502, 503, 504].includes(response.status) && attempt + 1 < maxAttempts) {
|
||||
await sleep(250 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const payload = contentType.includes("application/json")
|
||||
? await response.json().catch(() => ({}))
|
||||
: await response.text().catch(() => "");
|
||||
if (!response.ok) {
|
||||
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Request failed (${response.status})`;
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
setConnectivityState(true);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.status) throw error;
|
||||
lastNetworkError = error;
|
||||
setConnectivityState(false);
|
||||
if (attempt + 1 < maxAttempts) {
|
||||
await sleep(350 * (attempt + 1));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
const error = new Error(navigator.onLine ? "RustBox server is temporarily unreachable" : "You are offline");
|
||||
error.cause = lastNetworkError;
|
||||
error.network = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
function uploadRequest(path, formData, onProgress = null) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", apiPath(path), true);
|
||||
xhr.withCredentials = true;
|
||||
if (state.accountId) xhr.setRequestHeader("X-Active-Account", state.accountId);
|
||||
xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest");
|
||||
if (typeof onProgress === "function") {
|
||||
xhr.upload.addEventListener("progress", (event) => {
|
||||
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("load", () => {
|
||||
const contentType = xhr.getResponseHeader("content-type") || "";
|
||||
let payload = xhr.responseText || "";
|
||||
if (contentType.includes("application/json")) {
|
||||
try { payload = payload ? JSON.parse(payload) : {}; } catch (_) { payload = {}; }
|
||||
}
|
||||
if (xhr.status < 200 || xhr.status >= 300) {
|
||||
const message = payload?.error?.message || (typeof payload === "string" && payload) || `Upload failed (${xhr.status})`;
|
||||
const error = new Error(message);
|
||||
error.status = xhr.status;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve(payload);
|
||||
});
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
|
||||
function toast(message, kind = "info", duration = null, action = null) {
|
||||
const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info";
|
||||
const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800;
|
||||
const node = el("div", `toast toast-${normalized}`);
|
||||
node.setAttribute("role", normalized === "error" ? "alert" : "status");
|
||||
const iconName = normalized === "success" ? "check-circle" : normalized === "error" || normalized === "warning" ? "circle-alert" : "info";
|
||||
const icon = el("span", "toast-icon");
|
||||
icon.innerHTML = svgIcon(iconName);
|
||||
const text = el("span", "toast-message", message);
|
||||
let actionButton = null;
|
||||
if (action?.label && typeof action.onClick === "function") {
|
||||
actionButton = el("button", "toast-action", action.label);
|
||||
actionButton.type = "button";
|
||||
}
|
||||
const countdown = el("span", "toast-countdown");
|
||||
countdown.setAttribute("aria-hidden", "true");
|
||||
const close = el("button", "toast-close");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Dismiss notification");
|
||||
close.innerHTML = svgIcon("x");
|
||||
const controls = el("span", "toast-controls");
|
||||
if (actionButton) controls.append(actionButton);
|
||||
controls.append(close);
|
||||
node.append(icon, text, countdown, controls);
|
||||
const openDialogs = $$("dialog[open]");
|
||||
let stack = $("#toast-stack");
|
||||
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);
|
||||
}
|
||||
}
|
||||
stack.append(node);
|
||||
requestAnimationFrame(() => node.classList.add("visible"));
|
||||
|
||||
let remaining = lifetime;
|
||||
let startedAt = 0;
|
||||
let timeout = null;
|
||||
let ticker = null;
|
||||
let dismissed = false;
|
||||
const renderCountdown = () => { countdown.textContent = `${Math.max(1, Math.ceil(remaining / 1000))}s`; };
|
||||
const stopTimers = () => { clearTimeout(timeout); clearInterval(ticker); timeout = null; ticker = null; };
|
||||
const dismiss = (immediate = false) => {
|
||||
if (dismissed) return;
|
||||
dismissed = true;
|
||||
stopTimers();
|
||||
if (immediate) return node.remove();
|
||||
node.classList.remove("visible");
|
||||
setTimeout(() => node.remove(), 160);
|
||||
};
|
||||
const pause = () => {
|
||||
if (dismissed || !startedAt) return;
|
||||
remaining = Math.max(0, remaining - (performance.now() - startedAt));
|
||||
startedAt = 0;
|
||||
stopTimers();
|
||||
renderCountdown();
|
||||
};
|
||||
const resume = () => {
|
||||
if (dismissed || remaining <= 0) return dismiss();
|
||||
stopTimers();
|
||||
startedAt = performance.now();
|
||||
renderCountdown();
|
||||
timeout = setTimeout(() => dismiss(), remaining);
|
||||
ticker = setInterval(() => {
|
||||
if (!startedAt) return;
|
||||
const elapsed = performance.now() - startedAt;
|
||||
const visibleRemaining = Math.max(0, remaining - elapsed);
|
||||
countdown.textContent = `${Math.max(1, Math.ceil(visibleRemaining / 1000))}s`;
|
||||
}, 200);
|
||||
};
|
||||
close.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dismiss(true);
|
||||
});
|
||||
if (actionButton) {
|
||||
actionButton.addEventListener("click", async (event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
dismiss(true);
|
||||
try { await action.onClick(); }
|
||||
catch (error) { toast(error?.message || "Action failed", "error"); }
|
||||
});
|
||||
}
|
||||
node.addEventListener("mouseenter", pause);
|
||||
node.addEventListener("mouseleave", resume);
|
||||
node.addEventListener("focusin", pause);
|
||||
node.addEventListener("focusout", (event) => { if (!node.contains(event.relatedTarget)) resume(); });
|
||||
resume();
|
||||
}
|
||||
|
||||
function appDialog({ title = "RustBox", message = "", input = null, selectOptions = null, confirmLabel = "OK", cancelLabel = "Cancel", danger = false } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = el("dialog", "dialog app-prompt-dialog");
|
||||
const form = el("form", "dialog-card app-prompt-card");
|
||||
form.method = "dialog";
|
||||
const header = el("header", "dialog-header");
|
||||
header.append(el("strong", "", title));
|
||||
const close = el("button", "icon-button");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", cancelLabel);
|
||||
close.innerHTML = svgIcon("x");
|
||||
header.append(close);
|
||||
const body = el("div", "app-prompt-body");
|
||||
if (message) body.append(el("p", "app-prompt-message", message));
|
||||
let control = null;
|
||||
if (Array.isArray(selectOptions)) {
|
||||
control = document.createElement("select");
|
||||
control.className = "app-prompt-control";
|
||||
for (const option of selectOptions) control.add(new Option(option.label ?? option.value, option.value));
|
||||
if (input?.value !== undefined) control.value = String(input.value);
|
||||
body.append(control);
|
||||
} else if (input) {
|
||||
control = input.multiline ? document.createElement("textarea") : document.createElement("input");
|
||||
control.className = "app-prompt-control";
|
||||
if (!input.multiline) control.type = input.type || "text";
|
||||
control.value = input.value ?? "";
|
||||
if (input.placeholder) control.placeholder = input.placeholder;
|
||||
if (input.required) control.required = true;
|
||||
body.append(control);
|
||||
}
|
||||
const actions = el("footer", "dialog-actions app-prompt-actions");
|
||||
const cancel = el("button", "button quiet", cancelLabel);
|
||||
cancel.type = "button";
|
||||
const confirm = el("button", danger ? "button danger" : "button primary", confirmLabel);
|
||||
confirm.type = "submit";
|
||||
actions.append(cancel, confirm);
|
||||
form.append(header, body, actions);
|
||||
dialog.append(form);
|
||||
document.body.append(dialog);
|
||||
|
||||
let settled = false;
|
||||
const finish = (value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolve(value);
|
||||
if (dialog.open) dialog.close();
|
||||
else dialog.remove();
|
||||
};
|
||||
close.addEventListener("click", () => finish(null));
|
||||
cancel.addEventListener("click", () => finish(null));
|
||||
dialog.addEventListener("cancel", (event) => { event.preventDefault(); finish(null); });
|
||||
dialog.addEventListener("close", () => { if (!settled) { settled = true; resolve(null); } dialog.remove(); }, { once: true });
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
if (control && !control.reportValidity()) return;
|
||||
finish(control ? control.value : true);
|
||||
});
|
||||
dialog.showModal();
|
||||
setTimeout(() => (control || confirm).focus(), 20);
|
||||
});
|
||||
}
|
||||
|
||||
async function appConfirm(message, { title = "Confirm action", confirmLabel = "Continue", danger = false } = {}) {
|
||||
return (await appDialog({ title, message, confirmLabel, danger })) === true;
|
||||
}
|
||||
|
||||
function appPrompt(title, { message = "", value = "", placeholder = "", type = "text", confirmLabel = "OK", required = false } = {}) {
|
||||
return appDialog({ title, message, input: { value, placeholder, type, required }, confirmLabel });
|
||||
}
|
||||
|
||||
function appSelect(title, options, { message = "", value = "", confirmLabel = "Choose" } = {}) {
|
||||
return appDialog({ title, message, input: { value }, selectOptions: options, confirmLabel });
|
||||
}
|
||||
|
||||
function setBusy(button, busy) {
|
||||
if (!button) return;
|
||||
button.disabled = busy;
|
||||
button.setAttribute("aria-busy", String(busy));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,558 @@
|
||||
/* RustBox UI module: 20-preferences-auth.js */
|
||||
function resolveTheme(theme) {
|
||||
if (theme !== "system") return theme;
|
||||
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||||
}
|
||||
|
||||
const preferenceMap = {
|
||||
theme: "theme", interfaceSkin: "interface_skin", density: "density", preview: "preview_pane", scale: "ui_scale", language: "language",
|
||||
colorPreset: "color_preset", accentColor: "accent_color", backgroundColor: "background_color", panelColor: "panel_color", textColor: "text_color", fontFamily: "font_family", fontSize: "font_size",
|
||||
rowHeight: "row_height", folderPaneWidth: "folder_pane_width", messagePaneWidth: "message_pane_width",
|
||||
borderRadius: "border_radius", contrast: "contrast", showAvatars: "show_avatars",
|
||||
showAttachmentIcons: "show_attachment_icons", showFlags: "show_flags", reduceMotion: "reduce_motion", composeFormat: "compose_format",
|
||||
composeAutosaveMs: "compose_autosave_ms", defaultPriority: "default_priority", requestMdnDefault: "request_mdn_default", attachmentReminder: "attachment_reminder", composeFontFamily: "compose_font_family", replyPosition: "reply_position", quoteStyle: "quote_style", forwardMode: "forward_mode", spellcheckEnabled: "spellcheck_enabled",
|
||||
dateFormat: "date_format", timeFormat: "time_format", iconSize: "icon_size", toolbarDensity: "toolbar_density", sidebarItemHeight: "sidebar_item_height",
|
||||
readerMaxWidth: "reader_max_width", composerWidth: "composer_width", selectionColor: "selection_color", unreadFontWeight: "unread_font_weight", unreadBadgeStyle: "unread_badge_style",
|
||||
compactHeaders: "compact_headers", showBorders: "show_borders", scrollbarDensity: "scrollbar_density", monochrome: "monochrome", folderBackground: "folder_background", messageBackground: "message_background", readerBackground: "reader_background",
|
||||
};
|
||||
const preferenceTimers = new Map();
|
||||
|
||||
const interfaceStyleProfiles = {
|
||||
"roundcube-classic": {
|
||||
label: "Roundcube Classic",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 12, rowHeight: 44, folderPaneWidth: 224, messagePaneWidth: 410, borderRadius: 0, contrast: "normal", iconSize: 16, toolbarDensity: "compact", sidebarItemHeight: 24, readerMaxWidth: 0, composerWidth: 900, selectionColor: "#cc3333", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
"roundcube-larry": {
|
||||
label: "Roundcube Larry",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 48, folderPaneWidth: 230, messagePaneWidth: 420, borderRadius: 3, contrast: "normal", iconSize: 17, toolbarDensity: "normal", sidebarItemHeight: 28, readerMaxWidth: 0, composerWidth: 920, selectionColor: "#c7e3ef", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
"roundcube-elastic": {
|
||||
label: "Roundcube Elastic",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 52, folderPaneWidth: 238, messagePaneWidth: 440, borderRadius: 4, contrast: "normal", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 35, readerMaxWidth: 0, composerWidth: 940, selectionColor: "#ebf8ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
clean: {
|
||||
label: "Clean",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 58, folderPaneWidth: 236, messagePaneWidth: 430, borderRadius: 8, contrast: "soft", iconSize: 18, toolbarDensity: "normal", sidebarItemHeight: 32, readerMaxWidth: 980, composerWidth: 940, selectionColor: "#dce9ff", unreadFontWeight: 700, unreadBadgeStyle: "dot", compactHeaders: false, showBorders: false, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
graphite: {
|
||||
label: "Graphite",
|
||||
preferences: { density: "compact", preview: "right", scale: "normal", fontFamily: "system", fontSize: 13, rowHeight: 50, folderPaneWidth: 220, messagePaneWidth: 400, borderRadius: 2, contrast: "normal", iconSize: 17, toolbarDensity: "compact", sidebarItemHeight: 27, readerMaxWidth: 0, composerWidth: 880, selectionColor: "#32373c", unreadFontWeight: 720, unreadBadgeStyle: "solid", compactHeaders: true, showBorders: true, scrollbarDensity: "thin", monochrome: false },
|
||||
},
|
||||
"high-contrast": {
|
||||
label: "High contrast",
|
||||
preferences: { density: "comfortable", preview: "right", scale: "normal", fontFamily: "system", fontSize: 14, rowHeight: 60, folderPaneWidth: 240, messagePaneWidth: 440, borderRadius: 2, contrast: "high", iconSize: 20, toolbarDensity: "normal", sidebarItemHeight: 34, readerMaxWidth: 980, composerWidth: 960, selectionColor: "#ffe500", unreadFontWeight: 800, unreadBadgeStyle: "solid", compactHeaders: false, showBorders: true, scrollbarDensity: "normal", monochrome: false },
|
||||
},
|
||||
};
|
||||
|
||||
function activeInterfaceStyle(prefs = state.prefs) {
|
||||
return interfaceStyleProfiles[prefs.interfaceSkin] || null;
|
||||
}
|
||||
|
||||
async function loadTranslations(language) {
|
||||
const locale = ["en", "pl"].includes(language) ? language : "en";
|
||||
try {
|
||||
const response = await fetch(`${BASE}/assets/i18n/${locale}.json`, { credentials: "same-origin" });
|
||||
state.translations = response.ok ? await response.json() : {};
|
||||
} catch (_) { state.translations = {}; }
|
||||
root.lang = locale;
|
||||
$$('[data-i18n]').forEach((node) => { const key = node.dataset.i18n; if (state.translations[key]) node.textContent = state.translations[key]; });
|
||||
$$('[data-i18n-placeholder]').forEach((node) => { const key = node.dataset.i18nPlaceholder; if (state.translations[key]) node.placeholder = state.translations[key]; });
|
||||
$$('[data-i18n-title]').forEach((node) => { const key = node.dataset.i18nTitle; if (state.translations[key]) node.title = state.translations[key]; });
|
||||
$$('[data-i18n-aria-label]').forEach((node) => { const key = node.dataset.i18nAriaLabel; if (state.translations[key]) node.setAttribute('aria-label', state.translations[key]); });
|
||||
}
|
||||
|
||||
function tr(key, fallback = key) {
|
||||
return state.translations[key] || fallback;
|
||||
}
|
||||
|
||||
function applyPreferences() {
|
||||
const p = state.prefs;
|
||||
const fixedStyle = activeInterfaceStyle(p);
|
||||
const visual = fixedStyle ? { ...p, ...fixedStyle.preferences } : p;
|
||||
root.dataset.theme = resolveTheme(p.theme);
|
||||
root.dataset.interfaceSkin = p.interfaceSkin || "rustbox";
|
||||
root.dataset.density = visual.density;
|
||||
root.dataset.preview = visual.preview;
|
||||
root.dataset.scale = visual.scale;
|
||||
root.dataset.colorPreset = p.colorPreset;
|
||||
root.dataset.fontFamily = visual.fontFamily;
|
||||
root.dataset.contrast = visual.contrast;
|
||||
root.dataset.hideAvatars = String(!p.showAvatars);
|
||||
root.dataset.hideAttachments = String(!p.showAttachmentIcons);
|
||||
root.dataset.hideFlags = String(!p.showFlags);
|
||||
root.dataset.reduceMotion = String(p.reduceMotion);
|
||||
root.dataset.toolbarDensity = visual.toolbarDensity;
|
||||
root.dataset.unreadBadge = visual.unreadBadgeStyle;
|
||||
root.dataset.compactHeaders = String(visual.compactHeaders);
|
||||
root.dataset.showBorders = String(visual.showBorders);
|
||||
root.dataset.scrollbarDensity = visual.scrollbarDensity;
|
||||
root.dataset.monochrome = String(visual.monochrome);
|
||||
root.style.setProperty("--user-accent", p.accentColor);
|
||||
root.style.setProperty("--user-bg", p.backgroundColor);
|
||||
root.style.setProperty("--user-panel", p.panelColor);
|
||||
root.style.setProperty("--user-text", p.textColor);
|
||||
const scaleFactor = ({ small: 0.90, normal: 1, large: 1.12 })[visual.scale] || 1;
|
||||
const densityFactor = visual.density === "comfortable" ? 1.13 : 0.93;
|
||||
root.style.setProperty("--ui-scale-factor", String(scaleFactor));
|
||||
root.style.setProperty("--density-factor", String(densityFactor));
|
||||
root.style.setProperty("--ui-size", `${Math.round(Number(visual.fontSize || 13) * scaleFactor * 100) / 100}px`);
|
||||
root.style.setProperty("--row-height", `${Math.max(42, Math.round(Number(visual.rowHeight || 58) * scaleFactor))}px`);
|
||||
root.style.setProperty("--folder-pane-width", `${Math.max(170, Math.round(Number(visual.folderPaneWidth || 228) * scaleFactor))}px`);
|
||||
root.style.setProperty("--message-pane-width", `${Math.max(300, Math.round(Number(visual.messagePaneWidth || 420) * scaleFactor))}px`);
|
||||
root.style.setProperty("--control-height", `${Math.max(26, Math.round(30 * scaleFactor * densityFactor))}px`);
|
||||
root.style.setProperty("--appbar-height", `${Math.max(38, Math.round(44 * scaleFactor * densityFactor))}px`);
|
||||
root.style.setProperty("--dialog-header-height", `${Math.max(38, Math.round(43 * scaleFactor * densityFactor))}px`);
|
||||
root.style.setProperty("--icon-size", `${Math.max(14, Math.round(Number(visual.iconSize || 18) * scaleFactor))}px`);
|
||||
const toolbarFactor = ({ compact: 0.88, normal: 1, spacious: 1.14 })[visual.toolbarDensity] || 1;
|
||||
root.style.setProperty("--toolbar-density-factor", String(toolbarFactor));
|
||||
root.style.setProperty("--sidebar-item-height", `${Math.max(24, Math.round(Number(visual.sidebarItemHeight || 29) * scaleFactor))}px`);
|
||||
root.style.setProperty("--reader-max-width", Number(visual.readerMaxWidth) > 0 ? `${Number(visual.readerMaxWidth)}px` : "none");
|
||||
root.style.setProperty("--composer-width", `${Math.max(640, Number(visual.composerWidth || 900))}px`);
|
||||
root.style.setProperty("--compose-font-family", composeFontCss(p.composeFontFamily));
|
||||
const lockedRoundcubeSkin = Boolean(fixedStyle);
|
||||
if (lockedRoundcubeSkin) {
|
||||
["--selected", "--folder-bg", "--message-bg", "--reader-bg"].forEach((name) => root.style.removeProperty(name));
|
||||
} else {
|
||||
root.style.setProperty("--selected", visual.selectionColor || "#28352e");
|
||||
root.style.setProperty("--folder-bg", p.folderBackground || "var(--sidebar)");
|
||||
root.style.setProperty("--message-bg", p.messageBackground || "var(--panel)");
|
||||
root.style.setProperty("--reader-bg", p.readerBackground || "var(--panel)");
|
||||
}
|
||||
root.style.setProperty("--unread-font-weight", String(Number(visual.unreadFontWeight || 730)));
|
||||
root.style.setProperty("--button-pad-x", `${Math.max(8, Math.round(10 * scaleFactor))}px`);
|
||||
root.style.setProperty("--button-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`);
|
||||
root.style.setProperty("--field-pad-x", `${Math.max(7, Math.round(8 * scaleFactor))}px`);
|
||||
root.style.setProperty("--field-pad-y", `${Math.max(3, Math.round(5 * scaleFactor * densityFactor))}px`);
|
||||
root.style.setProperty("--radius-control", `${visual.borderRadius}px`);
|
||||
root.style.setProperty("--radius-panel", `${Math.min(visual.borderRadius + 1, 13)}px`);
|
||||
|
||||
const values = {
|
||||
"#pref-theme": p.theme, "#pref-interface-skin": p.interfaceSkin, "#pref-density": visual.density, "#pref-preview": visual.preview, "#pref-scale": visual.scale,
|
||||
"#pref-language": p.language, "#pref-color-preset": p.colorPreset, "#pref-accent": p.accentColor,
|
||||
"#pref-background": p.backgroundColor, "#pref-panel": p.panelColor, "#pref-text-color": p.textColor,
|
||||
"#pref-font-family": visual.fontFamily, "#pref-font-size": visual.fontSize, "#pref-row-height": visual.rowHeight,
|
||||
"#pref-folder-width": visual.folderPaneWidth, "#pref-message-width": visual.messagePaneWidth, "#pref-radius": visual.borderRadius,
|
||||
"#pref-contrast": visual.contrast, "#pref-date-format": p.dateFormat, "#pref-time-format": p.timeFormat,
|
||||
"#compose-pref-format": p.composeFormat, "#compose-pref-autosave": String(p.composeAutosaveMs), "#compose-pref-priority": p.defaultPriority, "#compose-pref-font": p.composeFontFamily, "#compose-pref-reply-position": p.replyPosition, "#compose-pref-quote-style": p.quoteStyle, "#compose-pref-forward-mode": p.forwardMode,
|
||||
"#pref-icon-size": visual.iconSize, "#pref-toolbar-density": visual.toolbarDensity, "#pref-sidebar-height": visual.sidebarItemHeight, "#pref-reader-width": String(visual.readerMaxWidth), "#pref-composer-width": visual.composerWidth,
|
||||
"#pref-selection-color": visual.selectionColor, "#pref-unread-weight": visual.unreadFontWeight, "#pref-unread-badge": visual.unreadBadgeStyle, "#pref-scrollbar-density": visual.scrollbarDensity,
|
||||
};
|
||||
Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
|
||||
const checks = { "#pref-avatars": p.showAvatars, "#pref-attachment-icons": p.showAttachmentIcons, "#pref-flags": p.showFlags, "#pref-reduce-motion": p.reduceMotion, "#compose-pref-mdn": p.requestMdnDefault, "#compose-pref-attachment-reminder": p.attachmentReminder, "#compose-pref-spellcheck": p.spellcheckEnabled, "#pref-compact-headers": visual.compactHeaders, "#pref-show-borders": visual.showBorders, "#pref-monochrome": visual.monochrome, "#pref-folder-bg-enabled": Boolean(p.folderBackground), "#pref-message-bg-enabled": Boolean(p.messageBackground), "#pref-reader-bg-enabled": Boolean(p.readerBackground) };
|
||||
Object.entries(checks).forEach(([selector, value]) => { const node = $(selector); if (node) node.checked = Boolean(value); });
|
||||
const outputs = { "#pref-accent-value": p.accentColor, "#pref-background-value": p.backgroundColor, "#pref-panel-value": p.panelColor, "#pref-text-color-value": p.textColor, "#pref-font-size-value": `${visual.fontSize} px`, "#pref-row-height-value": `${visual.rowHeight} px`, "#pref-folder-width-value": `${visual.folderPaneWidth} px`, "#pref-message-width-value": `${visual.messagePaneWidth} px`, "#pref-radius-value": `${visual.borderRadius} px`, "#pref-icon-size-value": `${visual.iconSize} px`, "#pref-sidebar-height-value": `${visual.sidebarItemHeight} px`, "#pref-composer-width-value": `${visual.composerWidth} px`, "#pref-selection-color-value": visual.selectionColor, "#pref-unread-weight-value": String(visual.unreadFontWeight) };
|
||||
Object.entries(outputs).forEach(([selector, value]) => { const node = $(selector); if (node) node.textContent = value; });
|
||||
[["#pref-folder-bg", p.folderBackground || "#101310"], ["#pref-message-bg", p.messageBackground || "#181c19"], ["#pref-reader-bg", p.readerBackground || "#181c19"]].forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
|
||||
const customizationLocked = Boolean(fixedStyle);
|
||||
[
|
||||
"#pref-color-preset", "#pref-accent", "#pref-background", "#pref-panel", "#pref-text-color",
|
||||
"#pref-density", "#pref-preview", "#pref-scale", "#pref-font-family", "#pref-font-size",
|
||||
"#pref-row-height", "#pref-folder-width", "#pref-message-width", "#pref-radius", "#pref-contrast",
|
||||
"#pref-icon-size", "#pref-toolbar-density", "#pref-sidebar-height", "#pref-reader-width", "#pref-composer-width",
|
||||
"#pref-selection-color", "#pref-unread-weight", "#pref-unread-badge", "#pref-scrollbar-density",
|
||||
"#pref-compact-headers", "#pref-show-borders", "#pref-monochrome",
|
||||
"#pref-folder-bg-enabled", "#pref-folder-bg", "#pref-message-bg-enabled", "#pref-message-bg", "#pref-reader-bg-enabled", "#pref-reader-bg",
|
||||
].forEach((selector) => { const node = $(selector); if (node) node.disabled = customizationLocked; });
|
||||
const skinNote = $("#pref-interface-skin-note");
|
||||
if (skinNote) skinNote.textContent = fixedStyle
|
||||
? `${fixedStyle.label} is a fixed interface style. Theme still switches light/dark; choose RustBox (custom) to edit colors, spacing and geometry.`
|
||||
: "RustBox is fully customizable: colors, spacing, panes, typography and advanced interface controls are available below.";
|
||||
}
|
||||
|
||||
function normalizeServerPreferences(data) {
|
||||
if (!data) return;
|
||||
state.prefs = {
|
||||
...state.prefs,
|
||||
theme: data.theme || "system", interfaceSkin: data.interface_skin || "rustbox", density: data.density || "compact", preview: data.preview_pane || "right", composeFormat: data.compose_format || "html",
|
||||
composeAutosaveMs: Number(data.compose_autosave_ms || 3000), defaultPriority: data.default_priority || "normal", requestMdnDefault: Boolean(data.request_mdn_default), attachmentReminder: data.attachment_reminder !== false, composeFontFamily: data.compose_font_family || "system", replyPosition: data.reply_position || "above", quoteStyle: data.quote_style || "standard", forwardMode: data.forward_mode || "inline", spellcheckEnabled: data.spellcheck_enabled !== false,
|
||||
scale: data.ui_scale || "normal", language: data.language || "en", colorPreset: data.color_preset || "sage",
|
||||
accentColor: data.accent_color || "#6f9c86", backgroundColor: data.background_color || "#121513", panelColor: data.panel_color || "#181c19", textColor: data.text_color || "#edf0ec", fontFamily: data.font_family || "system", fontSize: data.font_size || 13,
|
||||
rowHeight: data.row_height || 58, folderPaneWidth: data.folder_pane_width || 228, messagePaneWidth: data.message_pane_width || 420,
|
||||
borderRadius: data.border_radius ?? 4, contrast: data.contrast || "normal", showAvatars: data.show_avatars !== false,
|
||||
showAttachmentIcons: data.show_attachment_icons !== false, showFlags: data.show_flags !== false, reduceMotion: Boolean(data.reduce_motion), dateFormat: data.date_format || "locale", timeFormat: data.time_format || "locale",
|
||||
iconSize: Number(data.icon_size || 18), toolbarDensity: data.toolbar_density || "normal", sidebarItemHeight: Number(data.sidebar_item_height || 29), readerMaxWidth: Number(data.reader_max_width || 0), composerWidth: Number(data.composer_width || 900), selectionColor: data.selection_color || "#28352e", unreadFontWeight: Number(data.unread_font_weight || 730), unreadBadgeStyle: data.unread_badge_style || "dot", compactHeaders: Boolean(data.compact_headers), showBorders: data.show_borders !== false, scrollbarDensity: data.scrollbar_density || "thin", monochrome: Boolean(data.monochrome), folderBackground: data.folder_background || "", messageBackground: data.message_background || "", readerBackground: data.reader_background || "",
|
||||
};
|
||||
}
|
||||
|
||||
async function loadPreferences() {
|
||||
try { normalizeServerPreferences(await request("/settings/display", { method: "GET" })); }
|
||||
catch (error) { console.warn("Could not load display preferences", error); }
|
||||
applyPreferences();
|
||||
await loadTranslations(state.prefs.language);
|
||||
}
|
||||
|
||||
async function loadMailPreferences() {
|
||||
try {
|
||||
const prefs = await request("/settings/mailbox", { method: "GET" });
|
||||
state.mailPrefs = { ...state.mailPrefs, ...prefs };
|
||||
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
|
||||
const values = {
|
||||
"#mail-pref-per-page": String(state.perPage),
|
||||
"#mail-pref-read-delay": String(state.mailPrefs.mark_read_delay_ms || 0),
|
||||
"#mail-pref-remote-images": state.mailPrefs.remote_images || "ask",
|
||||
"#mail-pref-preferred-body": state.mailPrefs.preferred_body || "auto",
|
||||
"#mail-pref-thread-mode": state.mailPrefs.thread_mode || "threaded",
|
||||
"#mail-pref-sort": state.mailPrefs.default_sort || "date_desc",
|
||||
};
|
||||
Object.entries(values).forEach(([selector, value]) => { const node = $(selector); if (node) node.value = value; });
|
||||
const messageSort = $("#message-sort");
|
||||
if (messageSort) messageSort.value = state.mailPrefs.default_sort || "date_desc";
|
||||
const collapse = $("#mail-pref-collapse-quotes");
|
||||
if (collapse) collapse.checked = state.mailPrefs.collapse_quotes !== false;
|
||||
const snippets = $("#mail-pref-show-snippets");
|
||||
if (snippets) snippets.checked = state.mailPrefs.show_snippets !== false;
|
||||
const flaggedTop = $("#mail-pref-flagged-top");
|
||||
if (flaggedTop) flaggedTop.checked = Boolean(state.mailPrefs.flagged_on_top);
|
||||
const syncKeywords = $("#mail-pref-sync-keywords");
|
||||
if (syncKeywords) syncKeywords.checked = Boolean(state.mailPrefs.sync_imap_keywords);
|
||||
} catch (error) { console.warn("Could not load mailbox preferences", error); }
|
||||
}
|
||||
|
||||
async function saveMailPreferences() {
|
||||
const payload = {
|
||||
messages_per_page: Number($("#mail-pref-per-page").value),
|
||||
mark_read_delay_ms: Number($("#mail-pref-read-delay").value),
|
||||
remote_images: $("#mail-pref-remote-images").value,
|
||||
preferred_body: $("#mail-pref-preferred-body").value,
|
||||
thread_mode: $("#mail-pref-thread-mode").value,
|
||||
collapse_quotes: $("#mail-pref-collapse-quotes").checked,
|
||||
show_snippets: $("#mail-pref-show-snippets").checked,
|
||||
default_sort: $("#mail-pref-sort").value,
|
||||
flagged_on_top: $("#mail-pref-flagged-top").checked,
|
||||
sync_imap_keywords: $("#mail-pref-sync-keywords").checked,
|
||||
};
|
||||
try {
|
||||
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) });
|
||||
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
|
||||
toast("Mailbox settings saved");
|
||||
if (state.currentDetail) renderMessageDetail(state.currentDetail);
|
||||
await loadMessages(true);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function reconcileImapLabels() {
|
||||
const button = $("#mail-pref-reconcile-labels");
|
||||
const status = $("#mail-pref-reconcile-status");
|
||||
if (!$("#mail-pref-sync-keywords")?.checked) {
|
||||
toast("Enable IMAP keyword synchronization first", "warning");
|
||||
return;
|
||||
}
|
||||
if (button) button.disabled = true;
|
||||
if (status) status.textContent = "Reconciling…";
|
||||
try {
|
||||
const result = await request("/tags/reconcile", { method: "POST", body: "{}" });
|
||||
const added = Number(result.labels_added || 0);
|
||||
const removed = Number(result.labels_removed || 0);
|
||||
const folders = Number(result.folders_checked || 0);
|
||||
const skipped = Number(result.skipped_folders || 0);
|
||||
if (status) status.textContent = `${folders} folder(s) checked · +${added} / −${removed}${skipped ? ` · ${skipped} skipped` : ""}`;
|
||||
toast(`Labels reconciled: +${added}, −${removed}`, "success");
|
||||
await loadTags();
|
||||
await loadMessages(true);
|
||||
} catch (error) {
|
||||
if (status) status.textContent = error.message;
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function savePreference(key, value) {
|
||||
state.prefs[key] = value;
|
||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) state.prefs.colorPreset = "custom";
|
||||
applyPreferences();
|
||||
if (key === "language") loadTranslations(value);
|
||||
const previousTimer = preferenceTimers.get(key);
|
||||
if (previousTimer) clearTimeout(previousTimer);
|
||||
const timer = setTimeout(async () => {
|
||||
preferenceTimers.delete(key);
|
||||
const field = preferenceMap[key];
|
||||
if (!field) return;
|
||||
const payload = { [field]: state.prefs[key] };
|
||||
if (["accentColor", "backgroundColor", "panelColor", "textColor"].includes(key)) payload.color_preset = "custom";
|
||||
try { await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) }); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}, 180);
|
||||
preferenceTimers.set(key, timer);
|
||||
}
|
||||
|
||||
async function resetAppearance() {
|
||||
const payload = { theme: "system", interface_skin: "rustbox", density: "compact", preview_pane: "right", ui_scale: "normal", color_preset: "sage", accent_color: "#6f9c86", background_color: "#121513", panel_color: "#181c19", text_color: "#edf0ec", font_family: "system", font_size: 13, row_height: 58, folder_pane_width: 228, message_pane_width: 420, border_radius: 4, contrast: "normal", show_avatars: true, show_attachment_icons: true, show_flags: true, reduce_motion: false, date_format: "locale", time_format: "locale", icon_size: 18, toolbar_density: "normal", sidebar_item_height: 29, reader_max_width: 0, composer_width: 900, selection_color: "#28352e", unread_font_weight: 730, unread_badge_style: "dot", compact_headers: false, show_borders: true, scrollbar_density: "thin", monochrome: false, folder_background: "", message_background: "", reader_background: "" };
|
||||
try { normalizeServerPreferences(await request("/settings/display", { method: "PUT", body: JSON.stringify(payload) })); applyPreferences(); toast("Appearance reset"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
|
||||
function formatDate(raw, long = false) {
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return raw || "";
|
||||
const timeOptions = state.prefs.timeFormat === "24h" ? { hour: "2-digit", minute: "2-digit", hour12: false } : state.prefs.timeFormat === "12h" ? { hour: "numeric", minute: "2-digit", hour12: true } : { hour: "2-digit", minute: "2-digit" };
|
||||
if (long) {
|
||||
if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")} ${date.toLocaleTimeString([], timeOptions)}`;
|
||||
const locale = state.prefs.dateFormat === "eu" ? "en-GB" : state.prefs.dateFormat === "us" ? "en-US" : undefined;
|
||||
return `${date.toLocaleDateString(locale)} ${date.toLocaleTimeString([], timeOptions)}`;
|
||||
}
|
||||
const now = new Date();
|
||||
if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString([], timeOptions);
|
||||
if (state.prefs.dateFormat === "iso") return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
if (state.prefs.dateFormat === "eu") return date.toLocaleDateString("en-GB");
|
||||
if (state.prefs.dateFormat === "us") return date.toLocaleDateString("en-US");
|
||||
if (date.getFullYear() === now.getFullYear()) return date.toLocaleDateString([], { month: "short", day: "numeric" });
|
||||
return date.toLocaleDateString([], { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
const bytes = Number(value || 0);
|
||||
if (!Number.isFinite(bytes) || bytes < 1) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||||
return `${(bytes / (1024 ** index)).toFixed(index ? 1 : 0)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function initial(value) {
|
||||
return (value || "?").trim().charAt(0).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
$("#boot").classList.add("hidden");
|
||||
$("#app-view").classList.add("hidden");
|
||||
$("#login-view").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
$("#boot").classList.add("hidden");
|
||||
$("#login-view").classList.add("hidden");
|
||||
$("#app-view").classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function loadAuthPolicy() {
|
||||
try {
|
||||
state.authPolicy = await request("/auth/policy", { method: "GET" });
|
||||
} catch (_) {
|
||||
state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24 };
|
||||
}
|
||||
const enabled = state.authPolicy.rememberMeEnabled !== false;
|
||||
["#login-remember-row", "#add-account-remember-row"].forEach((selector) => $(selector)?.classList.toggle("hidden", !enabled));
|
||||
["#login-remember", "#add-account-remember"].forEach((selector) => { const input = $(selector); if (input && !enabled) { input.checked = false; input.disabled = true; } });
|
||||
const days = Number(state.authPolicy.rememberMeDays || 30);
|
||||
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => { const label = $(selector); if (label) label.textContent = `Keep me signed in (${days} days)`; });
|
||||
}
|
||||
|
||||
async function loadAccounts() {
|
||||
try {
|
||||
const data = await request("/auth/accounts", { method: "GET" });
|
||||
state.accounts = data.accounts || [];
|
||||
if (!state.accounts.length) {
|
||||
state.accountId = null;
|
||||
localStorage.removeItem("rustbox.activeAccount");
|
||||
state.folders = [];
|
||||
updateUnreadAppBadge();
|
||||
showLogin();
|
||||
return false;
|
||||
}
|
||||
if (!state.accountId || !state.accounts.some((account) => account.id === state.accountId)) {
|
||||
state.accountId = state.accounts[0].id;
|
||||
localStorage.setItem("rustbox.activeAccount", state.accountId);
|
||||
}
|
||||
const account = state.accounts.find((item) => item.id === state.accountId) || state.accounts[0];
|
||||
$("#account-email").textContent = account.email;
|
||||
showApp();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showLogin();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderAccountSettings() {
|
||||
const list = $("#account-settings-list");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
if (!state.accounts.length) {
|
||||
list.append(el("div", "empty-list", "No active accounts."));
|
||||
return;
|
||||
}
|
||||
for (const account of state.accounts) {
|
||||
const row = el("div", `account-settings-row ${account.id === state.accountId ? "active" : ""}`);
|
||||
const email = el("span", "account-email", account.email || "Account");
|
||||
const use = el("button", "button quiet", account.id === state.accountId ? "Current" : "Switch");
|
||||
use.type = "button";
|
||||
use.disabled = account.id === state.accountId;
|
||||
use.addEventListener("click", () => switchAccount(account.id));
|
||||
const remove = el("button", "button quiet danger", "Remove");
|
||||
remove.type = "button";
|
||||
remove.addEventListener("click", () => removeBrowserAccount(account.id));
|
||||
row.append(email, use, remove);
|
||||
list.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadActiveSessions() {
|
||||
const list = $("#account-session-list");
|
||||
if (!list) return;
|
||||
list.replaceChildren(el("div", "empty-list", "Loading sessions…"));
|
||||
try {
|
||||
const data = await request("/auth/sessions", { method: "GET" });
|
||||
const sessions = data.sessions || [];
|
||||
list.replaceChildren();
|
||||
if (!sessions.length) {
|
||||
list.append(el("div", "empty-list", "No active sessions."));
|
||||
return;
|
||||
}
|
||||
for (const session of sessions) {
|
||||
const row = el("div", `account-settings-row ${session.current ? "active" : ""}`);
|
||||
const details = el("div", "account-session-details");
|
||||
const expiry = Number(session.expiresAt || 0);
|
||||
const expiryText = expiry ? ` · expires ${new Date(expiry * 1000).toLocaleString()}` : "";
|
||||
const rememberedText = session.remembered ? " · remembered" : "";
|
||||
details.append(
|
||||
el("strong", "account-email", session.current ? `${session.email} · current` : session.email),
|
||||
el("span", "muted", `Created ${new Date(session.createdAt * 1000).toLocaleString()} · active ${new Date(session.lastActive * 1000).toLocaleString()}${expiryText}${rememberedText}`),
|
||||
);
|
||||
const client = el("span", "muted", session.clientName || "Unknown device");
|
||||
if (session.userAgent) client.title = session.userAgent;
|
||||
details.append(client);
|
||||
const action = el("button", "button quiet danger", session.current ? "Current" : "Revoke");
|
||||
action.type = "button";
|
||||
action.disabled = Boolean(session.current);
|
||||
action.addEventListener("click", () => revokeActiveSession(session.id));
|
||||
row.append(details, action);
|
||||
list.append(row);
|
||||
}
|
||||
} catch (error) {
|
||||
list.replaceChildren(el("div", "empty-list", error.message));
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeActiveSession(id) {
|
||||
if (!await appConfirm("Revoke this RustBox session?", { title: "Revoke session", confirmLabel: "Revoke", danger: true })) return;
|
||||
try {
|
||||
await request(`/auth/sessions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
toast("Session revoked");
|
||||
await loadActiveSessions();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function revokeOtherSessions() {
|
||||
if (!await appConfirm("Revoke every other active RustBox session for this account?", { title: "Revoke other sessions", confirmLabel: "Revoke sessions", danger: true })) return;
|
||||
try {
|
||||
const result = await request("/auth/sessions/revoke-others", { method: "POST", body: "{}" });
|
||||
toast(`Revoked ${result.count || 0} session(s)`);
|
||||
await loadActiveSessions();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function reloadActiveAccount() {
|
||||
setFolderDrawerOpen(false);
|
||||
state.folder = "INBOX";
|
||||
state.selected = null;
|
||||
state.currentDetail = null;
|
||||
state.selectedMessages.clear();
|
||||
state.selectionMode = false;
|
||||
closeReader();
|
||||
await loadPreferences();
|
||||
await loadMailPreferences();
|
||||
await loadOpenPgp();
|
||||
await loadIdentities();
|
||||
await loadFolders(true);
|
||||
await loadSpecialFolders();
|
||||
await loadTags();
|
||||
await loadNotificationPreferences();
|
||||
await loadTrustedSenders();
|
||||
startCalendarReminderWatcher();
|
||||
renderAccountSettings();
|
||||
}
|
||||
|
||||
async function switchAccount(accountId) {
|
||||
if (!state.accounts.some((account) => account.id === accountId) || accountId === state.accountId) return;
|
||||
state.accountId = accountId;
|
||||
localStorage.setItem("rustbox.activeAccount", accountId);
|
||||
const account = state.accounts.find((item) => item.id === accountId);
|
||||
if (account) $("#account-email").textContent = account.email;
|
||||
try { await reloadActiveAccount(); } catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function removeBrowserAccount(accountId) {
|
||||
const account = state.accounts.find((item) => item.id === accountId);
|
||||
if (!account || !await appConfirm(`Remove ${account.email} from this browser session?`, { title: "Remove account", confirmLabel: "Remove", danger: true })) return;
|
||||
try {
|
||||
await request(`/auth/accounts/${encodeURIComponent(accountId)}`, { method: "DELETE" });
|
||||
const removingCurrent = accountId === state.accountId;
|
||||
await loadAccounts();
|
||||
if (!state.accounts.length) return;
|
||||
if (removingCurrent) await reloadActiveAccount();
|
||||
else renderAccountSettings();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function addBrowserAccount(event) {
|
||||
event.preventDefault();
|
||||
const errorBox = $("#add-account-error");
|
||||
const submit = $("#add-account-form button[type=submit]");
|
||||
errorBox.classList.add("hidden");
|
||||
setBusy(submit, true);
|
||||
try {
|
||||
const data = await request("/auth/login", { method: "POST", body: JSON.stringify({ email: $("#add-account-email").value.trim(), password: $("#add-account-password").value, remember: $("#add-account-remember").checked }) });
|
||||
state.accountId = data.account.id;
|
||||
localStorage.setItem("rustbox.activeAccount", state.accountId);
|
||||
$("#add-account-dialog").close();
|
||||
$("#add-account-form").reset();
|
||||
await loadAccounts();
|
||||
await reloadActiveAccount();
|
||||
disconnectRealtime();
|
||||
connectRealtime();
|
||||
toast("Account added");
|
||||
} catch (error) {
|
||||
errorBox.textContent = error.message;
|
||||
errorBox.classList.remove("hidden");
|
||||
} finally { setBusy(submit, false); }
|
||||
}
|
||||
|
||||
async function login(event) {
|
||||
event.preventDefault();
|
||||
const submit = $("#login-form button[type=submit]");
|
||||
const errorBox = $("#login-error");
|
||||
errorBox.classList.add("hidden");
|
||||
setBusy(submit, true);
|
||||
try {
|
||||
const data = await request("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
email: $("#login-email").value.trim(),
|
||||
password: $("#login-password").value,
|
||||
remember: $("#login-remember").checked,
|
||||
}),
|
||||
});
|
||||
state.accountId = data.account.id;
|
||||
localStorage.setItem("rustbox.activeAccount", state.accountId);
|
||||
await loadAccounts();
|
||||
await loadPreferences();
|
||||
await loadMailPreferences();
|
||||
await loadOpenPgp();
|
||||
await loadIdentities();
|
||||
await loadFolders(true);
|
||||
await loadSpecialFolders();
|
||||
await loadTags();
|
||||
await loadNotificationPreferences();
|
||||
await loadTrustedSenders();
|
||||
startCalendarReminderWatcher();
|
||||
connectRealtime();
|
||||
} catch (error) {
|
||||
errorBox.textContent = error.message;
|
||||
errorBox.classList.remove("hidden");
|
||||
} finally {
|
||||
setBusy(submit, false);
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
disconnectRealtime();
|
||||
if (state.calendarReminderTimer) { clearInterval(state.calendarReminderTimer); state.calendarReminderTimer = null; }
|
||||
try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) { }
|
||||
state.accountId = null;
|
||||
state.accounts = [];
|
||||
localStorage.removeItem("rustbox.activeAccount");
|
||||
showLogin();
|
||||
}
|
||||
|
||||
@@ -0,0 +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 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;
|
||||
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();
|
||||
toast(`Label “${tag.name}” added`);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,719 @@
|
||||
/* RustBox UI module: 40-messages-reader.js */
|
||||
async function loadMessages(reset = false, options = {}) {
|
||||
const list = $("#message-list");
|
||||
const restoreState = Boolean(options.restoreState);
|
||||
const savedState = restoreState ? readMessageListState(state.folder) : null;
|
||||
const previousScrollTop = reset && !restoreState && list ? list.scrollTop : null;
|
||||
if (reset) {
|
||||
state.page = 0;
|
||||
state.messages = [];
|
||||
state.selectedMessages.clear();
|
||||
clearActiveSearchContext();
|
||||
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
|
||||
}
|
||||
$("#folder-title").textContent = state.folder;
|
||||
try {
|
||||
const sort = encodeURIComponent(state.mailPrefs.default_sort || "date_desc");
|
||||
const maxRestorePage = Math.max(0, Math.ceil(500 / Math.max(20, state.perPage)) - 1);
|
||||
const targetPage = reset && savedState ? Math.min(savedState.page, maxRestorePage) : state.page;
|
||||
const pages = reset && targetPage > 0 ? Array.from({ length: targetPage + 1 }, (_, index) => index) : [state.page];
|
||||
let loaded = [];
|
||||
let totalCount = 0;
|
||||
let flaggedOnTop = state.currentFolderFlaggedOnTop;
|
||||
let lastLoadedPage = state.page;
|
||||
for (const page of pages) {
|
||||
const data = await request(`/folders/${encodeURIComponent(state.folder)}/messages?page=${page}&per_page=${state.perPage}&sort=${sort}`, { method: "GET" });
|
||||
totalCount = data.total_count || 0;
|
||||
flaggedOnTop = Boolean(data.flagged_on_top);
|
||||
loaded.push(...(data.messages || []));
|
||||
lastLoadedPage = page;
|
||||
if (loaded.length >= totalCount) break;
|
||||
}
|
||||
state.page = reset ? lastLoadedPage : state.page;
|
||||
state.total = totalCount;
|
||||
state.currentFolderFlaggedOnTop = flaggedOnTop;
|
||||
state.messages = reset ? loaded : [...state.messages, ...loaded];
|
||||
$("#connection-label").textContent = "Connected";
|
||||
renderMessages();
|
||||
const desiredScroll = savedState?.scrollTop ?? previousScrollTop;
|
||||
if (Number.isFinite(desiredScroll) && desiredScroll > 0) {
|
||||
requestAnimationFrame(() => { list.scrollTop = Math.min(desiredScroll, Math.max(0, list.scrollHeight - list.clientHeight)); });
|
||||
}
|
||||
saveMessageListState();
|
||||
} catch (error) {
|
||||
$("#connection-label").textContent = error.network ? (navigator.onLine ? "Reconnecting" : "Offline") : "Connection error";
|
||||
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
||||
$("#message-count").textContent = state.messages.length ? `${state.total} messages · cached view` : "Unavailable";
|
||||
}
|
||||
}
|
||||
|
||||
async function quickUpdateMessageFlag(message, flag, add, successMessage) {
|
||||
const folder = message.folder || state.folder;
|
||||
try {
|
||||
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
|
||||
const flags = new Set(Array.isArray(message.flags) ? message.flags : String(message.flags || "").split(/[,\s]+/).filter(Boolean));
|
||||
add ? flags.add(flag) : flags.delete(flag);
|
||||
message.flags = [...flags].join(" ");
|
||||
if (state.currentDetail?.uid === message.uid && state.currentDetail?.folder === folder) {
|
||||
const detailFlags = new Set(state.currentDetail.flags || []);
|
||||
add ? detailFlags.add(flag) : detailFlags.delete(flag);
|
||||
state.currentDetail.flags = [...detailFlags];
|
||||
renderMessageDetail(state.currentDetail);
|
||||
}
|
||||
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true);
|
||||
else renderMessages();
|
||||
toast(successMessage, "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
$("#folder-title").textContent = state.folder;
|
||||
$("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`;
|
||||
const list = $("#message-list");
|
||||
const highlightTerms = activeSearchHighlightTerms();
|
||||
list.classList.toggle("selection-mode", state.selectionMode);
|
||||
list.replaceChildren();
|
||||
if (!state.messages.length) list.innerHTML = `<div class="empty-list">${state.searchContext ? "No matching messages." : "No messages in this folder."}</div>`;
|
||||
for (const message of state.messages) {
|
||||
const folder = message.folder || state.folder;
|
||||
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : (message.flags || "");
|
||||
const unread = !flags.includes("\\Seen");
|
||||
const flagged = flags.includes("\\Flagged");
|
||||
const checked = state.selectedMessages.has(messageKey(message));
|
||||
const active = state.selected?.uid === message.uid && state.selected?.folder === folder;
|
||||
const row = el("div", `message-row ${unread ? "unread" : ""} ${flagged ? "flagged" : ""} ${active ? "active" : ""} ${checked ? "selected" : ""}`);
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`);
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.className = "message-select";
|
||||
checkbox.checked = checked;
|
||||
checkbox.disabled = !state.selectionMode;
|
||||
checkbox.setAttribute("aria-label", "Select message");
|
||||
checkbox.addEventListener("click", (event) => { event.stopPropagation(); });
|
||||
checkbox.addEventListener("change", () => setMessageSelected(message, checkbox.checked));
|
||||
row.innerHTML = `<span class="message-state" aria-hidden="true"></span><div class="message-main"><span class="message-from"></span><time class="message-date">${escapeHtml(formatDate(message.date))}</time></div><div class="message-subject-line"><span class="message-subject"></span><span class="message-snippet"></span></div><span class="message-icons"></span>`;
|
||||
renderHighlightedText(row.querySelector(".message-from"), message.from_name || message.from_address || "Unknown", highlightTerms);
|
||||
renderHighlightedText(row.querySelector(".message-subject"), message.subject || "(no subject)", highlightTerms);
|
||||
renderHighlightedText(row.querySelector(".message-snippet"), state.mailPrefs.show_snippets !== false ? (message.snippet || "") : "", highlightTerms);
|
||||
const icons = row.querySelector(".message-icons");
|
||||
if (message.has_attachments) {
|
||||
const attachmentIcon = el("span", "message-static-icon attachment-indicator");
|
||||
attachmentIcon.innerHTML = svgIcon("paperclip");
|
||||
attachmentIcon.title = "Has attachments";
|
||||
icons.append(attachmentIcon);
|
||||
}
|
||||
const quick = el("span", "message-quick-actions");
|
||||
const readButton = el("button", "message-quick-action");
|
||||
readButton.type = "button";
|
||||
readButton.title = unread ? "Mark read" : "Mark unread";
|
||||
readButton.setAttribute("aria-label", readButton.title);
|
||||
readButton.innerHTML = svgIcon("mail");
|
||||
readButton.addEventListener("click", (event) => { event.stopPropagation(); quickUpdateMessageFlag(message, "\\Seen", unread, unread ? "Marked read" : "Marked unread"); });
|
||||
const flagButton = el("button", `message-quick-action message-flag ${flagged ? "active" : ""}`);
|
||||
flagButton.type = "button";
|
||||
flagButton.title = flagged ? "Unflag" : "Flag";
|
||||
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);
|
||||
icons.append(quick);
|
||||
if (Array.isArray(message.tags) && message.tags.length) {
|
||||
const wrap = el("span", "message-tags");
|
||||
for (const tag of message.tags) {
|
||||
const chip = el("span", "message-tag", tag.name);
|
||||
chip.style.setProperty("--tag-color", tag.color || "#6b7280");
|
||||
wrap.append(chip);
|
||||
}
|
||||
row.querySelector(".message-subject").after(wrap);
|
||||
}
|
||||
row.prepend(checkbox);
|
||||
row.draggable = true;
|
||||
row.addEventListener("dragstart", (event) => {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("application/x-rustbox-message", JSON.stringify({ folder, uid: message.uid }));
|
||||
row.classList.add("dragging");
|
||||
});
|
||||
row.addEventListener("dragend", () => row.classList.remove("dragging"));
|
||||
row.addEventListener("click", (event) => {
|
||||
if (state.selectionMode || event.ctrlKey || event.metaKey) return setMessageSelected(message, !state.selectedMessages.has(messageKey(message)));
|
||||
openMessage(folder, message.uid);
|
||||
});
|
||||
row.addEventListener("keydown", (event) => { if (event.key === "Enter" && !state.selectionMode) openMessage(folder, message.uid); if (event.key === " ") { event.preventDefault(); setMessageSelected(message, !state.selectedMessages.has(messageKey(message))); } });
|
||||
row.addEventListener("contextmenu", (event) => { event.preventDefault(); openMessageContextMenu(event.clientX, event.clientY, message); });
|
||||
list.append(row);
|
||||
}
|
||||
$("#load-more").classList.toggle("hidden", Boolean(state.searchContext) || state.messages.length >= state.total);
|
||||
renderBulkToolbar();
|
||||
}
|
||||
|
||||
function navigateMessage(delta) {
|
||||
if (!state.selected) return;
|
||||
const index = state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder);
|
||||
if (index < 0) return;
|
||||
const next = state.messages[index + delta];
|
||||
if (!next) return toast(delta > 0 ? "No next loaded message" : "No previous loaded message");
|
||||
openMessage(next.folder || state.folder, next.uid);
|
||||
}
|
||||
|
||||
function updateMessageNavigation() {
|
||||
const index = state.selected ? state.messages.findIndex((message) => message.uid === state.selected.uid && (message.folder || state.folder) === state.selected.folder) : -1;
|
||||
$("#reader-prev").disabled = index <= 0;
|
||||
$("#reader-next").disabled = index < 0 || index >= state.messages.length - 1;
|
||||
}
|
||||
|
||||
async function senderIsSavedContact(email) {
|
||||
if (!email) return false;
|
||||
try {
|
||||
const data = await request(`/contacts?q=${encodeURIComponent(email)}&limit=10`, { method: "GET" });
|
||||
const wanted = String(email).trim().toLowerCase();
|
||||
return (data.contacts || []).some((contact) => {
|
||||
const addresses = [contact.email, ...(contact.emails || [])].filter(Boolean).map((value) => String(value).trim().toLowerCase());
|
||||
return addresses.includes(wanted);
|
||||
});
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openMessage(folder, uid) {
|
||||
clearTimeout(state.markReadTimer);
|
||||
state.markReadTimer = null;
|
||||
state.selected = { folder, uid };
|
||||
renderMessages();
|
||||
updateMessageNavigation();
|
||||
$("#reader-empty").classList.add("hidden");
|
||||
$("#reader-content").classList.remove("hidden");
|
||||
$("#reader-subject").textContent = "Loading…";
|
||||
$("#message-body").replaceChildren();
|
||||
$("#reader").classList.add("mobile-open");
|
||||
try {
|
||||
const detail = await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "GET" });
|
||||
if (state.mailPrefs.remote_images === "contacts" && detail.from_address) {
|
||||
detail._senderInContacts = await senderIsSavedContact(detail.from_address);
|
||||
}
|
||||
if (detail.from_address) detail._senderTrusted = state.trustedSenders.has(String(detail.from_address).trim().toLowerCase());
|
||||
state.currentDetail = detail;
|
||||
renderMessageDetail(detail);
|
||||
renderMoveOptions();
|
||||
scheduleMarkRead(detail);
|
||||
} catch (error) {
|
||||
$("#reader-subject").textContent = "Unable to open message";
|
||||
$("#message-body").innerHTML = `<div class="empty-list">${escapeHtml(error.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleMarkRead(detail) {
|
||||
clearTimeout(state.markReadTimer);
|
||||
state.markReadTimer = null;
|
||||
if (!detail || (detail.flags || []).includes("\\Seen")) return;
|
||||
const delay = Math.max(0, Number(state.mailPrefs.mark_read_delay_ms || 0));
|
||||
const run = async () => {
|
||||
if (!state.currentDetail || state.currentDetail.uid !== detail.uid || state.currentDetail.folder !== detail.folder) return;
|
||||
try {
|
||||
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) });
|
||||
detail.flags = [...new Set([...(detail.flags || []), "\\Seen"])];
|
||||
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder);
|
||||
if (item) {
|
||||
const flags = Array.isArray(item.flags) ? item.flags : String(item.flags || "").split(/[,\s]+/).filter(Boolean);
|
||||
item.flags = [...new Set([...flags, "\\Seen"])].join(" ");
|
||||
}
|
||||
renderMessages();
|
||||
} catch (_) { }
|
||||
};
|
||||
if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay);
|
||||
}
|
||||
|
||||
function renderThread(detail) {
|
||||
const thread = detail.thread || [];
|
||||
const section = $("#thread-section");
|
||||
const list = $("#thread-list");
|
||||
list.replaceChildren();
|
||||
const enabled = state.mailPrefs.thread_mode !== "flat";
|
||||
section.classList.toggle("hidden", !enabled || thread.length <= 1);
|
||||
if (!enabled || thread.length <= 1) return;
|
||||
$("#thread-title").textContent = `Thread (${thread.length})`;
|
||||
list.classList.toggle("hidden", !state.threadExpanded);
|
||||
$("#thread-toggle").classList.toggle("expanded", state.threadExpanded);
|
||||
$("#thread-toggle").setAttribute("aria-expanded", String(state.threadExpanded));
|
||||
for (const item of thread) {
|
||||
const button = el("button", `thread-item ${item.uid === detail.uid && item.folder === detail.folder ? "current" : ""}`);
|
||||
button.type = "button";
|
||||
button.innerHTML = `<span>${escapeHtml(item.from_name || item.from_address || "Unknown")} — ${escapeHtml(item.subject || "(no subject)")}</span><time>${escapeHtml(formatDate(item.date))}</time>`;
|
||||
button.addEventListener("click", () => openMessage(item.folder, item.uid));
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function addressLabel(entry) {
|
||||
if (!entry?.address) return "";
|
||||
return entry.name ? `${entry.name} <${entry.address}>` : entry.address;
|
||||
}
|
||||
|
||||
function renderReaderRecipients(detail) {
|
||||
const host = $("#reader-to");
|
||||
host.replaceChildren();
|
||||
const groups = [
|
||||
["to", detail.to_addresses || []],
|
||||
["cc", detail.cc_addresses || []],
|
||||
];
|
||||
let added = 0;
|
||||
for (const [prefix, entries] of groups) {
|
||||
for (const entry of entries) {
|
||||
if (!entry?.address) continue;
|
||||
const row = el("div", "recipient-line");
|
||||
row.append(el("span", "", `${prefix} ${addressLabel(entry)}`));
|
||||
const add = el("button", "address-contact-button");
|
||||
add.type = "button";
|
||||
add.title = `Add ${entry.address} to contacts`;
|
||||
add.setAttribute("aria-label", add.title);
|
||||
add.innerHTML = svgIcon("user-round");
|
||||
add.addEventListener("click", (event) => { event.stopPropagation(); addAddressToContacts(entry.address, entry.name || ""); });
|
||||
row.append(add);
|
||||
host.append(row);
|
||||
added += 1;
|
||||
}
|
||||
}
|
||||
if (!added) host.append(el("div", "recipient-line", "to me"));
|
||||
}
|
||||
|
||||
function renderMessageDetail(detail) {
|
||||
$("#reader-subject").textContent = detail.subject || "(no subject)";
|
||||
$("#reader-from").textContent = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address;
|
||||
$("#reader-from").title = detail.from_address ? "Sender actions" : "";
|
||||
$("#reader-from").tabIndex = detail.from_address ? 0 : -1;
|
||||
if (detail.from_address) $("#reader-from").setAttribute("role", "button");
|
||||
else $("#reader-from").removeAttribute("role");
|
||||
renderReaderRecipients(detail);
|
||||
$("#reader-add-sender-contact").classList.toggle("hidden", !detail.from_address);
|
||||
$("#reader-date").textContent = formatDate(detail.date, true);
|
||||
$("#reader-avatar").textContent = initial(detail.from_name || detail.from_address);
|
||||
$("#flag-btn").classList.toggle("active", (detail.flags || []).includes("\\Flagged"));
|
||||
const spamButton = $("#spam-btn");
|
||||
const inJunk = isJunkFolder(detail.folder);
|
||||
spamButton.classList.toggle("active", inJunk);
|
||||
spamButton.title = inJunk ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam");
|
||||
spamButton.setAttribute("aria-label", spamButton.title);
|
||||
renderThread(detail);
|
||||
renderPgpReaderState(detail);
|
||||
|
||||
const attachments = $("#attachment-list");
|
||||
attachments.replaceChildren();
|
||||
attachments.classList.toggle("hidden", !(detail.attachments || []).length);
|
||||
for (const attachment of detail.attachments || []) {
|
||||
const item = el("div", "attachment-item");
|
||||
const button = el("button", "attachment-button");
|
||||
button.type = "button";
|
||||
button.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
|
||||
button.addEventListener("click", () => attachmentCanPreview(attachment) ? openAttachment(detail.folder, detail.uid, attachment) : downloadAttachment(detail.folder, detail.uid, attachment));
|
||||
button.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const filename = String(attachment.filename || "").toLowerCase();
|
||||
const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase();
|
||||
const actions = [
|
||||
{ label: "Open", action: () => openAttachment(detail.folder, detail.uid, attachment) },
|
||||
{ label: "Download", action: () => downloadAttachment(detail.folder, detail.uid, attachment) },
|
||||
];
|
||||
if (contentType.includes("text/calendar") || filename.endsWith(".ics")) actions.push("separator", { label: "Add to calendar", action: () => importCalendarAttachment(detail.folder, detail.uid, attachment) });
|
||||
if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) actions.push("separator", { label: "Import contact", action: () => importVcardAttachment(detail.folder, detail.uid, attachment) });
|
||||
if ((detail.attachments || []).length > 1) actions.push("separator", { label: "Download all as ZIP", action: () => downloadAllAttachmentsZip(detail) });
|
||||
showContextMenu(event.clientX, event.clientY, actions);
|
||||
});
|
||||
item.append(button);
|
||||
const filename = String(attachment.filename || "").toLowerCase();
|
||||
const contentType = String(attachment.content_type || attachment.contentType || "").toLowerCase();
|
||||
if (contentType.includes("text/calendar") || filename.endsWith(".ics")) {
|
||||
const add = el("button", "icon-button small");
|
||||
add.innerHTML = svgIcon("calendar-days");
|
||||
add.type = "button";
|
||||
add.title = "Add to calendar";
|
||||
add.setAttribute("aria-label", "Add calendar attachment");
|
||||
add.addEventListener("click", () => importCalendarAttachment(detail.folder, detail.uid, attachment));
|
||||
item.append(add);
|
||||
}
|
||||
if (contentType.includes("text/vcard") || contentType.includes("text/x-vcard") || filename.endsWith(".vcf")) {
|
||||
const add = el("button", "icon-button small");
|
||||
add.innerHTML = svgIcon("user-round");
|
||||
add.type = "button";
|
||||
add.title = "Import contact";
|
||||
add.setAttribute("aria-label", "Import vCard attachment");
|
||||
add.addEventListener("click", () => importVcardAttachment(detail.folder, detail.uid, attachment));
|
||||
item.append(add);
|
||||
}
|
||||
attachments.append(item);
|
||||
}
|
||||
if ((detail.attachments || []).length > 1) {
|
||||
const all = el("button", "attachment-download-all");
|
||||
all.type = "button";
|
||||
all.innerHTML = `${svgIcon("package")}<span>Download all as ZIP</span>`;
|
||||
all.addEventListener("click", () => downloadAllAttachmentsZip(detail));
|
||||
attachments.prepend(all);
|
||||
}
|
||||
|
||||
renderMessageBody(detail);
|
||||
}
|
||||
|
||||
function pgpMessageKind(detail) {
|
||||
const headers = String(detail?.raw_headers || "").toLowerCase();
|
||||
const text = String(detail?.text || "").toLowerCase();
|
||||
const encrypted = (headers.includes("multipart/encrypted") && headers.includes("application/pgp-encrypted")) || text.includes("-----begin pgp message-----");
|
||||
const signed = (headers.includes("multipart/signed") && headers.includes("application/pgp-signature")) || text.includes("-----begin pgp signed message-----");
|
||||
return { pgp: encrypted || signed, encrypted, signed };
|
||||
}
|
||||
|
||||
function renderPgpReaderState(detail) {
|
||||
const bar = $("#pgp-reader-bar");
|
||||
const status = $("#pgp-reader-status");
|
||||
const title = $("#pgp-reader-title");
|
||||
const find = $("#pgp-reader-discover");
|
||||
const kind = pgpMessageKind(detail);
|
||||
const configured = state.pgpKeys.length > 0;
|
||||
bar.classList.toggle("hidden", !kind.pgp || !configured);
|
||||
find.classList.add("hidden");
|
||||
if (!kind.pgp || !configured) return;
|
||||
const processed = detail?._pgpProcessed;
|
||||
if (!processed) {
|
||||
title.textContent = kind.encrypted ? "OpenPGP encrypted message" : "OpenPGP signed message";
|
||||
status.textContent = kind.encrypted ? "Decrypt to read and verify the protected MIME payload." : "Signature has not been verified yet.";
|
||||
$("#pgp-reader-process").textContent = kind.encrypted ? "Decrypt / verify" : "Verify signature";
|
||||
return;
|
||||
}
|
||||
const signer = processed.signerFingerprint ? ` · ${pgpFingerprintLabel(processed.signerFingerprint)}` : "";
|
||||
const signerIdentity = processed.signerEmail ? ` · ${processed.signerEmail}` : "";
|
||||
const trust = processed.signerTrust ? ` · trust: ${processed.signerTrust}` : "";
|
||||
if (processed.encrypted && processed.verified) { title.textContent = "OpenPGP decrypted + verified"; status.textContent = `Encrypted content was decrypted and its signature is valid${signer}${signerIdentity}${trust}`; }
|
||||
else if (processed.encrypted) { title.textContent = "OpenPGP decrypted"; status.textContent = processed.signed ? `Content decrypted; embedded signature could not be verified${signer}${signerIdentity}${trust}` : "Content decrypted successfully"; }
|
||||
else if (processed.verified) { title.textContent = "OpenPGP signature verified"; status.textContent = `Valid signature${signer}${signerIdentity}${trust}`; }
|
||||
else {
|
||||
title.textContent = "OpenPGP signature not verified";
|
||||
status.textContent = "No matching public key verified this signature.";
|
||||
if (detail?.from_address && (state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled)) find.classList.remove("hidden");
|
||||
}
|
||||
$("#pgp-reader-process").textContent = "Reprocess";
|
||||
}
|
||||
|
||||
async function processCurrentPgpMessage() {
|
||||
const detail = state.currentDetail;
|
||||
if (!detail) return;
|
||||
const kind = pgpMessageKind(detail);
|
||||
let passphrase = "";
|
||||
if (kind.encrypted) {
|
||||
const value = await appPrompt("Decrypt OpenPGP message", { message: "Enter the private-key passphrase if this key is protected. It is used only for this request and is not stored.", type: "password", confirmLabel: "Decrypt" });
|
||||
if (value === null) return;
|
||||
passphrase = value;
|
||||
}
|
||||
const button = $("#pgp-reader-process"); setBusy(button, true);
|
||||
try {
|
||||
const result = await request(`/pgp/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/process`, { method: "POST", body: JSON.stringify({ passphrase }) });
|
||||
detail._pgpProcessed = result;
|
||||
renderPgpReaderState(detail);
|
||||
if (result.text != null || result.html != null) renderMessageBody({ ...detail, text: result.text, html: result.html });
|
||||
renderPgpProcessedAttachments(result.attachments || []);
|
||||
if (result.verified && result.signerTrust === "untrusted") toast("OpenPGP signature is cryptographically valid, but this key is marked untrusted", "warning");
|
||||
else if (result.verified) toast("OpenPGP signature verified", "success");
|
||||
else if (result.decrypted) toast("OpenPGP message decrypted", "success");
|
||||
else if (result.signed) toast("OpenPGP signature could not be verified", "warning");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { setBusy(button, false); }
|
||||
}
|
||||
|
||||
function renderPgpProcessedAttachments(items) {
|
||||
if (!items.length) return;
|
||||
const list = $("#attachment-list");
|
||||
list.replaceChildren(); list.classList.remove("hidden");
|
||||
for (const attachment of items) {
|
||||
const item = el("div", "attachment-item");
|
||||
const button = el("button", "attachment-button"); button.type = "button";
|
||||
button.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename || "attachment")} · ${Math.max(1, Math.ceil((attachment.size || 0) / 1024))} KB</span>`;
|
||||
button.addEventListener("click", () => {
|
||||
try {
|
||||
const binary = atob(attachment.dataBase64 || ""); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
const blob = new Blob([bytes], { type: attachment.contentType || "application/octet-stream" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = attachment.filename || "attachment"; a.click(); setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
} catch (_) { toast("Unable to decode decrypted attachment", "error"); }
|
||||
});
|
||||
item.append(button); list.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverCurrentPgpSigner() {
|
||||
const email = state.currentDetail?.from_address;
|
||||
if (!email) return;
|
||||
const key = await discoverPgpEmail(email, false);
|
||||
if (key) await processCurrentPgpMessage();
|
||||
}
|
||||
|
||||
function renderMessageBody(detail, forceRemote = false) {
|
||||
const body = $("#message-body");
|
||||
const remoteBar = $("#remote-image-bar");
|
||||
body.replaceChildren();
|
||||
remoteBar.classList.add("hidden");
|
||||
const preferredBody = state.mailPrefs.preferred_body || "auto";
|
||||
if (preferredBody === "text" || !detail?.html) {
|
||||
body.append(el("pre", "", cleanPlainMessageText(detail?.text || "(empty message)")));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasRemote = /(?:src|background)\s*=\s*["']https?:\/\//i.test(detail.html) || /url\(\s*["']?https?:\/\//i.test(detail.html);
|
||||
const policy = state.mailPrefs.remote_images || "ask";
|
||||
const allowRemote = forceRemote || policy === "always" || (policy !== "never" && detail?._senderTrusted === true) || (policy === "contacts" && detail?._senderInContacts === true);
|
||||
if (hasRemote && !allowRemote && (policy === "ask" || policy === "contacts")) remoteBar.classList.remove("hidden");
|
||||
|
||||
const iframe = document.createElement("iframe");
|
||||
iframe.setAttribute("sandbox", "");
|
||||
iframe.setAttribute("referrerpolicy", "no-referrer");
|
||||
const imageSources = allowRemote ? "data: cid: https: http:" : "data: cid:";
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src ${imageSources}; style-src 'unsafe-inline'; font-src data:">`;
|
||||
const quoteStyle = state.mailPrefs.collapse_quotes !== false ? "blockquote{max-height:16em;overflow:auto;border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}" : "blockquote{border-left:2px solid #d9ddda;padding-left:12px;margin-left:0}";
|
||||
const safeHtml = sanitizeMessageHtml(detail.html);
|
||||
iframe.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:22px;color:#202622;background:#fff;font:14px/1.6 Inter,system-ui,-apple-system,'Segoe UI',sans-serif}img{max-width:100%;height:auto}a{color:#356a56}pre{white-space:pre-wrap}${quoteStyle}</style></head><body>${safeHtml}</body></html>`;
|
||||
body.append(iframe);
|
||||
}
|
||||
|
||||
async function downloadAttachment(folder, uid, attachment) {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers });
|
||||
if (!response.ok) throw new Error(`Download failed (${response.status})`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = attachment.filename || "attachment";
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
toast(`Downloading ${attachment.filename || "attachment"}`, "info");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function fetchAttachmentBlob(folder, uid, attachment) {
|
||||
const headers = new Headers();
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath(`/messages/${encodeURIComponent(folder)}/${uid}/attachments/${encodeURIComponent(attachment.id)}`), { credentials: "include", headers });
|
||||
if (!response.ok) throw new Error(`Attachment request failed (${response.status})`);
|
||||
return response.blob();
|
||||
}
|
||||
|
||||
function attachmentMime(attachment, blob = null) {
|
||||
return String(blob?.type || attachment?.content_type || attachment?.contentType || "application/octet-stream").toLowerCase();
|
||||
}
|
||||
|
||||
function attachmentCanPreview(attachment) {
|
||||
const mime = attachmentMime(attachment);
|
||||
const filename = String(attachment?.filename || "").toLowerCase();
|
||||
return mime.startsWith("image/")
|
||||
|| mime === "application/pdf"
|
||||
|| mime.startsWith("text/")
|
||||
|| mime.startsWith("video/")
|
||||
|| /\.(pdf|png|jpe?g|gif|webp|txt|log|md|csv|html?|xhtml|mp4|webm|ogv|mov)$/i.test(filename);
|
||||
}
|
||||
|
||||
async function openAttachment(folder, uid, attachment) {
|
||||
try {
|
||||
const blob = await fetchAttachmentBlob(folder, uid, attachment);
|
||||
const mime = attachmentMime(attachment, blob);
|
||||
const filename = String(attachment?.filename || "").toLowerCase();
|
||||
const isHtml = mime.includes("html") || /\.(html?|xhtml)$/i.test(filename);
|
||||
const isImage = mime.startsWith("image/") || /\.(png|jpe?g|gif|webp)$/i.test(filename);
|
||||
const isVideo = mime.startsWith("video/") || /\.(mp4|webm|ogv|mov)$/i.test(filename);
|
||||
const safe = attachmentCanPreview({ ...attachment, content_type: mime });
|
||||
if (!safe) return toast("Preview is not available for this attachment type", "warning");
|
||||
if (blob.size > 30 * 1024 * 1024) return toast("Attachment is too large for preview", "warning");
|
||||
|
||||
const dialog = el("dialog", "dialog attachment-preview-dialog");
|
||||
const card = el("div", "dialog-card attachment-preview-card");
|
||||
const header = el("header", "dialog-header");
|
||||
header.append(el("strong", "", attachment.filename || "Attachment preview"));
|
||||
const close = el("button", "icon-button");
|
||||
close.type = "button";
|
||||
close.setAttribute("aria-label", "Close preview");
|
||||
close.innerHTML = svgIcon("x");
|
||||
header.append(close);
|
||||
const body = el("div", "attachment-preview-body");
|
||||
let objectUrl = null;
|
||||
|
||||
if (isImage) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
const image = document.createElement("img");
|
||||
image.src = objectUrl;
|
||||
image.alt = attachment.filename || "Attachment image";
|
||||
body.append(image);
|
||||
} else if (mime === "application/pdf" || filename.endsWith(".pdf")) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
const frame = document.createElement("iframe");
|
||||
frame.src = objectUrl;
|
||||
frame.title = attachment.filename || "PDF preview";
|
||||
frame.referrerPolicy = "no-referrer";
|
||||
body.append(frame);
|
||||
} else if (isVideo) {
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
const video = document.createElement("video");
|
||||
video.src = objectUrl;
|
||||
video.controls = true;
|
||||
video.playsInline = true;
|
||||
video.preload = "metadata";
|
||||
body.append(video);
|
||||
} else if (isHtml) {
|
||||
if (blob.size > 5 * 1024 * 1024) return toast("HTML attachment is too large for inline preview", "warning");
|
||||
const frame = document.createElement("iframe");
|
||||
frame.setAttribute("sandbox", "");
|
||||
frame.referrerPolicy = "no-referrer";
|
||||
const safeHtml = sanitizeAttachmentHtml(await blob.text());
|
||||
const csp = `<meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:">`;
|
||||
frame.srcdoc = `<!doctype html><html><head>${csp}<meta name="color-scheme" content="light"><style>body{margin:0;padding:20px;color:#202622;background:#fff;font:14px/1.55 system-ui,sans-serif}img{max-width:100%;height:auto}pre{white-space:pre-wrap}</style></head><body>${safeHtml}</body></html>`;
|
||||
body.append(frame);
|
||||
} else {
|
||||
if (blob.size > 5 * 1024 * 1024) return toast("Text attachment is too large for inline preview", "warning");
|
||||
const pre = document.createElement("pre");
|
||||
pre.textContent = await blob.text();
|
||||
body.append(pre);
|
||||
}
|
||||
|
||||
const footer = el("footer", "dialog-actions");
|
||||
const download = el("button", "button quiet", "Download");
|
||||
download.type = "button";
|
||||
download.addEventListener("click", () => downloadAttachment(folder, uid, attachment));
|
||||
const done = el("button", "button primary", "Close");
|
||||
done.type = "button";
|
||||
const dismiss = () => { if (dialog.open) dialog.close(); };
|
||||
close.addEventListener("click", dismiss);
|
||||
done.addEventListener("click", dismiss);
|
||||
footer.append(download, done);
|
||||
card.append(header, body, footer);
|
||||
dialog.append(card);
|
||||
document.body.append(dialog);
|
||||
dialog.addEventListener("close", () => { if (objectUrl) URL.revokeObjectURL(objectUrl); dialog.remove(); }, { once: true });
|
||||
dialog.showModal();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function safeZipFilename(value, index) {
|
||||
const cleaned = String(value || `attachment-${index + 1}`)
|
||||
.replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_")
|
||||
.replace(/^\.+$/, "attachment")
|
||||
.slice(0, 180);
|
||||
return cleaned || `attachment-${index + 1}`;
|
||||
}
|
||||
|
||||
let crcTable = null;
|
||||
function crc32(bytes) {
|
||||
if (!crcTable) {
|
||||
crcTable = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n += 1) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k += 1) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
||||
crcTable[n] = c >>> 0;
|
||||
}
|
||||
}
|
||||
let crc = 0xffffffff;
|
||||
for (const byte of bytes) crc = crcTable[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||
return (crc ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
|
||||
function zipDosDateTime(date = new Date()) {
|
||||
const year = Math.max(1980, date.getFullYear());
|
||||
const dosTime = ((date.getHours() & 31) << 11) | ((date.getMinutes() & 63) << 5) | ((Math.floor(date.getSeconds() / 2)) & 31);
|
||||
const dosDate = (((year - 1980) & 127) << 9) | (((date.getMonth() + 1) & 15) << 5) | (date.getDate() & 31);
|
||||
return { dosTime, dosDate };
|
||||
}
|
||||
|
||||
function zipRecord(size, fill) {
|
||||
const bytes = new Uint8Array(size);
|
||||
const view = new DataView(bytes.buffer);
|
||||
fill(view);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatBytes(parts) {
|
||||
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
|
||||
const out = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const part of parts) { out.set(part, offset); offset += part.byteLength; }
|
||||
return out;
|
||||
}
|
||||
|
||||
async function downloadAllAttachmentsZip(detail = state.currentDetail) {
|
||||
const attachments = detail?.attachments || [];
|
||||
if (!attachments.length) return toast("This message has no attachments", "warning");
|
||||
if (attachments.length > 100) return toast("Too many attachments for a browser ZIP", "warning");
|
||||
try {
|
||||
toast(`Preparing ZIP with ${attachments.length} attachment${attachments.length === 1 ? "" : "s"}…`, "info");
|
||||
const encoder = new TextEncoder();
|
||||
const localParts = [];
|
||||
const centralParts = [];
|
||||
let localOffset = 0;
|
||||
let totalPayload = 0;
|
||||
for (let index = 0; index < attachments.length; index += 1) {
|
||||
const attachment = attachments[index];
|
||||
const blob = await fetchAttachmentBlob(detail.folder, detail.uid, attachment);
|
||||
totalPayload += blob.size;
|
||||
if (totalPayload > 120 * 1024 * 1024) throw new Error("Attachments exceed the 120 MB browser ZIP limit");
|
||||
const data = new Uint8Array(await blob.arrayBuffer());
|
||||
const name = encoder.encode(safeZipFilename(attachment.filename, index));
|
||||
const checksum = crc32(data);
|
||||
const { dosTime, dosDate } = zipDosDateTime();
|
||||
const local = zipRecord(30, (view) => {
|
||||
view.setUint32(0, 0x04034b50, true); view.setUint16(4, 20, true); view.setUint16(6, 0x0800, true); view.setUint16(8, 0, true);
|
||||
view.setUint16(10, dosTime, true); view.setUint16(12, dosDate, true); view.setUint32(14, checksum, true);
|
||||
view.setUint32(18, data.length, true); view.setUint32(22, data.length, true); view.setUint16(26, name.length, true); view.setUint16(28, 0, true);
|
||||
});
|
||||
const central = zipRecord(46, (view) => {
|
||||
view.setUint32(0, 0x02014b50, true); view.setUint16(4, 20, true); view.setUint16(6, 20, true); view.setUint16(8, 0x0800, true); view.setUint16(10, 0, true);
|
||||
view.setUint16(12, dosTime, true); view.setUint16(14, dosDate, true); view.setUint32(16, checksum, true);
|
||||
view.setUint32(20, data.length, true); view.setUint32(24, data.length, true); view.setUint16(28, name.length, true); view.setUint16(30, 0, true);
|
||||
view.setUint16(32, 0, true); view.setUint16(34, 0, true); view.setUint16(36, 0, true); view.setUint32(38, 0, true); view.setUint32(42, localOffset, true);
|
||||
});
|
||||
localParts.push(local, name, data);
|
||||
centralParts.push(central, name);
|
||||
localOffset += local.byteLength + name.byteLength + data.byteLength;
|
||||
}
|
||||
const centralDirectory = concatBytes(centralParts);
|
||||
const eocd = zipRecord(22, (view) => {
|
||||
view.setUint32(0, 0x06054b50, true); view.setUint16(4, 0, true); view.setUint16(6, 0, true);
|
||||
view.setUint16(8, attachments.length, true); view.setUint16(10, attachments.length, true);
|
||||
view.setUint32(12, centralDirectory.byteLength, true); view.setUint32(16, localOffset, true); view.setUint16(20, 0, true);
|
||||
});
|
||||
const zip = new Blob([...localParts, centralDirectory, eocd], { type: "application/zip" });
|
||||
const url = URL.createObjectURL(zip);
|
||||
const link = document.createElement("a");
|
||||
const stem = String(detail.subject || "attachments").replace(/[\x00-\x1f\x7f\\/:*?"<>|]/g, "_").slice(0, 80) || "attachments";
|
||||
link.href = url; link.download = `${stem}-attachments.zip`; document.body.append(link); link.click(); link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
toast(`Downloaded ${attachments.length} attachment${attachments.length === 1 ? "" : "s"} as ZIP`, "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function importCalendarAttachment(folder, uid, attachment) {
|
||||
try {
|
||||
const blob = await fetchAttachmentBlob(folder, uid, attachment);
|
||||
if (blob.size > 10 * 1024 * 1024) throw new Error("ICS attachment is too large");
|
||||
const text = await blob.text();
|
||||
const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" });
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath("/calendar/events/import-ics"), { method: "POST", credentials: "include", headers, body: text });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data?.error?.message || `Calendar import failed (${response.status})`);
|
||||
toast(`Calendar: ${data.created || 0} event(s) imported`);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function importVcardAttachment(folder, uid, attachment) {
|
||||
try {
|
||||
const blob = await fetchAttachmentBlob(folder, uid, attachment);
|
||||
if (blob.size > 10 * 1024 * 1024) throw new Error("vCard attachment is too large");
|
||||
const form = new FormData();
|
||||
form.append("file", blob, attachment.filename || "contact.vcf");
|
||||
const result = await request("/contacts/import", { method: "POST", body: form });
|
||||
toast(`Contacts: ${result.created || 0} created, ${result.updated || 0} updated`);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function closeReader() {
|
||||
clearTimeout(state.markReadTimer);
|
||||
state.markReadTimer = null;
|
||||
$("#reader").classList.remove("mobile-open");
|
||||
$("#reader-empty").classList.remove("hidden");
|
||||
$("#reader-content").classList.add("hidden");
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +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"); }
|
||||
}
|
||||
|
||||
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 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 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 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 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 || []);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/* RustBox UI module: 71-calendar.js */
|
||||
function startOfCalendarDay(date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function addCalendarDays(date, count) {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + count);
|
||||
return next;
|
||||
}
|
||||
|
||||
function startOfCalendarWeek(date) {
|
||||
const start = startOfCalendarDay(date);
|
||||
const day = start.getDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
return addCalendarDays(start, mondayOffset);
|
||||
}
|
||||
|
||||
function calendarRange(date, view = state.calendarView) {
|
||||
if (view === "week") {
|
||||
const start = startOfCalendarWeek(date);
|
||||
return { start, end: addCalendarDays(start, 7) };
|
||||
}
|
||||
if (view === "day") {
|
||||
const start = startOfCalendarDay(date);
|
||||
return { start, end: addCalendarDays(start, 1) };
|
||||
}
|
||||
if (view === "agenda") {
|
||||
const start = startOfCalendarDay(date);
|
||||
return { start, end: addCalendarDays(start, 30) };
|
||||
}
|
||||
const first = new Date(date.getFullYear(), date.getMonth(), 1);
|
||||
const start = startOfCalendarWeek(first);
|
||||
return { start, end: addCalendarDays(start, 42) };
|
||||
}
|
||||
|
||||
function calendarDayKey(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function calendarRangeLabel() {
|
||||
const cursor = state.calendarCursor;
|
||||
if (state.calendarView === "month") return cursor.toLocaleDateString([], { month: "long", year: "numeric" });
|
||||
if (state.calendarView === "day") return cursor.toLocaleDateString([], { weekday: "long", year: "numeric", month: "long", day: "numeric" });
|
||||
const { start, end } = calendarRange(cursor);
|
||||
const last = addCalendarDays(end, -1);
|
||||
if (state.calendarView === "agenda") return `${start.toLocaleDateString()} – ${last.toLocaleDateString()}`;
|
||||
return `${start.toLocaleDateString([], { month: "short", day: "numeric" })} – ${last.toLocaleDateString([], { month: "short", day: "numeric", year: "numeric" })}`;
|
||||
}
|
||||
|
||||
function shiftCalendarCursor(direction) {
|
||||
const cursor = new Date(state.calendarCursor);
|
||||
if (state.calendarView === "month") cursor.setMonth(cursor.getMonth() + direction);
|
||||
else if (state.calendarView === "week") cursor.setDate(cursor.getDate() + (7 * direction));
|
||||
else if (state.calendarView === "agenda") cursor.setDate(cursor.getDate() + (30 * direction));
|
||||
else cursor.setDate(cursor.getDate() + direction);
|
||||
state.calendarCursor = cursor;
|
||||
}
|
||||
|
||||
function toLocalInput(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const local = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
async function importCalendarIcs(files) {
|
||||
const file = files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) return toast("ICS file is too large", "error");
|
||||
try {
|
||||
const text = await file.text();
|
||||
const headers = new Headers({ "Content-Type": "text/calendar; charset=utf-8", "X-Requested-With": "XMLHttpRequest" });
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath("/calendar/events/import-ics"), { method: "POST", credentials: "include", headers, body: text });
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) throw new Error(data?.error?.message || `ICS import failed (${response.status})`);
|
||||
toast(`ICS import: ${data.created || 0} created, ${data.skipped || 0} skipped`);
|
||||
await loadCalendar();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
finally { $("#calendar-import-file").value = ""; }
|
||||
}
|
||||
|
||||
async function exportCalendarIcs() {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath("/calendar/events/export-ics"), { credentials: "include", headers });
|
||||
if (!response.ok) throw new Error(`Calendar export failed (${response.status})`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "rustbox-calendar.ics";
|
||||
document.body.append(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function openCalendar() {
|
||||
$("#calendar-dialog").showModal();
|
||||
await loadCalendar();
|
||||
}
|
||||
|
||||
async function loadCalendar() {
|
||||
const { start, end } = calendarRange(state.calendarCursor);
|
||||
$("#calendar-view").value = state.calendarView;
|
||||
$("#calendar-range").textContent = calendarRangeLabel();
|
||||
try {
|
||||
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
|
||||
state.calendarEvents = data.events || [];
|
||||
renderCalendarEvents();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function createCalendarEventButton(event, compact = false) {
|
||||
const button = el("button", compact ? "calendar-event-chip" : "calendar-event");
|
||||
button.type = "button";
|
||||
const color = /^#[0-9a-f]{6}$/i.test(String(event.color || "")) ? event.color : "var(--accent)";
|
||||
button.style.setProperty("--event-color", color);
|
||||
if (compact) {
|
||||
const time = event.all_day ? "All day" : new Date(event.start_time).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
button.innerHTML = `<span>${escapeHtml(time)}</span><strong>${escapeHtml(event.title)}</strong>`;
|
||||
} else {
|
||||
button.innerHTML = `<time>${escapeHtml(formatDate(event.start_time, true))}</time><div><strong>${escapeHtml(event.title)}</strong><small>${escapeHtml(event.location || "")}</small></div><span>${escapeHtml(event.status || "")}</span>`;
|
||||
}
|
||||
button.addEventListener("click", () => editCalendarEvent(event));
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderCalendarMonthOrWeek(view) {
|
||||
const host = $("#calendar-events");
|
||||
const { start, end } = calendarRange(state.calendarCursor, view);
|
||||
const days = view === "week" ? 7 : 42;
|
||||
const grid = el("div", `calendar-grid calendar-${view}-grid`);
|
||||
for (const label of ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]) grid.append(el("div", "calendar-weekday", label));
|
||||
const byDay = new Map();
|
||||
for (const event of state.calendarEvents) {
|
||||
const key = calendarDayKey(event.start_time);
|
||||
if (!byDay.has(key)) byDay.set(key, []);
|
||||
byDay.get(key).push(event);
|
||||
}
|
||||
for (let i = 0; i < days; i++) {
|
||||
const date = addCalendarDays(start, i);
|
||||
const cell = el("section", "calendar-day-cell");
|
||||
if (view === "month" && date.getMonth() !== state.calendarCursor.getMonth()) cell.classList.add("outside-month");
|
||||
if (calendarDayKey(date) === calendarDayKey(new Date())) cell.classList.add("today");
|
||||
const heading = el("button", "calendar-day-number", String(date.getDate()));
|
||||
heading.type = "button";
|
||||
heading.title = "Create event on this day";
|
||||
heading.addEventListener("click", () => {
|
||||
const startDate = new Date(date); startDate.setHours(9, 0, 0, 0);
|
||||
const endDate = new Date(startDate.getTime() + 3600000);
|
||||
editCalendarEvent(null);
|
||||
$("#calendar-start").value = toLocalInput(startDate);
|
||||
$("#calendar-end").value = toLocalInput(endDate);
|
||||
});
|
||||
cell.append(heading);
|
||||
const dayEvents = (byDay.get(calendarDayKey(date)) || []).sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
|
||||
for (const event of dayEvents.slice(0, view === "month" ? 4 : 12)) cell.append(createCalendarEventButton(event, true));
|
||||
if (view === "month" && dayEvents.length > 4) cell.append(el("span", "calendar-more", `+${dayEvents.length - 4} more`));
|
||||
grid.append(cell);
|
||||
}
|
||||
host.append(grid);
|
||||
}
|
||||
|
||||
function renderCalendarAgenda() {
|
||||
const host = $("#calendar-events");
|
||||
const groups = new Map();
|
||||
for (const event of state.calendarEvents) {
|
||||
const key = calendarDayKey(event.start_time);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(event);
|
||||
}
|
||||
if (!groups.size) { host.append(el("div", "empty-list", "No events in this range")); return; }
|
||||
for (const [key, events] of [...groups.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
||||
const date = new Date(`${key}T12:00:00`);
|
||||
const section = el("section", "calendar-agenda-day");
|
||||
section.append(el("h4", "", date.toLocaleDateString([], { weekday: "long", month: "long", day: "numeric", year: "numeric" })));
|
||||
for (const event of events.sort((a, b) => new Date(a.start_time) - new Date(b.start_time))) section.append(createCalendarEventButton(event));
|
||||
host.append(section);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCalendarEvents() {
|
||||
const list = $("#calendar-events");
|
||||
list.replaceChildren();
|
||||
if (state.calendarView === "month" || state.calendarView === "week") {
|
||||
renderCalendarMonthOrWeek(state.calendarView);
|
||||
return;
|
||||
}
|
||||
if (!state.calendarEvents.length) {
|
||||
list.append(el("div", "empty-list", state.calendarView === "day" ? "No events today" : "No events in this range"));
|
||||
return;
|
||||
}
|
||||
renderCalendarAgenda();
|
||||
}
|
||||
|
||||
function updateCalendarRecurrenceUi() {
|
||||
$("#calendar-rrule-wrap").classList.toggle("hidden", $("#calendar-recurrence").value !== "custom");
|
||||
}
|
||||
|
||||
function editCalendarEvent(event = null) {
|
||||
$("#calendar-form").classList.remove("hidden");
|
||||
$("#calendar-event-id").value = event?.id || "";
|
||||
$("#calendar-title").value = event?.title || "";
|
||||
$("#calendar-start").value = event ? toLocalInput(event.start_time) : toLocalInput(new Date());
|
||||
$("#calendar-end").value = event ? toLocalInput(event.end_time) : toLocalInput(new Date(Date.now() + 3600000));
|
||||
$("#calendar-all-day").checked = Boolean(event?.all_day);
|
||||
const rule = event?.recurrence_rule || "";
|
||||
const commonRules = new Set(["", "FREQ=DAILY", "FREQ=WEEKLY", "FREQ=MONTHLY", "FREQ=YEARLY"]);
|
||||
$("#calendar-recurrence").value = commonRules.has(rule) ? rule : "custom";
|
||||
$("#calendar-rrule").value = commonRules.has(rule) ? "" : rule;
|
||||
updateCalendarRecurrenceUi();
|
||||
let attendees = [];
|
||||
try { attendees = JSON.parse(event?.attendees || "[]"); } catch (_) { }
|
||||
$("#calendar-attendees").value = Array.isArray(attendees) ? attendees.join(", ") : "";
|
||||
$("#calendar-organizer").value = event?.organizer || state.accounts.find((item) => item.id === state.accountId)?.email || "";
|
||||
$("#calendar-status").value = String(event?.status || "confirmed").toLowerCase();
|
||||
$("#calendar-location").value = event?.location || "";
|
||||
$("#calendar-meeting-url").value = event?.meeting_url || "";
|
||||
$("#calendar-description").value = event?.description || "";
|
||||
const color = String(event?.color || "");
|
||||
$("#calendar-color").value = /^#[0-9a-f]{6}$/i.test(color) ? color : "#6b7280";
|
||||
const reminder = event?.reminder_minutes == null ? "" : String(event.reminder_minutes);
|
||||
$("#calendar-reminder").value = Array.from($("#calendar-reminder").options).some((option) => option.value === reminder) ? reminder : "";
|
||||
$("#calendar-delete").disabled = !event;
|
||||
}
|
||||
|
||||
async function saveCalendarEvent(event) {
|
||||
event.preventDefault();
|
||||
const id = $("#calendar-event-id").value;
|
||||
const recurrenceChoice = $("#calendar-recurrence").value;
|
||||
const recurrenceRule = recurrenceChoice === "custom" ? $("#calendar-rrule").value.trim() : recurrenceChoice;
|
||||
const attendees = $("#calendar-attendees").value.split(",").map((value) => value.trim()).filter(Boolean);
|
||||
const startValue = $("#calendar-start").value;
|
||||
const endValue = $("#calendar-end").value;
|
||||
if (!startValue || !endValue) return toast("Start and end are required", "error");
|
||||
const base = {
|
||||
title: $("#calendar-title").value.trim(),
|
||||
description: $("#calendar-description").value,
|
||||
location: $("#calendar-location").value.trim(),
|
||||
start_time: new Date(startValue).toISOString(),
|
||||
end_time: new Date(endValue).toISOString(),
|
||||
all_day: $("#calendar-all-day").checked,
|
||||
recurrence_rule: recurrenceRule || null,
|
||||
attendees: JSON.stringify(attendees),
|
||||
organizer: $("#calendar-organizer").value.trim(),
|
||||
status: $("#calendar-status").value || "confirmed",
|
||||
meeting_url: $("#calendar-meeting-url").value.trim() || null,
|
||||
color: $("#calendar-color").value || null,
|
||||
reminder_minutes: $("#calendar-reminder").value === "" ? null : Number($("#calendar-reminder").value),
|
||||
};
|
||||
const payload = id ? base : { ...base, source: "manual", source_uid: null, source_message_uid: null, source_message_folder: null };
|
||||
try { await request(id ? `/calendar/events/${encodeURIComponent(id)}` : "/calendar/events", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event saved"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteCalendarEvent() {
|
||||
const id = $("#calendar-event-id").value;
|
||||
if (!id || !await appConfirm("Delete this event?", { title: "Delete event", confirmLabel: "Delete", danger: true })) return;
|
||||
try { await request(`/calendar/events/${encodeURIComponent(id)}`, { method: "DELETE" }); $("#calendar-form").classList.add("hidden"); await loadCalendar(); toast("Event deleted"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
/* RustBox UI module: 72-openpgp-identities.js */
|
||||
async function loadOpenPgp() {
|
||||
try {
|
||||
const [keys, prefs] = await Promise.all([
|
||||
request("/pgp/keys", { method: "GET" }),
|
||||
request("/pgp/preferences", { method: "GET" }),
|
||||
]);
|
||||
state.pgpKeys = Array.isArray(keys) ? keys : [];
|
||||
state.pgpPrefs = { ...state.pgpPrefs, ...(prefs || {}) };
|
||||
renderOpenPgp();
|
||||
renderIdentityPgpOptions();
|
||||
updateComposePgpUi();
|
||||
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
|
||||
} catch (error) {
|
||||
state.pgpKeys = [];
|
||||
updateComposePgpUi();
|
||||
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
|
||||
toast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function pgpFingerprintLabel(value) {
|
||||
const fp = String(value || "").replace(/\s+/g, "").toUpperCase();
|
||||
return fp ? fp.replace(/(.{4})/g, "$1 ").trim() : "";
|
||||
}
|
||||
|
||||
function renderOpenPgp() {
|
||||
if (!$("#pgp-key-list")) return;
|
||||
$("#pgp-pref-wkd").checked = Boolean(state.pgpPrefs.wkdEnabled);
|
||||
$("#pgp-pref-hkp").checked = Boolean(state.pgpPrefs.hkpEnabled);
|
||||
$("#pgp-pref-trusted").checked = Boolean(state.pgpPrefs.requireTrustedEncryption);
|
||||
const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled);
|
||||
if ($("#pgp-discover")) {
|
||||
$("#pgp-discover").disabled = !discoveryEnabled;
|
||||
$("#pgp-discover").title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server first";
|
||||
}
|
||||
$("#pgp-key-summary").textContent = `${state.pgpKeys.length} key${state.pgpKeys.length === 1 ? "" : "s"} · ${state.pgpKeys.filter((key) => key.hasSecret).length} private`;
|
||||
const list = $("#pgp-key-list");
|
||||
list.replaceChildren();
|
||||
if (!state.pgpKeys.length) list.append(el("div", "empty-list", "No OpenPGP keys yet"));
|
||||
for (const key of state.pgpKeys) {
|
||||
const card = el("article", "pgp-key-card");
|
||||
const head = el("div", "pgp-key-head");
|
||||
const title = el("div");
|
||||
title.innerHTML = `<strong>${escapeHtml(key.userId || key.email || key.keyId)}</strong><small>${escapeHtml(key.email || "No email")} · ${key.hasSecret ? "private + public" : "public"} · ${escapeHtml(key.source || "import")}</small>`;
|
||||
const trust = document.createElement("select");
|
||||
trust.setAttribute("aria-label", `Trust for ${key.email || key.keyId}`);
|
||||
for (const value of ["unknown", "verified", "trusted", "untrusted"]) trust.add(new Option(value[0].toUpperCase() + value.slice(1), value));
|
||||
trust.value = key.trust || "unknown";
|
||||
trust.addEventListener("change", async () => {
|
||||
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/trust`, { method: "PATCH", body: JSON.stringify({ trust: trust.value }) }); await loadOpenPgp(); toast("OpenPGP trust updated", "success"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
});
|
||||
head.append(title, trust);
|
||||
const fp = el("code", "pgp-fingerprint", pgpFingerprintLabel(key.fingerprint));
|
||||
fp.title = "Fingerprint";
|
||||
const actions = el("div", "inline-actions");
|
||||
const pub = el("button", "button quiet small", "Export public"); pub.type = "button"; pub.addEventListener("click", () => downloadPgpKey(key, false)); actions.append(pub);
|
||||
if (key.hasSecret) {
|
||||
const test = el("button", "button quiet small", "Test passphrase"); test.type = "button"; test.addEventListener("click", async () => {
|
||||
const passphrase = await appPrompt("Test OpenPGP passphrase", { message: "Enter the passphrase for this private key. It is used only for this test and is not stored.", type: "password", confirmLabel: "Test" });
|
||||
if (passphrase === null) return;
|
||||
setBusy(test, true);
|
||||
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/test-passphrase`, { method: "POST", body: JSON.stringify({ passphrase }) }); toast("OpenPGP private key unlocked successfully", "success"); }
|
||||
catch (error) { toast(error.message, "error"); } finally { setBusy(test, false); }
|
||||
}); actions.append(test);
|
||||
const change = el("button", "button quiet small", "Change passphrase"); change.type = "button"; change.addEventListener("click", async () => {
|
||||
const currentPassphrase = await appPrompt("Current OpenPGP passphrase", { message: "Enter the current passphrase. Leave it empty if the key is currently unprotected.", type: "password", confirmLabel: "Continue" });
|
||||
if (currentPassphrase === null) return;
|
||||
const newPassphrase = await appPrompt("New OpenPGP passphrase", { message: "Enter the new passphrase. Leave it empty only if you intentionally want to remove passphrase protection.", type: "password", confirmLabel: "Change" });
|
||||
if (newPassphrase === null) return;
|
||||
if (newPassphrase === "" && !await appConfirm("Remove passphrase protection from this private key? The key file will still be protected by server filesystem permissions, but anyone who obtains it could use it immediately.", { title: "Remove OpenPGP passphrase", confirmLabel: "Remove passphrase", danger: true })) return;
|
||||
setBusy(change, true);
|
||||
try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/change-passphrase`, { method: "POST", body: JSON.stringify({ currentPassphrase, newPassphrase }) }); toast("OpenPGP passphrase changed", "success"); }
|
||||
catch (error) { toast(error.message, "error"); } finally { setBusy(change, false); }
|
||||
}); actions.append(change);
|
||||
const sec = el("button", "button quiet small", "Export private"); sec.type = "button"; sec.addEventListener("click", async () => { if (await appConfirm("Export the private OpenPGP key? Anyone with this file and its passphrase may use your identity.", { title: "Export private key", confirmLabel: "Export" })) downloadPgpKey(key, true); }); actions.append(sec);
|
||||
}
|
||||
const del = el("button", "button quiet small danger", "Delete"); del.type = "button"; del.addEventListener("click", async () => { if (!await appConfirm(`Delete OpenPGP key ${key.keyId}?`, { title: "Delete OpenPGP key", confirmLabel: "Delete", danger: true })) return; try { await request(`/pgp/keys/${encodeURIComponent(key.fingerprint)}`, { method: "DELETE" }); await loadOpenPgp(); await loadIdentities(); toast("OpenPGP key deleted", "success"); } catch (error) { toast(error.message, "error"); } }); actions.append(del);
|
||||
card.append(head, fp, actions);
|
||||
list.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPgpKey(key, secret) {
|
||||
try {
|
||||
const headers = new Headers();
|
||||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||||
const response = await fetch(apiPath(`/pgp/keys/${encodeURIComponent(key.fingerprint)}/export?secret=${secret ? "true" : "false"}`), { credentials: "include", headers });
|
||||
if (!response.ok) throw new Error(await response.text() || `Export failed (${response.status})`);
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a"); a.href = url; a.download = `openpgp-${key.keyId}-${secret ? "private" : "public"}.asc`; a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function savePgpPreferences() {
|
||||
try {
|
||||
state.pgpPrefs = await request("/pgp/preferences", { method: "PUT", body: JSON.stringify({ wkdEnabled: $("#pgp-pref-wkd").checked, hkpEnabled: $("#pgp-pref-hkp").checked, requireTrustedEncryption: $("#pgp-pref-trusted").checked }) });
|
||||
renderOpenPgp();
|
||||
updateComposePgpUi();
|
||||
if (state.currentDetail) renderPgpReaderState(state.currentDetail);
|
||||
toast("OpenPGP policy saved", "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function generatePgpKey() {
|
||||
const button = $("#pgp-generate"); setBusy(button, true);
|
||||
try {
|
||||
const key = await request("/pgp/keys/generate", { method: "POST", body: JSON.stringify({ name: $("#pgp-generate-name").value.trim(), email: $("#pgp-generate-email").value.trim(), passphrase: $("#pgp-generate-passphrase").value }) });
|
||||
$("#pgp-generate-passphrase").value = ""; await loadOpenPgp(); toast(`Generated OpenPGP key ${key.keyId}`, "success");
|
||||
} catch (error) { toast(error.message, "error"); } finally { setBusy(button, false); }
|
||||
}
|
||||
|
||||
async function importPgpText(text = $("#pgp-import-data").value) {
|
||||
if (!String(text || "").trim()) return toast("Paste or choose an OpenPGP key first", "warning");
|
||||
try { const key = await request("/pgp/keys", { method: "POST", body: JSON.stringify({ keyData: String(text), trust: $("#pgp-import-trust").value || "unknown" }) }); $("#pgp-import-data").value = ""; await loadOpenPgp(); toast(`Imported OpenPGP key ${key.keyId}`, "success"); }
|
||||
catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes) {
|
||||
let binary = "";
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
async function importPgpFile(file) {
|
||||
if (!file) return;
|
||||
try {
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (!bytes.length) return toast("The selected OpenPGP key file is empty", "warning");
|
||||
const key = await request("/pgp/keys", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ keyDataBase64: bytesToBase64(bytes), trust: $("#pgp-import-trust").value || "unknown" }),
|
||||
});
|
||||
$("#pgp-import-data").value = "";
|
||||
await loadOpenPgp();
|
||||
toast(`Imported OpenPGP key ${key.keyId}`, "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function discoverPgpEmail(email = $("#pgp-discover-email").value.trim(), quiet = false) {
|
||||
if (!email) return null;
|
||||
try {
|
||||
const result = await request("/pgp/discover", { method: "POST", body: JSON.stringify({ email }) });
|
||||
if (result.found) { await loadOpenPgp(); if (!quiet) toast(`OpenPGP key discovered via ${(result.source || "discovery").toUpperCase()}`, "success"); return result.key; }
|
||||
if (!quiet) toast(`No OpenPGP key found for ${email}`, "warning");
|
||||
return null;
|
||||
} catch (error) { if (!quiet) toast(error.message, "error"); return null; }
|
||||
}
|
||||
|
||||
function renderIdentityPgpOptions(selectedFingerprint = null) {
|
||||
const select = $("#identity-pgp-key");
|
||||
if (!select) return;
|
||||
const value = selectedFingerprint ?? select.value;
|
||||
select.replaceChildren(new Option("Automatic / none", ""));
|
||||
for (const key of state.pgpKeys.filter((item) => item.hasSecret)) {
|
||||
select.add(new Option(`${key.email || key.userId || key.keyId} · ${key.keyId}`, key.fingerprint));
|
||||
}
|
||||
select.value = value || "";
|
||||
}
|
||||
|
||||
function pgpModeForIdentity(identity) {
|
||||
if (!identity) return "none";
|
||||
if (identity.pgp_sign_default && identity.pgp_encrypt_default) return "sign-encrypt";
|
||||
if (identity.pgp_encrypt_default) return "encrypt";
|
||||
if (identity.pgp_sign_default) return "sign";
|
||||
return "none";
|
||||
}
|
||||
|
||||
function updateComposePgpUi(applyDefaults = false) {
|
||||
const modeSelect = $("#compose-pgp-mode");
|
||||
const panel = $("#compose-pgp-panel");
|
||||
const option = $("#compose-pgp-option");
|
||||
if (!modeSelect || !panel) return;
|
||||
const configured = state.pgpKeys.length > 0;
|
||||
option?.classList.toggle("hidden", !configured);
|
||||
panel.classList.toggle("hidden", !configured);
|
||||
if (!configured) {
|
||||
panel.open = false;
|
||||
modeSelect.value = "none";
|
||||
$("#compose-pgp-inline").checked = false;
|
||||
$("#compose-pgp-attach-key").checked = false;
|
||||
$("#compose-pgp-attach-recipient-keys").checked = false;
|
||||
state.pgpRecipientStatus = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const identity = selectedIdentity();
|
||||
const accountEmail = state.accounts.find((item) => item.id === state.accountId)?.email || "";
|
||||
const senderEmail = identity?.email || accountEmail;
|
||||
const selectedKey = identity?.pgp_fingerprint ? state.pgpKeys.find((key) => key.fingerprint === identity.pgp_fingerprint) : null;
|
||||
const senderPublicKey = selectedKey || state.pgpKeys.find((key) => key.email?.toLowerCase() === senderEmail.toLowerCase());
|
||||
const signingKey = selectedKey?.hasSecret ? selectedKey : state.pgpKeys.find((key) => key.hasSecret && key.email?.toLowerCase() === senderEmail.toLowerCase());
|
||||
if (applyDefaults) {
|
||||
modeSelect.value = pgpModeForIdentity(identity);
|
||||
$("#compose-pgp-inline").checked = Boolean(identity?.pgp_inline_default);
|
||||
panel.open = false;
|
||||
}
|
||||
|
||||
const hasSigningKey = Boolean(signingKey?.hasSecret);
|
||||
for (const value of ["sign", "sign-encrypt"]) {
|
||||
const modeOption = [...modeSelect.options].find((item) => item.value === value);
|
||||
if (modeOption) modeOption.disabled = !hasSigningKey;
|
||||
}
|
||||
if (!hasSigningKey && ["sign", "sign-encrypt"].includes(modeSelect.value)) modeSelect.value = modeSelect.value === "sign-encrypt" ? "encrypt" : "none";
|
||||
|
||||
const activeMode = modeSelect.value || "none";
|
||||
const needsSigning = activeMode === "sign" || activeMode === "sign-encrypt";
|
||||
$("#compose-pgp-passphrase-row").classList.toggle("hidden", !needsSigning);
|
||||
const inline = $("#compose-pgp-inline");
|
||||
if (inline) inline.disabled = activeMode === "none" || Boolean(state.composeAttachments.length);
|
||||
if (inline?.disabled) inline.checked = false;
|
||||
|
||||
const senderRow = $("#compose-pgp-attach-sender-row");
|
||||
senderRow?.classList.toggle("hidden", !senderPublicKey);
|
||||
if (!senderPublicKey) $("#compose-pgp-attach-key").checked = false;
|
||||
|
||||
const discoveryEnabled = Boolean(state.pgpPrefs.wkdEnabled || state.pgpPrefs.hkpEnabled);
|
||||
const discoverButton = $("#compose-pgp-discover");
|
||||
if (discoverButton) {
|
||||
discoverButton.disabled = !discoveryEnabled;
|
||||
discoverButton.title = discoveryEnabled ? "" : "Enable WKD or the HTTPS key server in OpenPGP settings first";
|
||||
}
|
||||
|
||||
const recipientAttach = $("#compose-pgp-attach-recipient-keys");
|
||||
const parts = [];
|
||||
if (needsSigning) parts.push(`Signing key ${signingKey.keyId}`);
|
||||
if (activeMode.includes("encrypt")) parts.push(state.pgpRecipientStatus.length ? recipientPgpSummary(state.pgpRecipientStatus) : "Recipient keys not checked yet");
|
||||
if (recipientAttach?.checked) {
|
||||
const available = state.pgpRecipientStatus.filter((item) => item.hasKey).length;
|
||||
parts.push(state.pgpRecipientStatus.length ? `${available} visible recipient key${available === 1 ? "" : "s"} will be attached` : "Recipient keys to attach not checked yet");
|
||||
}
|
||||
if (!parts.length) parts.push(senderPublicKey ? `OpenPGP ready · ${senderPublicKey.keyId}` : "OpenPGP public keys available");
|
||||
$("#compose-pgp-status").textContent = parts.join(" · ");
|
||||
}
|
||||
|
||||
function recipientPgpSummary(statuses) {
|
||||
const missing = statuses.filter((item) => !item.hasKey);
|
||||
const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted");
|
||||
const notTrusted = state.pgpPrefs.requireTrustedEncryption
|
||||
? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust))
|
||||
: [];
|
||||
if (missing.length) return `${missing.length} recipient key${missing.length === 1 ? "" : "s"} missing`;
|
||||
if (untrusted.length) return `${untrusted.length} recipient key${untrusted.length === 1 ? "" : "s"} marked untrusted`;
|
||||
if (notTrusted.length) return `${notTrusted.length} recipient key${notTrusted.length === 1 ? " is" : "s are"} not trusted by policy`;
|
||||
return `Keys available for ${statuses.length} recipient${statuses.length === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
function pgpRecipientStatusHasWarning(item) {
|
||||
if (!item.hasKey || item.trust === "untrusted") return true;
|
||||
return Boolean(state.pgpPrefs.requireTrustedEncryption && !["trusted", "verified"].includes(item.trust));
|
||||
}
|
||||
|
||||
function composePgpVisibleRecipients() {
|
||||
return [...parseRecipients($("#compose-to").value), ...parseRecipients($("#compose-cc").value)];
|
||||
}
|
||||
|
||||
function composePgpRecipients() {
|
||||
return [...composePgpVisibleRecipients(), ...parseRecipients($("#compose-bcc").value)];
|
||||
}
|
||||
|
||||
async function checkComposePgpRecipients(showToast = true, emails = composePgpRecipients()) {
|
||||
if (!emails.length) { state.pgpRecipientStatus = []; updateComposePgpUi(); return []; }
|
||||
try {
|
||||
const statuses = await request("/pgp/recipients", { method: "POST", body: JSON.stringify({ emails }) });
|
||||
state.pgpRecipientStatus = statuses || [];
|
||||
updateComposePgpUi();
|
||||
if (showToast) toast(recipientPgpSummary(state.pgpRecipientStatus), state.pgpRecipientStatus.some(pgpRecipientStatusHasWarning) ? "warning" : "success");
|
||||
return state.pgpRecipientStatus;
|
||||
} catch (error) { if (showToast) toast(error.message, "error"); return []; }
|
||||
}
|
||||
|
||||
async function discoverComposePgpRecipients() {
|
||||
const statuses = await checkComposePgpRecipients(false);
|
||||
const missing = statuses.filter((item) => !item.hasKey).map((item) => item.email);
|
||||
if (!missing.length) return toast("All recipient OpenPGP keys are already available", "success");
|
||||
let found = 0;
|
||||
for (const email of missing) if (await discoverPgpEmail(email, true)) found += 1;
|
||||
await checkComposePgpRecipients(false);
|
||||
toast(found ? `Discovered ${found} OpenPGP key${found === 1 ? "" : "s"}` : "No missing OpenPGP keys were discovered", found ? "success" : "warning");
|
||||
}
|
||||
|
||||
async function loadIdentities() {
|
||||
try {
|
||||
const data = await request("/identities", { method: "GET" });
|
||||
state.identities = data.identities || [];
|
||||
renderIdentities();
|
||||
renderComposeIdentities();
|
||||
renderIdentityPgpOptions();
|
||||
updateComposePgpUi();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function renderComposeIdentities() {
|
||||
const select = $("#compose-from");
|
||||
if (!select) return;
|
||||
select.replaceChildren(new Option("Account default", ""));
|
||||
for (const identity of state.identities) {
|
||||
const label = identity.display_name ? `${identity.display_name} <${identity.email}>` : identity.email;
|
||||
const option = new Option(label, String(identity.id));
|
||||
if (identity.is_default) option.selected = true;
|
||||
select.add(option);
|
||||
}
|
||||
}
|
||||
|
||||
function renderIdentities() {
|
||||
const list = $("#identity-list");
|
||||
if (!list) return;
|
||||
list.replaceChildren();
|
||||
if (!state.identities.length) list.append(el("div", "empty-list", "No identities yet"));
|
||||
for (const identity of state.identities) {
|
||||
const button = el("button", "identity-item");
|
||||
button.type = "button";
|
||||
button.innerHTML = `<strong>${escapeHtml(identity.display_name || identity.email)}</strong><small>${escapeHtml(identity.email)}${identity.is_default ? " · default" : ""}</small>`;
|
||||
button.addEventListener("click", () => editIdentity(identity));
|
||||
list.append(button);
|
||||
}
|
||||
}
|
||||
|
||||
function editIdentity(identity = null) {
|
||||
$("#identity-id").value = identity?.id || "";
|
||||
$("#identity-name").value = identity?.display_name || "";
|
||||
$("#identity-email").value = identity?.email || "";
|
||||
$("#identity-reply-to").value = identity?.reply_to || "";
|
||||
$("#identity-bcc").value = identity?.bcc || "";
|
||||
$("#identity-organization").value = identity?.organization || "";
|
||||
$("#identity-signature-text").value = identity?.signature_text || "";
|
||||
$("#identity-signature").innerHTML = sanitizeMessageHtml(identity?.signature_html || "");
|
||||
$("#identity-signature-position").value = identity?.signature_position || "above";
|
||||
renderIdentityPgpOptions(identity?.pgp_fingerprint || "");
|
||||
$("#identity-pgp-sign").checked = Boolean(identity?.pgp_sign_default);
|
||||
$("#identity-pgp-encrypt").checked = Boolean(identity?.pgp_encrypt_default);
|
||||
$("#identity-pgp-inline").checked = Boolean(identity?.pgp_inline_default);
|
||||
$("#identity-signature-new").checked = identity?.signature_new !== false;
|
||||
$("#identity-signature-reply").checked = identity?.signature_reply !== false;
|
||||
$("#identity-signature-forward").checked = identity?.signature_forward !== false;
|
||||
$("#identity-default").checked = Boolean(identity?.is_default);
|
||||
$("#identity-delete").disabled = !identity;
|
||||
$("#identity-dialog-title").textContent = identity ? `Edit identity · ${identity.email}` : "New identity & signature";
|
||||
const pgpConfigured = state.pgpKeys.length > 0;
|
||||
$("#identity-pgp-key-row")?.classList.toggle("hidden", !pgpConfigured);
|
||||
$$(".identity-pgp-default").forEach((row) => row.classList.toggle("hidden", !pgpConfigured));
|
||||
const dialog = $("#identity-dialog");
|
||||
if (!dialog.open) dialog.showModal();
|
||||
setTimeout(() => (identity ? $("#identity-name") : $("#identity-email"))?.focus(), 20);
|
||||
}
|
||||
|
||||
async function saveIdentity(event) {
|
||||
event.preventDefault();
|
||||
const id = $("#identity-id").value;
|
||||
const signatureHtml = sanitizeMessageHtml($("#identity-signature").innerHTML);
|
||||
if (new TextEncoder().encode(signatureHtml).length > 3 * 1024 * 1024) return toast("HTML signature is too large. Reduce imported image sizes.", "warning");
|
||||
const payload = {
|
||||
display_name: $("#identity-name").value.trim(),
|
||||
email: $("#identity-email").value.trim(),
|
||||
reply_to: $("#identity-reply-to").value.trim(),
|
||||
bcc: $("#identity-bcc").value.trim(),
|
||||
organization: $("#identity-organization").value.trim(),
|
||||
signature_text: $("#identity-signature-text").value,
|
||||
signature_html: signatureHtml,
|
||||
signature_position: $("#identity-signature-position").value,
|
||||
pgp_fingerprint: $("#identity-pgp-key").value,
|
||||
pgp_sign_default: $("#identity-pgp-sign").checked,
|
||||
pgp_encrypt_default: $("#identity-pgp-encrypt").checked,
|
||||
pgp_inline_default: $("#identity-pgp-inline").checked,
|
||||
signature_new: $("#identity-signature-new").checked,
|
||||
signature_reply: $("#identity-signature-reply").checked,
|
||||
signature_forward: $("#identity-signature-forward").checked,
|
||||
is_default: $("#identity-default").checked,
|
||||
};
|
||||
try {
|
||||
await request(id ? `/identities/${id}` : "/identities", { method: id ? "PUT" : "POST", body: JSON.stringify(payload) });
|
||||
await loadIdentities();
|
||||
const saved = state.identities.find((item) => item.email === payload.email);
|
||||
if (saved) {
|
||||
$("#identity-id").value = saved.id || "";
|
||||
}
|
||||
if ($("#identity-dialog").open) $("#identity-dialog").close();
|
||||
toast("Identity saved");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function deleteIdentity() {
|
||||
const id = $("#identity-id").value;
|
||||
if (!id || !await appConfirm("Delete this identity?", { title: "Delete identity", confirmLabel: "Delete", danger: true })) return;
|
||||
try {
|
||||
await request(`/identities/${id}`, { method: "DELETE" });
|
||||
await loadIdentities();
|
||||
if ($("#identity-dialog").open) $("#identity-dialog").close();
|
||||
toast("Identity deleted");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function selectedIdentity() {
|
||||
const id = Number($("#compose-from")?.value || 0);
|
||||
return state.identities.find((identity) => identity.id === id) || state.identities.find((identity) => identity.is_default) || null;
|
||||
}
|
||||
|
||||
function composeActionFromTitle() {
|
||||
const title = $("#compose-title").textContent;
|
||||
if (title === "Reply" || title === "Reply all") return "reply";
|
||||
if (title === "Forward") return "forward";
|
||||
return "new";
|
||||
}
|
||||
|
||||
function dataUrlToSignatureFile(src, index = 0) {
|
||||
const match = /^data:(image\/(?:png|jpeg|gif|webp));base64,([A-Za-z0-9+/=\r\n]+)$/i.exec(String(src || ""));
|
||||
if (!match) return null;
|
||||
try {
|
||||
const binary = atob(match[2].replace(/\s+/g, ""));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
const type = match[1].toLowerCase();
|
||||
const ext = type === "image/jpeg" ? "jpg" : type.slice("image/".length);
|
||||
return new File([bytes], `signature-${index + 1}.${ext}`, { type });
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeSignatureImages(signature, generation) {
|
||||
const images = [...signature.querySelectorAll('img[src^="data:image/"]')];
|
||||
if (!images.length) return;
|
||||
const pairs = images.map((image, index) => ({ image, file: dataUrlToSignatureFile(image.getAttribute("src"), index) })).filter((item) => item.file);
|
||||
if (!pairs.length) return;
|
||||
const uploaded = await uploadComposeFiles(pairs.map((item) => item.file), { announce: false });
|
||||
if (!uploaded.length) return;
|
||||
if (generation !== state.composeSignatureGeneration || !signature.isConnected) {
|
||||
for (const attachment of uploaded) state.inlineAttachmentIds.add(String(attachment.id));
|
||||
renderComposeAttachments();
|
||||
scheduleUnusedInlineAttachmentCleanup();
|
||||
return;
|
||||
}
|
||||
const draftId = ensureDraftId();
|
||||
for (let index = 0; index < Math.min(pairs.length, uploaded.length); index += 1) {
|
||||
const { image } = pairs[index];
|
||||
const attachment = uploaded[index];
|
||||
state.inlineAttachmentIds.add(String(attachment.id));
|
||||
image.dataset.rustboxCid = String(attachment.id);
|
||||
image.dataset.rustboxSignatureImage = "true";
|
||||
image.setAttribute("src", composeAttachmentContentUrl(draftId, attachment.id));
|
||||
}
|
||||
renderComposeAttachments();
|
||||
scheduleDraftSave();
|
||||
}
|
||||
|
||||
function insertIdentitySignature(editor, action = composeActionFromTitle()) {
|
||||
state.composeSignatureGeneration += 1;
|
||||
const generation = state.composeSignatureGeneration;
|
||||
editor.querySelectorAll(".compose-signature").forEach((node) => node.remove());
|
||||
scheduleUnusedInlineAttachmentCleanup();
|
||||
const identity = selectedIdentity();
|
||||
if (!identity) return;
|
||||
const enabled = action === "reply" ? identity.signature_reply !== false : action === "forward" ? identity.signature_forward !== false : identity.signature_new !== false;
|
||||
if (!enabled) return;
|
||||
const format = $("#compose-mode").value;
|
||||
const signature = el("div", "compose-signature");
|
||||
if (format === "html") {
|
||||
if (!identity.signature_html) return;
|
||||
signature.innerHTML = sanitizeMessageHtml(identity.signature_html);
|
||||
} else {
|
||||
const text = identity.signature_text || "";
|
||||
if (!text) return;
|
||||
signature.textContent = `\n-- \n${text}\n`;
|
||||
}
|
||||
const quote = editor.querySelector(".compose-quote");
|
||||
if (quote && identity.signature_position !== "below") editor.insertBefore(signature, quote);
|
||||
else editor.append(signature);
|
||||
if (format === "html") void materializeSignatureImages(signature, generation);
|
||||
}
|
||||
|
||||
function readFileAsDataUrl(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result || ""));
|
||||
reader.onerror = () => reject(reader.error || new Error("Could not read image"));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function insertIdentitySignatureImage(file, range = state.identitySignatureRange) {
|
||||
state.identitySignatureRange = null;
|
||||
if (!file) return;
|
||||
if (!/^image\/(png|jpeg|gif|webp)$/i.test(file.type || "")) return toast("Choose a PNG, JPEG, GIF or WebP image", "warning");
|
||||
if (file.size > 2 * 1024 * 1024) return toast("Signature image must be 2 MB or smaller", "warning");
|
||||
const editor = $("#identity-signature");
|
||||
try {
|
||||
const src = await readFileAsDataUrl(file);
|
||||
editor.focus();
|
||||
restoreEditorRange(range, editor);
|
||||
const alt = escapeHtml((file.name || "signature image").replace(/\.[^.]+$/, ""));
|
||||
document.execCommand("insertHTML", false, `<img src="${escapeHtml(src)}" alt="${alt}" style="max-width:320px;height:auto">`);
|
||||
} catch (error) {
|
||||
toast(error.message || "Could not import signature image", "error");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function populateSpecialFolderSelects() {
|
||||
const roles = ["sent", "drafts", "trash", "junk", "archive"];
|
||||
for (const role of roles) {
|
||||
const select = $(`#special-${role}`);
|
||||
if (!select) continue;
|
||||
const automaticLabel = role === "junk" ? "Use default / auto-detect" : "Automatic / not set";
|
||||
select.replaceChildren(new Option(automaticLabel, ""));
|
||||
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
|
||||
select.value = state.specialFolders?.[role] || "";
|
||||
}
|
||||
populateSpamDefaultFolderSelect();
|
||||
}
|
||||
|
||||
function populateSpamDefaultFolderSelect() {
|
||||
const select = $("#spam-default-folder");
|
||||
if (!select) return;
|
||||
const stored = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim();
|
||||
select.replaceChildren(new Option("Automatic IMAP Junk/Spam", ""));
|
||||
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
|
||||
if (stored && !state.folders.some((folder) => folder.name === stored)) {
|
||||
const stale = new Option(`${stored} (not available in this account)`, stored);
|
||||
stale.disabled = true;
|
||||
select.add(stale);
|
||||
}
|
||||
select.value = stored;
|
||||
}
|
||||
|
||||
async function loadSpecialFolders() {
|
||||
try { state.specialFolders = { ...state.specialFolders, ...(await request("/settings/special-folders", { method: "GET" })) }; }
|
||||
catch (error) { console.warn("Could not load special folders", error); }
|
||||
populateSpecialFolderSelects();
|
||||
}
|
||||
|
||||
async function saveSpecialFolders() {
|
||||
const value = {};
|
||||
for (const role of ["sent", "drafts", "trash", "junk", "archive"]) value[role] = $(`#special-${role}`).value || null;
|
||||
const browserSpamDefault = $("#spam-default-folder")?.value || "";
|
||||
try {
|
||||
state.specialFolders = await request("/settings/special-folders", { method: "PUT", body: JSON.stringify(value) });
|
||||
if (browserSpamDefault) localStorage.setItem(SPAM_DEFAULT_FOLDER_KEY, browserSpamDefault);
|
||||
else localStorage.removeItem(SPAM_DEFAULT_FOLDER_KEY);
|
||||
populateSpecialFolderSelects();
|
||||
toast("Folder settings saved");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function autoDetectSpecialFolders() {
|
||||
for (const role of ["sent", "drafts", "trash", "junk", "archive"]) {
|
||||
const select = $(`#special-${role}`);
|
||||
if (select) select.value = autoDetectSpecialFolder(role) || "";
|
||||
}
|
||||
await saveSpecialFolders();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
/* RustBox UI module: 80-settings-realtime.js */
|
||||
async function loadQuota() {
|
||||
const summary = $("#quota-summary span");
|
||||
const bar = $("#quota-bar");
|
||||
if (!summary || !bar) return;
|
||||
summary.textContent = "Loading quota…";
|
||||
bar.style.width = "0%";
|
||||
try {
|
||||
const quota = await request("/quota", { method: "GET" });
|
||||
const used = Number(quota.usage_bytes || 0);
|
||||
const limit = Number(quota.limit_bytes || 0);
|
||||
if (limit > 0) {
|
||||
const percent = Math.max(0, Math.min(100, (used / limit) * 100));
|
||||
summary.textContent = `${formatBytes(used)} of ${formatBytes(limit)} (${percent.toFixed(1)}%)`;
|
||||
bar.style.width = `${percent}%`;
|
||||
const quotaBox = $("#quota-summary");
|
||||
const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal";
|
||||
quotaBox?.classList.toggle("quota-warning", level === "warning");
|
||||
quotaBox?.classList.toggle("quota-danger", level === "danger");
|
||||
if (level !== "normal" && state.quotaWarningLevel !== `${state.accountId}:${level}`) {
|
||||
state.quotaWarningLevel = `${state.accountId}:${level}`;
|
||||
toast(level === "danger" ? `Mailbox storage is ${percent.toFixed(0)}% full` : `Mailbox storage is ${percent.toFixed(0)}% used`, level === "danger" ? "error" : "warning");
|
||||
}
|
||||
} else {
|
||||
summary.textContent = `${formatBytes(used)} used · server limit unavailable`;
|
||||
}
|
||||
} catch (error) { summary.textContent = `Quota unavailable: ${error.message}`; }
|
||||
}
|
||||
|
||||
function openSettings(tab = "appearance") {
|
||||
selectSettingsTab(tab);
|
||||
$("#settings-dialog").showModal();
|
||||
if (tab === "sieve") loadSieve();
|
||||
if (tab === "identities") { loadIdentities(); loadOpenPgp(); }
|
||||
if (tab === "openpgp") loadOpenPgp();
|
||||
if (tab === "notifications") loadNotificationPreferences();
|
||||
if (tab === "mailbox") loadMailPreferences();
|
||||
if (tab === "diagnostics") loadDiagnostics();
|
||||
if (tab === "account") { renderAccountSettings(); loadActiveSessions(); }
|
||||
if (tab === "folders") { loadSpecialFolders(); loadQuota(); }
|
||||
}
|
||||
|
||||
function selectSettingsTab(name) {
|
||||
$$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name));
|
||||
$$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name));
|
||||
}
|
||||
|
||||
function updateNotificationPermissionStatus() {
|
||||
const status = $("#notify-permission-status");
|
||||
if (!status) return;
|
||||
status.textContent = !("Notification" in window) ? "Browser permission: unsupported" : `Browser permission: ${Notification.permission}`;
|
||||
}
|
||||
|
||||
async function loadNotificationPreferences() {
|
||||
try {
|
||||
const prefs = await request("/settings/notifications", { method: "GET" });
|
||||
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
|
||||
$("#notify-enabled").checked = Boolean(prefs.enabled);
|
||||
$("#notify-sound").checked = Boolean(prefs.sound);
|
||||
$("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled);
|
||||
$("#notify-dnd-start").value = prefs.dnd_start || "22:00";
|
||||
$("#notify-dnd-end").value = prefs.dnd_end || "07:00";
|
||||
renderNotificationFolders(prefs.folders || ["INBOX"]);
|
||||
updateNotificationPermissionStatus();
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
function renderNotificationFolders(selected) {
|
||||
const set = new Set(selected || []);
|
||||
const list = $("#notify-folders");
|
||||
list.replaceChildren();
|
||||
for (const folder of orderedFolders()) {
|
||||
const label = el("label", "checkbox-row");
|
||||
const input = document.createElement("input");
|
||||
input.type = "checkbox";
|
||||
input.value = folder.name;
|
||||
input.checked = set.has(folder.name);
|
||||
label.append(input, el("span", "", folder.name));
|
||||
list.append(label);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNotificationPreferences() {
|
||||
const folders = $$('#notify-folders input[type="checkbox"]:checked').map((input) => input.value);
|
||||
try {
|
||||
if ($("#notify-enabled").checked && "Notification" in window && Notification.permission === "default") await requestNotificationPermission();
|
||||
const prefs = await request("/settings/notifications", { method: "PUT", body: JSON.stringify({ enabled: $("#notify-enabled").checked, sound: $("#notify-sound").checked, folders, dnd_enabled: $("#notify-dnd-enabled").checked, dnd_start: $("#notify-dnd-start").value || "22:00", dnd_end: $("#notify-dnd-end").value || "07:00" }) });
|
||||
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
|
||||
updateNotificationPermissionStatus();
|
||||
toast("Notification settings saved");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
async function requestNotificationPermission() {
|
||||
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
|
||||
const result = await Notification.requestPermission();
|
||||
updateNotificationPermissionStatus();
|
||||
toast(`Notification permission: ${result}`, result === "granted" ? "success" : "warning");
|
||||
return result;
|
||||
}
|
||||
|
||||
async function testNotification() {
|
||||
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
|
||||
let permission = Notification.permission;
|
||||
if (permission === "default") permission = await requestNotificationPermission();
|
||||
if (permission !== "granted") return toast("Browser notifications are not allowed", "warning");
|
||||
const notification = new Notification("RustBox notification test", { body: "Browser notifications are working.", tag: "rustbox-test" });
|
||||
notification.onclick = () => { window.focus(); notification.close(); };
|
||||
if ($("#notify-sound")?.checked) playNotificationSound();
|
||||
toast("Test notification sent", "success");
|
||||
}
|
||||
|
||||
function dndActive(prefs = state.notificationPrefs) {
|
||||
if (!prefs?.dnd_enabled) return false;
|
||||
const toMinutes = (value) => {
|
||||
const [h, m] = String(value || "00:00").split(":").map(Number);
|
||||
return h * 60 + m;
|
||||
};
|
||||
const now = new Date();
|
||||
const current = now.getHours() * 60 + now.getMinutes();
|
||||
const start = toMinutes(prefs.dnd_start);
|
||||
const end = toMinutes(prefs.dnd_end);
|
||||
if (start === end) return true;
|
||||
return start < end ? current >= start && current < end : current >= start || current < end;
|
||||
}
|
||||
|
||||
function playNotificationSound() {
|
||||
try {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContext) return;
|
||||
const context = new AudioContext();
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.frequency.value = 620;
|
||||
gain.gain.setValueAtTime(0.0001, context.currentTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.035, context.currentTime + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.16);
|
||||
oscillator.connect(gain); gain.connect(context.destination);
|
||||
oscillator.start(); oscillator.stop(context.currentTime + 0.17);
|
||||
oscillator.onended = () => context.close().catch(() => { });
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
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;
|
||||
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(); };
|
||||
}
|
||||
if (prefs.sound) playNotificationSound();
|
||||
}
|
||||
|
||||
function calendarReminderStorageKey(event) {
|
||||
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}`;
|
||||
}
|
||||
|
||||
function fireCalendarReminder(event) {
|
||||
const title = event.title || "Calendar reminder";
|
||||
const location = event.location ? ` · ${event.location}` : "";
|
||||
const when = formatDate(event.start_time, true);
|
||||
toast(`${title} — ${when}${location}`, "info", 12000);
|
||||
if (!dndActive() && state.notificationPrefs?.enabled && "Notification" in window && Notification.permission === "granted") {
|
||||
const notification = new Notification(title, { body: `${when}${location}`, tag: `rustbox-calendar-${event.id}-${event.start_time}`, renotify: true });
|
||||
notification.onclick = () => { window.focus(); notification.close(); openCalendar(); };
|
||||
}
|
||||
if (!dndActive() && state.notificationPrefs?.sound) playNotificationSound();
|
||||
}
|
||||
|
||||
async function checkCalendarReminders() {
|
||||
if (!state.accountId) return;
|
||||
const now = Date.now();
|
||||
const start = new Date(now - 2 * 24 * 60 * 60 * 1000);
|
||||
const end = new Date(now + 2 * 24 * 60 * 60 * 1000);
|
||||
try {
|
||||
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
|
||||
for (const event of data.events || []) {
|
||||
if (event.reminder_minutes == null || String(event.status || "").toLowerCase() === "cancelled") continue;
|
||||
const startMs = new Date(event.start_time).getTime();
|
||||
if (!Number.isFinite(startMs)) continue;
|
||||
const trigger = startMs - Math.max(0, Number(event.reminder_minutes) || 0) * 60000;
|
||||
if (trigger > now || now - trigger > 10 * 60 * 1000) continue;
|
||||
const key = calendarReminderStorageKey(event);
|
||||
if (localStorage.getItem(key)) continue;
|
||||
localStorage.setItem(key, String(now));
|
||||
fireCalendarReminder(event);
|
||||
}
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function startCalendarReminderWatcher() {
|
||||
if (state.calendarReminderTimer) clearInterval(state.calendarReminderTimer);
|
||||
void checkCalendarReminders();
|
||||
state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000);
|
||||
}
|
||||
|
||||
function scheduleRealtimeRefresh(event, accountId) {
|
||||
if (accountId !== state.accountId) return;
|
||||
clearTimeout(state.realtimeRefreshTimer);
|
||||
state.realtimeRefreshTimer = setTimeout(async () => {
|
||||
await refreshFolderListOnly();
|
||||
if (["NewMessages", "FlagsChanged"].includes(event.type) && event.data?.folder === state.folder) {
|
||||
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true);
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function connectRealtime() {
|
||||
if (!state.accounts.length) return;
|
||||
if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return;
|
||||
clearTimeout(state.wsRetryTimer);
|
||||
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws`);
|
||||
state.ws = socket;
|
||||
socket.onmessage = (message) => {
|
||||
try {
|
||||
const payload = JSON.parse(message.data);
|
||||
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);
|
||||
};
|
||||
socket.onerror = () => socket.close();
|
||||
}
|
||||
|
||||
function disconnectRealtime() {
|
||||
clearTimeout(state.wsRetryTimer);
|
||||
clearTimeout(state.realtimeRefreshTimer);
|
||||
state.wsRetryTimer = null;
|
||||
state.realtimeRefreshTimer = null;
|
||||
const socket = state.ws;
|
||||
state.ws = null;
|
||||
if (socket && socket.readyState < WebSocket.CLOSING) socket.close();
|
||||
}
|
||||
|
||||
function capabilityBadge(label, supported) {
|
||||
return `<span class="capability-badge ${supported ? "supported" : "unsupported"}">${escapeHtml(label)}<strong>${supported ? "yes" : "no"}</strong></span>`;
|
||||
}
|
||||
|
||||
async function loadDiagnostics() {
|
||||
const imapStatus = $("#diagnostic-imap-status");
|
||||
const smtpStatus = $("#diagnostic-smtp-status");
|
||||
const sieveStatus = $("#diagnostic-sieve-status");
|
||||
const wsStatus = $("#diagnostic-ws-status");
|
||||
const syncStatus = $("#diagnostic-sync-status");
|
||||
const searchStatus = $("#diagnostic-search-status");
|
||||
imapStatus.textContent = "Checking";
|
||||
smtpStatus.textContent = "Checking";
|
||||
sieveStatus.textContent = "Checking";
|
||||
syncStatus.textContent = "Checking";
|
||||
searchStatus.textContent = "Checking";
|
||||
wsStatus.textContent = state.ws?.readyState === WebSocket.OPEN ? "Connected" : "Disconnected";
|
||||
try {
|
||||
const info = await request("/diagnostics/imap", { method: "GET" });
|
||||
imapStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||
$("#diagnostic-imap-summary").innerHTML = [
|
||||
["IDLE", info.idle], ["MOVE", info.moveExtension], ["UIDPLUS", info.uidplus], ["CONDSTORE", info.condstore],
|
||||
["QRESYNC", info.qresync], ["SORT", info.sort], ["THREAD", info.thread], ["NAMESPACE", info.namespace],
|
||||
["SPECIAL-USE", info.specialUse], ["QUOTA", info.quota], ["ACL", info.acl], ["LIST-EXTENDED", info.listExtended], ["UTF8=ACCEPT", info.utf8Accept], ["CUSTOM KEYWORDS", info.customKeywords],
|
||||
].map(([label, supported]) => capabilityBadge(label, supported)).join("");
|
||||
$("#diagnostic-imap-caps").textContent = (info.capabilities || []).join(" · ") || "No capabilities reported";
|
||||
} catch (error) {
|
||||
imapStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-imap-summary").textContent = error.message;
|
||||
$("#diagnostic-imap-caps").textContent = "";
|
||||
}
|
||||
try {
|
||||
const info = await request("/diagnostics/smtp", { method: "GET" });
|
||||
smtpStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||
$("#diagnostic-smtp-summary").textContent = info.connected ? (info.secureTransport ? "Authenticated connection · TLS enabled" : "Authenticated connection · plaintext transport") : "SMTP unavailable";
|
||||
} catch (error) {
|
||||
smtpStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-smtp-summary").textContent = error.message;
|
||||
}
|
||||
try {
|
||||
const info = await request("/sieve/status", { method: "GET" });
|
||||
sieveStatus.textContent = info.connected ? "Connected" : "Unavailable";
|
||||
$("#diagnostic-sieve-summary").textContent = `${info.tlsMode || ""}${info.implementation ? ` · ${info.implementation}` : ""}${info.extensions?.length ? ` · ${info.extensions.join(", ")}` : ""}`;
|
||||
} catch (error) {
|
||||
sieveStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-sieve-summary").textContent = error.message;
|
||||
}
|
||||
try {
|
||||
const info = await request("/diagnostics/sync", { method: "GET" });
|
||||
syncStatus.textContent = info.unsyncedFolders ? "Partial" : "Ready";
|
||||
const folderSync = info.latestFolderListSync ? formatDate(info.latestFolderListSync, true) : "never";
|
||||
const messageSync = info.latestMessageSync ? formatDate(info.latestMessageSync, true) : "never";
|
||||
$("#diagnostic-sync-summary").textContent = `${info.cachedFolders} cached folders · ${info.cachedMessages} cached messages · folder list ${folderSync} · messages ${messageSync}${info.unsyncedFolders ? ` · ${info.unsyncedFolders} folders awaiting message sync` : ""} · ${info.idleTasks || 0} IDLE task(s) / ${info.idleSubscribers || 0} subscriber(s)`;
|
||||
} catch (error) {
|
||||
syncStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-sync-summary").textContent = error.message;
|
||||
}
|
||||
try {
|
||||
const info = await request("/diagnostics/search", { method: "GET" });
|
||||
searchStatus.textContent = "Ready";
|
||||
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies${info.excludedMessages ? ` · ${info.excludedMessages} excluded` : ""}`;
|
||||
} catch (error) {
|
||||
searchStatus.textContent = "Unavailable";
|
||||
$("#diagnostic-search-summary").textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnectImap() {
|
||||
const button = $("#diagnostic-imap-reconnect");
|
||||
if (button) button.disabled = true;
|
||||
try {
|
||||
const result = await request("/diagnostics/imap/reconnect", { method: "POST", body: "{}" });
|
||||
toast(`IMAP reconnected · ${result.capabilityCount || 0} capabilities`);
|
||||
await loadDiagnostics();
|
||||
await loadFolders(false);
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildSearchIndex() {
|
||||
const button = $("#diagnostic-search-rebuild");
|
||||
if (!await appConfirm("Rebuild the search index from the local message cache?", { title: "Rebuild search index", confirmLabel: "Rebuild" })) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await request("/diagnostics/search/rebuild", { method: "POST", body: "{}" });
|
||||
toast(`Search index rebuilt: ${result.indexedDocuments} messages`);
|
||||
await loadDiagnostics();
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,454 @@
|
||||
/* RustBox UI module: 95-bindings-boot.js */
|
||||
function bindEditor() {
|
||||
$$(".editor-toolbar [data-command]").forEach((button) => button.addEventListener("click", () => {
|
||||
$("#compose-editor").focus();
|
||||
document.execCommand(button.dataset.command, false, button.dataset.commandValue || null);
|
||||
}));
|
||||
$("#editor-link").addEventListener("click", async () => {
|
||||
const url = await appPrompt("Insert link", { value: "https://", type: "url", confirmLabel: "Insert link", required: true });
|
||||
if (!url) return;
|
||||
$("#compose-editor").focus();
|
||||
document.execCommand("createLink", false, url);
|
||||
});
|
||||
$("#editor-font").addEventListener("change", (event) => {
|
||||
if (!event.target.value) return;
|
||||
$("#compose-editor").focus();
|
||||
document.execCommand("fontName", false, event.target.value);
|
||||
scheduleDraftSave();
|
||||
});
|
||||
$("#editor-size").addEventListener("change", (event) => {
|
||||
if (!event.target.value) return;
|
||||
$("#compose-editor").focus();
|
||||
document.execCommand("fontSize", false, event.target.value);
|
||||
scheduleDraftSave();
|
||||
});
|
||||
$("#editor-color").addEventListener("input", (event) => {
|
||||
$("#compose-editor").focus();
|
||||
document.execCommand("foreColor", false, event.target.value);
|
||||
scheduleDraftSave();
|
||||
});
|
||||
}
|
||||
|
||||
function bindPaneResizer(selector, preferenceKey, minPx, maxPx) {
|
||||
const handle = $(selector);
|
||||
if (!handle) return;
|
||||
handle.addEventListener("pointerdown", (event) => {
|
||||
if (innerWidth <= 820 || event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
const scaleFactor = Number(getComputedStyle(root).getPropertyValue("--ui-scale-factor")) || 1;
|
||||
const startX = event.clientX;
|
||||
const startRendered = Number(state.prefs[preferenceKey] || minPx) * scaleFactor;
|
||||
handle.setPointerCapture?.(event.pointerId);
|
||||
document.body.classList.add("resizing-pane");
|
||||
const move = (moveEvent) => {
|
||||
const rendered = Math.max(minPx * scaleFactor, Math.min(maxPx * scaleFactor, startRendered + (moveEvent.clientX - startX)));
|
||||
const base = Math.round(rendered / scaleFactor);
|
||||
state.prefs[preferenceKey] = base;
|
||||
const cssName = preferenceKey === "folderPaneWidth" ? "--folder-pane-width" : "--message-pane-width";
|
||||
root.style.setProperty(cssName, `${Math.round(rendered)}px`);
|
||||
handle.setAttribute("aria-valuenow", String(base));
|
||||
};
|
||||
const finish = () => {
|
||||
handle.removeEventListener("pointermove", move);
|
||||
handle.removeEventListener("pointerup", finish);
|
||||
handle.removeEventListener("pointercancel", finish);
|
||||
document.body.classList.remove("resizing-pane");
|
||||
savePreference(preferenceKey, state.prefs[preferenceKey]);
|
||||
};
|
||||
handle.addEventListener("pointermove", move);
|
||||
handle.addEventListener("pointerup", finish);
|
||||
handle.addEventListener("pointercancel", finish);
|
||||
});
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
bindPaneResizer("#folder-resizer", "folderPaneWidth", 170, 420);
|
||||
bindPaneResizer("#message-resizer", "messagePaneWidth", 300, 760);
|
||||
bindRecipientAutocomplete("#compose-to");
|
||||
bindRecipientAutocomplete("#compose-cc");
|
||||
bindRecipientAutocomplete("#compose-bcc");
|
||||
$("#login-form").addEventListener("submit", login);
|
||||
$("#logout-btn").addEventListener("click", logout);
|
||||
$("#compose-btn").addEventListener("click", () => openCompose("new"));
|
||||
$("#compose-fullscreen").addEventListener("click", toggleComposeFullscreen);
|
||||
$("#compose-form").addEventListener("submit", sendMessage);
|
||||
$("#compose-attach").addEventListener("click", () => $("#compose-files").click());
|
||||
$("#compose-files").addEventListener("change", async (event) => { await uploadComposeFiles(event.target.files); event.target.value = ""; });
|
||||
$("#editor-image").addEventListener("click", () => { state.composeInsertRange = captureEditorRange(); $("#compose-images").click(); });
|
||||
$("#editor-image-edit").addEventListener("click", () => openInlineImageEditor());
|
||||
$("#compose-images").addEventListener("change", async (event) => { await uploadInlineImage(event.target.files, state.composeInsertRange); state.composeInsertRange = null; event.target.value = ""; });
|
||||
$("#image-edit-form").addEventListener("submit", applyInlineImageEditor);
|
||||
$("#image-edit-reset").addEventListener("click", resetImageCropControls);
|
||||
["#image-crop-top", "#image-crop-right", "#image-crop-bottom", "#image-crop-left"].forEach((selector) => $(selector).addEventListener("input", drawInlineImageCropPreview));
|
||||
$("#image-edit-width").addEventListener("input", updateImageEditOutputs);
|
||||
$("#image-edit-dialog").addEventListener("close", () => { state.imageEditorBitmap?.close?.(); state.imageEditorBitmap = null; state.imageEditorBlob = null; });
|
||||
$("#compose-recover").addEventListener("click", recoverDraft);
|
||||
$("#compose-mode").addEventListener("change", async (event) => {
|
||||
const editor = $("#compose-editor");
|
||||
const previous = editor.dataset.mode || "html";
|
||||
const next = event.target.value;
|
||||
const richContent = previous === "html" && next !== "html" && Boolean(editor.querySelector("img,a,strong,b,em,i,u,s,ul,ol,li,blockquote,[style]"));
|
||||
if (richContent && !await appConfirm("Switching away from HTML can hide or discard formatting when the message is sent. Continue?", { title: "Change compose format", confirmLabel: "Switch format" })) {
|
||||
event.target.value = previous;
|
||||
return;
|
||||
}
|
||||
setComposeMode(next, true);
|
||||
insertIdentitySignature(editor);
|
||||
scheduleDraftSave();
|
||||
});
|
||||
$("#compose-from").addEventListener("change", () => { insertIdentitySignature($("#compose-editor")); updateComposePgpUi(true); });
|
||||
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("input", () => {
|
||||
state.pgpRecipientStatus = [];
|
||||
updateComposePgpUi();
|
||||
scheduleDraftSave();
|
||||
}));
|
||||
$("#compose-subject").addEventListener("input", scheduleDraftSave);
|
||||
["#compose-priority", "#compose-mdn", "#compose-pgp-mode", "#compose-pgp-inline", "#compose-pgp-attach-key", "#compose-pgp-attach-recipient-keys"].forEach((selector) => $(selector).addEventListener("change", async () => {
|
||||
if (selector === "#compose-pgp-attach-recipient-keys" && $(selector).checked) await checkComposePgpRecipients(false, composePgpVisibleRecipients());
|
||||
updateComposePgpUi();
|
||||
scheduleDraftSave();
|
||||
}));
|
||||
$("#compose-editor").addEventListener("input", () => { if (state.composeSelectedImage && !state.composeSelectedImage.isConnected) setSelectedInlineImage(null); scheduleDraftSave(); scheduleUnusedInlineAttachmentCleanup(); });
|
||||
$("#compose-editor").addEventListener("click", (event) => {
|
||||
const image = event.target.closest?.("img[data-rustbox-cid]");
|
||||
setSelectedInlineImage(image || null);
|
||||
});
|
||||
$("#compose-editor").addEventListener("dblclick", (event) => {
|
||||
const image = event.target.closest?.("img[data-rustbox-cid]");
|
||||
if (image) { event.preventDefault(); openInlineImageEditor(image); }
|
||||
});
|
||||
$("#compose-editor").addEventListener("paste", async (event) => {
|
||||
const images = [...(event.clipboardData?.items || [])]
|
||||
.filter((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))
|
||||
.map((item) => item.getAsFile())
|
||||
.filter(Boolean);
|
||||
if (!images.length) return;
|
||||
event.preventDefault();
|
||||
if ($("#compose-mode").value !== "html") return toast("Switch to HTML mode to paste inline images", "warning");
|
||||
const range = captureEditorRange();
|
||||
await uploadInlineImage(images, range);
|
||||
});
|
||||
$("#compose-editor").addEventListener("dragover", (event) => { if ([...event.dataTransfer.types].includes("Files")) event.preventDefault(); });
|
||||
$("#compose-editor").addEventListener("drop", async (event) => {
|
||||
const files = [...event.dataTransfer.files];
|
||||
if (!files.length) return;
|
||||
event.preventDefault();
|
||||
const allImages = files.every((file) => /^image\/(png|jpeg|gif|webp)$/i.test(file.type));
|
||||
if ($("#compose-mode").value === "html" && allImages) await uploadInlineImage(files, editorRangeFromPoint(event.clientX, event.clientY));
|
||||
else await uploadComposeFiles(files);
|
||||
});
|
||||
$("#reply-btn").addEventListener("click", () => openCompose("reply"));
|
||||
$("#reply-all-btn").addEventListener("click", () => openCompose("reply-all"));
|
||||
$("#forward-btn").addEventListener("click", () => openCompose("forward"));
|
||||
$("#delete-btn").addEventListener("click", deleteCurrent);
|
||||
$("#unread-btn").addEventListener("click", toggleUnread);
|
||||
$("#flag-btn").addEventListener("click", toggleFlagged);
|
||||
$("#archive-btn").addEventListener("click", archiveCurrent);
|
||||
$("#spam-btn").addEventListener("click", junkCurrent);
|
||||
$("#move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await moveCurrentTo(target); });
|
||||
$("#copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await copyCurrentTo(target); });
|
||||
$("#bulk-select-all").addEventListener("change", (event) => { const source = state.searchAllResults || state.messages; for (const message of source) { const key = messageKey(message); event.target.checked ? state.selectedMessages.add(key) : state.selectedMessages.delete(key); } renderMessages(); });
|
||||
$("#bulk-read").addEventListener("click", () => performBulk("read"));
|
||||
$("#bulk-unread").addEventListener("click", () => performBulk("unread"));
|
||||
$("#bulk-flag").addEventListener("click", () => performBulk("flag"));
|
||||
$("#bulk-unflag").addEventListener("click", () => performBulk("unflag"));
|
||||
$("#bulk-archive").addEventListener("click", () => performBulk("archive"));
|
||||
$("#bulk-select-search").addEventListener("click", selectAllSearchResults);
|
||||
$("#bulk-junk").addEventListener("click", () => performBulk("junk"));
|
||||
$("#bulk-delete").addEventListener("click", () => performBulk("delete"));
|
||||
$("#bulk-move-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await performBulk("move", target); });
|
||||
$("#bulk-copy-select").addEventListener("change", async (event) => { const target = event.target.value; event.target.value = ""; if (target) await performBulk("copy", target); });
|
||||
$("#bulk-tag-select").addEventListener("change", async (event) => { const tagId = event.target.value; event.target.value = ""; if (tagId) await bulkAddTag(tagId); });
|
||||
$("#select-mode-btn").addEventListener("click", () => setSelectionMode(!state.selectionMode));
|
||||
$("#selection-done").addEventListener("click", () => setSelectionMode(false));
|
||||
$("#refresh-btn").addEventListener("click", () => loadFolders(false));
|
||||
$("#message-sort").addEventListener("change", async (event) => {
|
||||
const value = event.target.value;
|
||||
state.mailPrefs.default_sort = value;
|
||||
$("#mail-pref-sort").value = value;
|
||||
try {
|
||||
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify({ default_sort: value }) });
|
||||
state.page = 0;
|
||||
await loadMessages(true);
|
||||
toast("Message sort updated", "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
});
|
||||
$("#load-more").addEventListener("click", async () => { state.page += 1; await loadMessages(false); });
|
||||
$("#message-list").addEventListener("scroll", scheduleMessageListStateSave, { passive: true });
|
||||
window.addEventListener("offline", () => setConnectivityState(false));
|
||||
window.addEventListener("online", async () => {
|
||||
setConnectivityState(true);
|
||||
disconnectRealtime();
|
||||
connectRealtime();
|
||||
if (state.accounts.length) {
|
||||
await refreshFolderListOnly();
|
||||
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
||||
else await loadMessages(true);
|
||||
}
|
||||
});
|
||||
$("#search-form").addEventListener("submit", searchMail);
|
||||
$("#advanced-search-btn").addEventListener("click", openAdvancedSearch);
|
||||
$("#advanced-search-form").addEventListener("submit", submitAdvancedSearch);
|
||||
$("#search-reset-btn").addEventListener("click", resetAdvancedSearch);
|
||||
$("#search-save-btn").addEventListener("click", saveAdvancedSearch);
|
||||
$("#contacts-btn").addEventListener("click", openContacts);
|
||||
$("#contact-group-filter").addEventListener("change", async () => { renderContactGroupFilter(); await loadContacts($("#contact-search").value.trim()); });
|
||||
$("#contact-group-new").addEventListener("click", createContactGroup);
|
||||
$("#contact-group-rename").addEventListener("click", renameContactGroup);
|
||||
$("#contact-group-delete").addEventListener("click", deleteContactGroup);
|
||||
$("#tag-add-btn").addEventListener("click", () => openTagDialog());
|
||||
$("#tag-form").addEventListener("submit", saveTag);
|
||||
$("#tag-delete").addEventListener("click", deleteTag);
|
||||
$("#calendar-btn").addEventListener("click", openCalendar);
|
||||
$("#settings-btn").addEventListener("click", () => openSettings("appearance"));
|
||||
$("#shortcuts-open").addEventListener("click", () => $("#shortcuts-dialog").showModal());
|
||||
$("#account-add").addEventListener("click", () => { $("#add-account-error").classList.add("hidden"); $("#add-account-dialog").showModal(); setTimeout(() => $("#add-account-email").focus(), 20); });
|
||||
$("#account-revoke-others").addEventListener("click", revokeOtherSessions);
|
||||
$("#add-account-form").addEventListener("submit", addBrowserAccount);
|
||||
$("#mobile-folders").addEventListener("click", toggleFolderDrawer);
|
||||
$("#folder-drawer-close").addEventListener("click", () => setFolderDrawerOpen(false));
|
||||
$("#folder-drawer-scrim").addEventListener("click", () => setFolderDrawerOpen(false));
|
||||
$("#reader-back").addEventListener("click", closeReader);
|
||||
$("#reader-prev").addEventListener("click", () => navigateMessage(-1));
|
||||
$("#reader-next").addEventListener("click", () => navigateMessage(1));
|
||||
$("#thread-toggle").addEventListener("click", () => {
|
||||
state.threadExpanded = !state.threadExpanded;
|
||||
localStorage.setItem("rustbox.threadExpanded", String(state.threadExpanded));
|
||||
if (state.currentDetail) renderThread(state.currentDetail);
|
||||
});
|
||||
$("#folder-manage-btn").addEventListener("click", openFolderDialog);
|
||||
$("#create-folder-btn").addEventListener("click", createFolder);
|
||||
$("#rename-folder-btn").addEventListener("click", renameFolder);
|
||||
$("#delete-folder-btn").addEventListener("click", deleteFolder);
|
||||
$("#subscribe-folder-btn").addEventListener("click", toggleCurrentFolderSubscription);
|
||||
$("#headers-btn").addEventListener("click", showHeaders);
|
||||
$("#source-btn").addEventListener("click", showMessageSource);
|
||||
$("#quick-filter-btn").addEventListener("click", () => createFilterFromCurrentMessage("sender"));
|
||||
$("#reader-from").addEventListener("click", (event) => { const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); });
|
||||
$("#reader-from").addEventListener("keydown", (event) => { if (["Enter", " "].includes(event.key)) { event.preventDefault(); const rect = event.currentTarget.getBoundingClientRect(); openCurrentSenderMenu(rect.left, rect.bottom + 4); } });
|
||||
$("#print-btn").addEventListener("click", printCurrentMessage);
|
||||
$("#pgp-reader-process").addEventListener("click", processCurrentPgpMessage);
|
||||
$("#pgp-reader-discover").addEventListener("click", discoverCurrentPgpSigner);
|
||||
$("#copy-headers-btn").addEventListener("click", copyHeaders);
|
||||
$$(".close-dialog").forEach((button) => button.addEventListener("click", async () => {
|
||||
const dialog = button.closest("dialog");
|
||||
if (dialog?.id === "compose-dialog") await closeComposer();
|
||||
else dialog?.close();
|
||||
}));
|
||||
$("#compose-dialog").addEventListener("cancel", async (event) => { event.preventDefault(); await closeComposer(); });
|
||||
window.addEventListener("beforeunload", () => { if (state.composeDirty && draftHasContent()) storeLocalDraftSnapshot(); });
|
||||
$("#compose-pgp-check").addEventListener("click", () => checkComposePgpRecipients(true));
|
||||
$("#compose-pgp-discover").addEventListener("click", discoverComposePgpRecipients);
|
||||
["#compose-to", "#compose-cc", "#compose-bcc"].forEach((selector) => $(selector).addEventListener("change", () => { state.pgpRecipientStatus = []; updateComposePgpUi(); }));
|
||||
$("#pgp-refresh").addEventListener("click", loadOpenPgp);
|
||||
$("#pgp-pref-save").addEventListener("click", savePgpPreferences);
|
||||
$("#pgp-generate").addEventListener("click", generatePgpKey);
|
||||
$("#pgp-import").addEventListener("click", () => importPgpText());
|
||||
$("#pgp-import-file").addEventListener("click", () => $("#pgp-import-file-input").click());
|
||||
$("#pgp-import-file-input").addEventListener("change", async (event) => { const file = event.target.files?.[0]; if (file) await importPgpFile(file); event.target.value = ""; });
|
||||
$("#pgp-discover").addEventListener("click", () => discoverPgpEmail());
|
||||
$$(".settings-tab").forEach((button) => button.addEventListener("click", () => { selectSettingsTab(button.dataset.tab); if (button.dataset.tab === "sieve") loadSieve(); if (button.dataset.tab === "identities") { loadIdentities(); loadOpenPgp(); } if (button.dataset.tab === "openpgp") loadOpenPgp(); if (button.dataset.tab === "notifications") loadNotificationPreferences(); if (button.dataset.tab === "mailbox") loadMailPreferences(); if (button.dataset.tab === "composing") loadResponseTemplates(); if (button.dataset.tab === "diagnostics") loadDiagnostics(); if (button.dataset.tab === "account") { renderAccountSettings(); loadActiveSessions(); } if (button.dataset.tab === "folders") { loadSpecialFolders(); loadQuota(); } }));
|
||||
$("#pref-theme").addEventListener("change", (event) => savePreference("theme", event.target.value));
|
||||
$("#pref-interface-skin").addEventListener("change", (event) => savePreference("interfaceSkin", event.target.value));
|
||||
$("#pref-density").addEventListener("change", (event) => savePreference("density", event.target.value));
|
||||
$("#pref-preview").addEventListener("change", (event) => savePreference("preview", event.target.value));
|
||||
$("#pref-scale").addEventListener("change", (event) => savePreference("scale", event.target.value));
|
||||
$("#pref-language").addEventListener("change", (event) => savePreference("language", event.target.value));
|
||||
$("#pref-color-preset").addEventListener("change", (event) => savePreference("colorPreset", event.target.value));
|
||||
$("#pref-accent").addEventListener("input", (event) => { $("#pref-accent-value").textContent = event.target.value; savePreference("accentColor", event.target.value); });
|
||||
[["#pref-background", "#pref-background-value", "backgroundColor"], ["#pref-panel", "#pref-panel-value", "panelColor"], ["#pref-text-color", "#pref-text-color-value", "textColor"]].forEach(([input, output, key]) => $(input).addEventListener("input", (event) => { $(output).textContent = event.target.value; savePreference(key, event.target.value); }));
|
||||
$("#pref-font-family").addEventListener("change", (event) => savePreference("fontFamily", event.target.value));
|
||||
[["#pref-font-size", "fontSize"], ["#pref-row-height", "rowHeight"], ["#pref-folder-width", "folderPaneWidth"], ["#pref-message-width", "messagePaneWidth"], ["#pref-radius", "borderRadius"]].forEach(([selector, key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value))));
|
||||
$("#pref-contrast").addEventListener("change", (event) => savePreference("contrast", event.target.value));
|
||||
$("#pref-date-format").addEventListener("change", (event) => { savePreference("dateFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); });
|
||||
$("#pref-time-format").addEventListener("change", (event) => { savePreference("timeFormat", event.target.value); renderMessages(); if (state.currentDetail) renderMessageDetail(state.currentDetail); });
|
||||
$("#pref-avatars").addEventListener("change", (event) => savePreference("showAvatars", event.target.checked));
|
||||
$("#pref-attachment-icons").addEventListener("change", (event) => savePreference("showAttachmentIcons", event.target.checked));
|
||||
$("#pref-flags").addEventListener("change", (event) => savePreference("showFlags", event.target.checked));
|
||||
$("#pref-reduce-motion").addEventListener("change", (event) => savePreference("reduceMotion", event.target.checked));
|
||||
[["#pref-icon-size", "iconSize"], ["#pref-sidebar-height", "sidebarItemHeight"], ["#pref-composer-width", "composerWidth"], ["#pref-unread-weight", "unreadFontWeight"]].forEach(([selector, key]) => $(selector).addEventListener("input", (event) => savePreference(key, Number(event.target.value))));
|
||||
$("#pref-reader-width").addEventListener("change", (event) => savePreference("readerMaxWidth", Number(event.target.value)));
|
||||
$("#pref-toolbar-density").addEventListener("change", (event) => savePreference("toolbarDensity", event.target.value));
|
||||
$("#pref-selection-color").addEventListener("input", (event) => { $("#pref-selection-color-value").textContent = event.target.value; savePreference("selectionColor", event.target.value); });
|
||||
$("#pref-unread-badge").addEventListener("change", (event) => savePreference("unreadBadgeStyle", event.target.value));
|
||||
$("#pref-scrollbar-density").addEventListener("change", (event) => savePreference("scrollbarDensity", event.target.value));
|
||||
$("#pref-compact-headers").addEventListener("change", (event) => savePreference("compactHeaders", event.target.checked));
|
||||
$("#pref-show-borders").addEventListener("change", (event) => savePreference("showBorders", event.target.checked));
|
||||
$("#pref-monochrome").addEventListener("change", (event) => savePreference("monochrome", event.target.checked));
|
||||
[["folder", "folderBackground", "#101310"], ["message", "messageBackground", "#181c19"], ["reader", "readerBackground", "#181c19"]].forEach(([name, key, fallback]) => {
|
||||
const enabled = $(`#pref-${name}-bg-enabled`); const color = $(`#pref-${name}-bg`);
|
||||
enabled.addEventListener("change", () => savePreference(key, enabled.checked ? color.value : ""));
|
||||
color.addEventListener("input", () => { if (enabled.checked) savePreference(key, color.value); });
|
||||
});
|
||||
$("#response-template-new").addEventListener("click", () => editResponseTemplate());
|
||||
$("#compose-response-template").addEventListener("change", (event) => { $("#compose-response-insert").disabled = !event.target.value; });
|
||||
$("#compose-response-insert").addEventListener("click", insertSavedResponse);
|
||||
$("#compose-pref-format").addEventListener("change", (event) => savePreference("composeFormat", event.target.value));
|
||||
$("#compose-pref-autosave").addEventListener("change", (event) => savePreference("composeAutosaveMs", Number(event.target.value)));
|
||||
$("#compose-pref-priority").addEventListener("change", (event) => savePreference("defaultPriority", event.target.value));
|
||||
$("#compose-pref-mdn").addEventListener("change", (event) => savePreference("requestMdnDefault", event.target.checked));
|
||||
$("#compose-pref-attachment-reminder").addEventListener("change", (event) => savePreference("attachmentReminder", event.target.checked));
|
||||
$("#compose-pref-font").addEventListener("change", (event) => savePreference("composeFontFamily", event.target.value));
|
||||
$("#compose-pref-reply-position").addEventListener("change", (event) => savePreference("replyPosition", event.target.value));
|
||||
$("#compose-pref-quote-style").addEventListener("change", (event) => savePreference("quoteStyle", event.target.value));
|
||||
$("#compose-pref-forward-mode").addEventListener("change", (event) => savePreference("forwardMode", event.target.value));
|
||||
$("#compose-pref-spellcheck").addEventListener("change", (event) => savePreference("spellcheckEnabled", event.target.checked));
|
||||
$("#compose-markdown-preview").addEventListener("click", previewMarkdown);
|
||||
$("#pref-reset").addEventListener("click", resetAppearance);
|
||||
$("#special-folders-save").addEventListener("click", saveSpecialFolders);
|
||||
$("#special-folders-auto").addEventListener("click", autoDetectSpecialFolders);
|
||||
$("#identity-form").addEventListener("submit", saveIdentity);
|
||||
$("#identity-new").addEventListener("click", () => editIdentity(null));
|
||||
$("#identity-delete").addEventListener("click", deleteIdentity);
|
||||
$$("[data-signature-command]").forEach((button) => button.addEventListener("click", () => {
|
||||
const editor = $("#identity-signature");
|
||||
editor.focus();
|
||||
document.execCommand(button.dataset.signatureCommand, false, null);
|
||||
}));
|
||||
$("#identity-signature-link").addEventListener("click", async () => {
|
||||
const editor = $("#identity-signature");
|
||||
const range = captureEditorRange(editor);
|
||||
const url = await appPrompt("Insert signature link", { placeholder: "https://example.com", type: "url", confirmLabel: "Insert" });
|
||||
if (!url) return;
|
||||
editor.focus();
|
||||
restoreEditorRange(range, editor);
|
||||
document.execCommand("createLink", false, url);
|
||||
});
|
||||
$("#identity-signature-image-btn").addEventListener("click", () => { state.identitySignatureRange = captureEditorRange($("#identity-signature")); $("#identity-signature-image").click(); });
|
||||
$("#identity-signature-image").addEventListener("change", async (event) => { const file = event.target.files?.[0]; await insertIdentitySignatureImage(file); event.target.value = ""; });
|
||||
$("#identity-signature").addEventListener("paste", async (event) => {
|
||||
const file = [...(event.clipboardData?.items || [])].find((item) => item.kind === "file" && /^image\/(png|jpeg|gif|webp)$/i.test(item.type))?.getAsFile();
|
||||
if (!file) return;
|
||||
event.preventDefault();
|
||||
await insertIdentitySignatureImage(file, captureEditorRange($("#identity-signature")));
|
||||
});
|
||||
$("#contact-form").addEventListener("submit", saveContact);
|
||||
$("#contact-new").addEventListener("click", () => editContact(null));
|
||||
$("#contact-import").addEventListener("click", () => $("#contact-import-file").click());
|
||||
$("#contact-import-file").addEventListener("change", (event) => importContactVcard(event.target.files));
|
||||
$("#contact-export").addEventListener("click", downloadContactExport);
|
||||
$("#contact-duplicates").addEventListener("click", showContactDuplicates);
|
||||
$("#contact-photo-upload").addEventListener("click", () => $("#contact-photo-file").click());
|
||||
$("#contact-photo-file").addEventListener("change", (event) => uploadContactPhoto(event.target.files));
|
||||
$("#contact-photo-delete").addEventListener("click", deleteContactPhoto);
|
||||
$("#contact-delete").addEventListener("click", deleteContact);
|
||||
$("#contact-compose").addEventListener("click", composeToContact);
|
||||
$("#contact-search").addEventListener("input", (event) => { clearTimeout(event.target._timer); event.target._timer = setTimeout(() => loadContacts(event.target.value.trim()), 180); });
|
||||
$("#calendar-form").addEventListener("submit", saveCalendarEvent);
|
||||
$("#calendar-import").addEventListener("click", () => $("#calendar-import-file").click());
|
||||
$("#calendar-import-file").addEventListener("change", (event) => importCalendarIcs(event.target.files));
|
||||
$("#calendar-export").addEventListener("click", exportCalendarIcs);
|
||||
$("#calendar-recurrence").addEventListener("change", updateCalendarRecurrenceUi);
|
||||
$("#calendar-new").addEventListener("click", () => editCalendarEvent(null));
|
||||
$("#calendar-cancel").addEventListener("click", () => $("#calendar-form").classList.add("hidden"));
|
||||
$("#calendar-delete").addEventListener("click", deleteCalendarEvent);
|
||||
$("#calendar-view").addEventListener("change", async (event) => { state.calendarView = event.target.value; localStorage.setItem("rustbox.calendarView", state.calendarView); await loadCalendar(); });
|
||||
$("#calendar-prev").addEventListener("click", async () => { shiftCalendarCursor(-1); await loadCalendar(); });
|
||||
$("#calendar-next").addEventListener("click", async () => { shiftCalendarCursor(1); await loadCalendar(); });
|
||||
$("#calendar-today").addEventListener("click", async () => { state.calendarCursor = new Date(); await loadCalendar(); });
|
||||
$("#notify-save").addEventListener("click", saveNotificationPreferences);
|
||||
$("#notify-permission").addEventListener("click", requestNotificationPermission);
|
||||
$("#notify-test").addEventListener("click", testNotification);
|
||||
$("#notify-enabled").addEventListener("change", (event) => { if (event.target.checked && "Notification" in window && Notification.permission === "default") requestNotificationPermission(); });
|
||||
$("#mail-pref-save").addEventListener("click", saveMailPreferences);
|
||||
$("#mail-pref-reconcile-labels")?.addEventListener("click", reconcileImapLabels);
|
||||
$("#diagnostics-refresh").addEventListener("click", loadDiagnostics);
|
||||
$("#diagnostic-imap-reconnect").addEventListener("click", reconnectImap);
|
||||
$("#diagnostic-search-rebuild").addEventListener("click", rebuildSearchIndex);
|
||||
$("#remote-image-load").addEventListener("click", () => { if (state.currentDetail) renderMessageBody(state.currentDetail, true); });
|
||||
$("#remote-image-trust").addEventListener("click", trustCurrentSender);
|
||||
$("#reader-add-sender-contact").addEventListener("click", (event) => { event.stopPropagation(); addSenderToContacts(); });
|
||||
const sieveBuilderFields = [
|
||||
"#sieve-builder-name", "#sieve-builder-enabled", "#sieve-builder-stop",
|
||||
"#sieve-builder-condition", "#sieve-builder-match", "#sieve-builder-header", "#sieve-builder-value", "#sieve-builder-negate",
|
||||
"#sieve-builder-condition-2", "#sieve-builder-match-2", "#sieve-builder-header-2", "#sieve-builder-value-2", "#sieve-builder-negate-2", "#sieve-builder-logic",
|
||||
"#sieve-builder-condition-3", "#sieve-builder-match-3", "#sieve-builder-header-3", "#sieve-builder-value-3", "#sieve-builder-negate-3", "#sieve-builder-logic-3",
|
||||
"#sieve-builder-action", "#sieve-builder-target", "#sieve-builder-action-2", "#sieve-builder-target-2",
|
||||
];
|
||||
sieveBuilderFields.forEach((selector) => {
|
||||
const node = $(selector);
|
||||
if (!node) return;
|
||||
node.addEventListener("input", updateSieveBuilder);
|
||||
node.addEventListener("change", updateSieveBuilder);
|
||||
});
|
||||
$("#sieve-rule-new").addEventListener("click", openNewSieveRuleDialog);
|
||||
$("#sieve-builder-add-condition").addEventListener("click", () => { $("#sieve-builder-extra-conditions").append(createSieveDynamicCondition()); updateSieveBuilder(); });
|
||||
$("#sieve-builder-add-action").addEventListener("click", () => { $("#sieve-builder-extra-actions").append(createSieveDynamicAction()); updateSieveBuilder(); });
|
||||
$("#sieve-vacation-start").addEventListener("change", updateSieveBuilder);
|
||||
$("#sieve-vacation-end").addEventListener("change", updateSieveBuilder);
|
||||
$("#sieve-builder-append").addEventListener("click", () => applySieveVisualRule(false));
|
||||
$("#sieve-builder-replace").addEventListener("click", () => applySieveVisualRule(true));
|
||||
$("#sieve-builder-clear").addEventListener("click", clearSieveRuleBuilder);
|
||||
$("#sieve-editor").addEventListener("input", () => {
|
||||
state.sieveEditingRuleId = null;
|
||||
renderManagedSieveRules();
|
||||
updateSieveBuilder();
|
||||
});
|
||||
$("#sieve-vacation-append").addEventListener("click", appendSieveVacation);
|
||||
$("#sieve-backup").addEventListener("click", backupSieveScripts);
|
||||
$("#sieve-restore").addEventListener("click", () => $("#sieve-restore-file").click());
|
||||
$("#sieve-restore-file").addEventListener("change", (event) => restoreSieveBackup(event.target.files?.[0]));
|
||||
$("#sieve-refresh").addEventListener("click", loadSieve);
|
||||
$("#sieve-new").addEventListener("click", newSieveScript);
|
||||
$("#sieve-check").addEventListener("click", validateSieve);
|
||||
$("#sieve-save").addEventListener("click", saveSieve);
|
||||
$("#sieve-activate").addEventListener("click", activateSieve);
|
||||
$("#sieve-deactivate").addEventListener("click", deactivateSieve);
|
||||
$("#sieve-delete").addEventListener("click", deleteSieve);
|
||||
bindEditor();
|
||||
document.addEventListener("click", (event) => { if (!event.target.closest("#context-menu")) hideContextMenu(); });
|
||||
window.addEventListener("blur", hideContextMenu);
|
||||
document.addEventListener("scroll", hideContextMenu, true);
|
||||
document.addEventListener("keydown", (event) => {
|
||||
const tag = document.activeElement?.tagName || "";
|
||||
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) {
|
||||
event.preventDefault(); $("#search-input").focus();
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "n") { event.preventDefault(); openCompose("new"); }
|
||||
if (event.key === "?" && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable) {
|
||||
event.preventDefault();
|
||||
if (!document.querySelector("dialog[open]")) $("#shortcuts-dialog").showModal();
|
||||
}
|
||||
const dialogOpen = Boolean(document.querySelector("dialog[open]"));
|
||||
const keyboardIdle = !dialogOpen && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable;
|
||||
if (keyboardIdle) {
|
||||
const globalKey = event.key.toLowerCase();
|
||||
if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; }
|
||||
if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; }
|
||||
}
|
||||
if (keyboardIdle && state.currentDetail) {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "x") {
|
||||
const current = state.messages.find((message) => message.uid === state.currentDetail.uid && (message.folder || state.folder) === state.currentDetail.folder);
|
||||
if (current) { event.preventDefault(); setMessageSelected(current, !state.selectedMessages.has(messageKey(current))); }
|
||||
}
|
||||
else if (key === "j") { event.preventDefault(); navigateMessage(1); }
|
||||
else if (key === "k") { event.preventDefault(); navigateMessage(-1); }
|
||||
else if (key === "r") { event.preventDefault(); openCompose("reply"); }
|
||||
else if (key === "a") { event.preventDefault(); openCompose("reply-all"); }
|
||||
else if (key === "f") { event.preventDefault(); openCompose("forward"); }
|
||||
else if (key === "e") { event.preventDefault(); archiveCurrent(); }
|
||||
else if (key === "m") { event.preventDefault(); toggleUnread(); }
|
||||
else if (key === "s") { event.preventDefault(); toggleFlagged(); }
|
||||
else if (event.key === "!") { event.preventDefault(); junkCurrent(); }
|
||||
else if (event.key === "Delete") { event.preventDefault(); deleteCurrent(); }
|
||||
}
|
||||
if (event.key === "Escape" && innerWidth <= 820) {
|
||||
if ($("#folder-pane")?.classList.contains("open")) setFolderDrawerOpen(false);
|
||||
else closeReader();
|
||||
}
|
||||
});
|
||||
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); });
|
||||
window.addEventListener("resize", () => { if (innerWidth > 820) setFolderDrawerOpen(false); });
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
applyPreferences();
|
||||
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 loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
|
||||
}
|
||||
|
||||
boot();
|
||||
}) ();
|
||||
@@ -0,0 +1,13 @@
|
||||
00-bootstrap.js
|
||||
10-core.js
|
||||
20-preferences-auth.js
|
||||
30-folders-tags.js
|
||||
40-messages-reader.js
|
||||
50-compose.js
|
||||
60-mail-actions-search.js
|
||||
70-contacts.js
|
||||
71-calendar.js
|
||||
72-openpgp-identities.js
|
||||
80-settings-realtime.js
|
||||
90-sieve.js
|
||||
95-bindings-boot.js
|
||||
Reference in new issue
Block a user