7049 lines
369 KiB
JavaScript
7049 lines
369 KiB
JavaScript
(() => {
|
||
"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: [],
|
||
contactGroups: [],
|
||
contactPhotoUrl: null,
|
||
calendarCursor: new Date(),
|
||
calendarView: localStorage.getItem("rustbox.calendarView") || "month",
|
||
threadExpanded: localStorage.getItem("rustbox.threadExpanded") !== "false",
|
||
calendarEvents: [],
|
||
savedSearches: [],
|
||
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,
|
||
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", 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 apiPath(path) {
|
||
return `${API}${path.startsWith("/") ? path : `/${path}`}`;
|
||
}
|
||
|
||
function sleep(ms) {
|
||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
function resolveTheme(theme) {
|
||
if (theme !== "system") return theme;
|
||
return matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
|
||
}
|
||
|
||
const preferenceMap = {
|
||
theme: "theme", 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();
|
||
|
||
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;
|
||
root.dataset.theme = resolveTheme(p.theme);
|
||
root.dataset.density = p.density;
|
||
root.dataset.preview = p.preview;
|
||
root.dataset.scale = p.scale;
|
||
root.dataset.colorPreset = p.colorPreset;
|
||
root.dataset.fontFamily = p.fontFamily;
|
||
root.dataset.contrast = p.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 = p.toolbarDensity;
|
||
root.dataset.unreadBadge = p.unreadBadgeStyle;
|
||
root.dataset.compactHeaders = String(p.compactHeaders);
|
||
root.dataset.showBorders = String(p.showBorders);
|
||
root.dataset.scrollbarDensity = p.scrollbarDensity;
|
||
root.dataset.monochrome = String(p.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 })[p.scale] || 1;
|
||
const densityFactor = p.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(p.fontSize || 13) * scaleFactor * 100) / 100}px`);
|
||
root.style.setProperty("--row-height", `${Math.max(42, Math.round(Number(p.rowHeight || 58) * scaleFactor))}px`);
|
||
root.style.setProperty("--folder-pane-width", `${Math.max(170, Math.round(Number(p.folderPaneWidth || 228) * scaleFactor))}px`);
|
||
root.style.setProperty("--message-pane-width", `${Math.max(300, Math.round(Number(p.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(p.iconSize || 18) * scaleFactor))}px`);
|
||
const toolbarFactor = ({ compact: 0.88, normal: 1, spacious: 1.14 })[p.toolbarDensity] || 1;
|
||
root.style.setProperty("--toolbar-density-factor", String(toolbarFactor));
|
||
root.style.setProperty("--sidebar-item-height", `${Math.max(24, Math.round(Number(p.sidebarItemHeight || 29) * scaleFactor))}px`);
|
||
root.style.setProperty("--reader-max-width", Number(p.readerMaxWidth) > 0 ? `${Number(p.readerMaxWidth)}px` : "none");
|
||
root.style.setProperty("--composer-width", `${Math.max(640, Number(p.composerWidth || 900))}px`);
|
||
root.style.setProperty("--compose-font-family", composeFontCss(p.composeFontFamily));
|
||
root.style.setProperty("--selected", p.selectionColor || "#28352e");
|
||
root.style.setProperty("--unread-font-weight", String(Number(p.unreadFontWeight || 730)));
|
||
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("--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", `${p.borderRadius}px`);
|
||
root.style.setProperty("--radius-panel", `${Math.min(p.borderRadius + 1, 13)}px`);
|
||
|
||
const values = {
|
||
"#pref-theme": p.theme, "#pref-density": p.density, "#pref-preview": p.preview, "#pref-scale": p.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": p.fontFamily, "#pref-font-size": p.fontSize, "#pref-row-height": p.rowHeight,
|
||
"#pref-folder-width": p.folderPaneWidth, "#pref-message-width": p.messagePaneWidth, "#pref-radius": p.borderRadius,
|
||
"#pref-contrast": p.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": p.iconSize, "#pref-toolbar-density": p.toolbarDensity, "#pref-sidebar-height": p.sidebarItemHeight, "#pref-reader-width": String(p.readerMaxWidth), "#pref-composer-width": p.composerWidth,
|
||
"#pref-selection-color": p.selectionColor, "#pref-unread-weight": p.unreadFontWeight, "#pref-unread-badge": p.unreadBadgeStyle, "#pref-scrollbar-density": p.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": p.compactHeaders, "#pref-show-borders": p.showBorders, "#pref-monochrome": p.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": `${p.fontSize} px`, "#pref-row-height-value": `${p.rowHeight} px`, "#pref-folder-width-value": `${p.folderPaneWidth} px`, "#pref-message-width-value": `${p.messagePaneWidth} px`, "#pref-radius-value": `${p.borderRadius} px`, "#pref-icon-size-value": `${p.iconSize} px`, "#pref-sidebar-height-value": `${p.sidebarItemHeight} px`, "#pref-composer-width-value": `${p.composerWidth} px`, "#pref-selection-color-value": p.selectionColor, "#pref-unread-weight-value": String(p.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; });
|
||
}
|
||
|
||
function normalizeServerPreferences(data) {
|
||
if (!data) return;
|
||
state.prefs = {
|
||
...state.prefs,
|
||
theme: data.theme || "system", 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", 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"); }
|
||
}
|
||
|
||
const appearancePresets = {
|
||
compact: { density:"compact", scale:"small", fontSize:12, rowHeight:46, folderPaneWidth:196, messagePaneWidth:350, borderRadius:2, preview:"right" },
|
||
balanced: { density:"compact", scale:"normal", fontSize:13, rowHeight:56, folderPaneWidth:228, messagePaneWidth:420, borderRadius:4, preview:"right" },
|
||
comfortable: { density:"comfortable", scale:"normal", fontSize:14, rowHeight:66, folderPaneWidth:244, messagePaneWidth:460, borderRadius:6, preview:"right" },
|
||
reading: { density:"comfortable", scale:"large", fontSize:15, rowHeight:72, folderPaneWidth:236, messagePaneWidth:520, borderRadius:6, preview:"bottom" },
|
||
};
|
||
|
||
async function applyAppearancePreset(name) {
|
||
const preset = appearancePresets[name];
|
||
if (!preset) return;
|
||
state.prefs = { ...state.prefs, ...preset };
|
||
applyPreferences();
|
||
const payload = {};
|
||
for (const [key, value] of Object.entries(preset)) {
|
||
const field = preferenceMap[key];
|
||
if (field) payload[field] = value;
|
||
}
|
||
try {
|
||
normalizeServerPreferences(await request("/settings/display", { method:"PUT", body:JSON.stringify(payload) }));
|
||
applyPreferences();
|
||
const label = $(`[data-appearance-preset="${name}"]`)?.dataset.presetLabel || name;
|
||
toast(`Applied interface preset: ${label}`, "success");
|
||
} 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() {
|
||
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();
|
||
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 loadNotificationPreferences();
|
||
connectRealtime();
|
||
} catch (error) {
|
||
errorBox.textContent = error.message;
|
||
errorBox.classList.remove("hidden");
|
||
} finally {
|
||
setBusy(submit, false);
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
disconnectRealtime();
|
||
try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) {}
|
||
state.accountId = null;
|
||
state.accounts = [];
|
||
localStorage.removeItem("rustbox.activeAccount");
|
||
showLogin();
|
||
}
|
||
|
||
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 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();
|
||
$("#folder-pane").classList.remove("open");
|
||
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"); }
|
||
}
|
||
|
||
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);
|
||
}
|
||
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 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");
|
||
const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", ");
|
||
$("#reader-to").textContent = `to ${to || "me"}`;
|
||
$("#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", () => 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 === "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();
|
||
}
|
||
|
||
async function openAttachment(folder, uid, attachment) {
|
||
try {
|
||
const blob = await fetchAttachmentBlob(folder, uid, attachment);
|
||
const mime = attachmentMime(attachment, blob);
|
||
const safe = mime.startsWith("image/") || mime === "application/pdf" || mime.startsWith("text/");
|
||
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 (mime.startsWith("image/")) {
|
||
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") {
|
||
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 (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");
|
||
}
|
||
|
||
function parseRecipients(raw) {
|
||
return raw.split(",").map((value) => value.trim()).filter(Boolean);
|
||
}
|
||
|
||
function recipientToken(input) {
|
||
const value = input.value || "";
|
||
const comma = value.lastIndexOf(",");
|
||
return { prefix: comma >= 0 ? value.slice(0, comma + 1) : "", query: value.slice(comma + 1).trim() };
|
||
}
|
||
|
||
function bindRecipientAutocomplete(selector) {
|
||
const input = $(selector);
|
||
if (!input) return;
|
||
const host = input.closest("label") || input.parentElement;
|
||
host.classList.add("recipient-field");
|
||
const menu = el("div", "recipient-suggestions hidden");
|
||
menu.setAttribute("role", "listbox");
|
||
host.append(menu);
|
||
let timer = null;
|
||
let activeIndex = -1;
|
||
let suggestions = [];
|
||
|
||
const hide = () => { menu.classList.add("hidden"); menu.replaceChildren(); activeIndex = -1; suggestions = []; };
|
||
const select = (item) => {
|
||
const { prefix } = recipientToken(input);
|
||
const formatted = item.name?.trim() ? `${item.name.trim()} <${item.email}>` : item.email;
|
||
input.value = `${prefix}${prefix ? " " : ""}${formatted}, `;
|
||
hide();
|
||
input.focus();
|
||
scheduleDraftSave();
|
||
input.dispatchEvent(new Event("input", { bubbles:true }));
|
||
};
|
||
const render = () => {
|
||
menu.replaceChildren();
|
||
if (!suggestions.length) { hide(); return; }
|
||
suggestions.forEach((item, index) => {
|
||
const button = el("button", `recipient-suggestion ${index === activeIndex ? "active" : ""}`);
|
||
button.type = "button";
|
||
button.setAttribute("role", "option");
|
||
button.setAttribute("aria-selected", String(index === activeIndex));
|
||
const primary = el("strong", "", item.name?.trim() || item.email);
|
||
const secondary = el("span", "", item.name?.trim() ? item.email : (item.source || ""));
|
||
button.append(primary, secondary);
|
||
button.addEventListener("mousedown", (event) => event.preventDefault());
|
||
button.addEventListener("click", () => select(item));
|
||
menu.append(button);
|
||
});
|
||
menu.classList.remove("hidden");
|
||
};
|
||
const load = async () => {
|
||
const { query } = recipientToken(input);
|
||
if (query.length < 2 || query.includes(">")) { hide(); return; }
|
||
try {
|
||
const data = await request(`/contacts/autocomplete?q=${encodeURIComponent(query)}&limit=8`, { method:"GET" });
|
||
suggestions = data.suggestions || [];
|
||
activeIndex = -1;
|
||
render();
|
||
} catch (_) { hide(); }
|
||
};
|
||
|
||
input.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(load, 140); });
|
||
input.addEventListener("blur", () => setTimeout(hide, 120));
|
||
input.addEventListener("keydown", (event) => {
|
||
if (menu.classList.contains("hidden") || !suggestions.length) return;
|
||
if (event.key === "ArrowDown") { event.preventDefault(); activeIndex = (activeIndex + 1) % suggestions.length; render(); }
|
||
else if (event.key === "ArrowUp") { event.preventDefault(); activeIndex = (activeIndex - 1 + suggestions.length) % suggestions.length; render(); }
|
||
else if (event.key === "Enter" && activeIndex >= 0) { event.preventDefault(); select(suggestions[activeIndex]); }
|
||
else if (event.key === "Escape") { event.preventDefault(); hide(); }
|
||
});
|
||
}
|
||
|
||
function quoteTextAsHtml(text) {
|
||
return escapeHtml(cleanPlainMessageText(text || "")).replaceAll("\n", "<br>");
|
||
}
|
||
|
||
function ownAddressSet() {
|
||
return new Set(state.accounts.map((account) => String(account.email || "").trim().toLowerCase()).filter(Boolean));
|
||
}
|
||
|
||
function ensureDraftId() {
|
||
if (!state.composeDraftId) state.composeDraftId = crypto.randomUUID ? crypto.randomUUID() : `draft-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
return state.composeDraftId;
|
||
}
|
||
|
||
function renderComposeAttachments() {
|
||
const box = $("#compose-attachments");
|
||
box.replaceChildren();
|
||
const visibleAttachments = state.composeAttachments.filter((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)));
|
||
box.classList.toggle("hidden", !visibleAttachments.length);
|
||
for (const attachment of visibleAttachments) {
|
||
const item = el("div", "compose-attachment");
|
||
item.innerHTML = `${svgIcon("paperclip")}<span>${escapeHtml(attachment.filename)} · ${Math.max(1, Math.ceil((attachment.size || 0)/1024))} KB</span>`;
|
||
const remove = el("button", "");
|
||
remove.innerHTML = svgIcon("x");
|
||
remove.type = "button";
|
||
remove.setAttribute("aria-label", `Remove ${attachment.filename}`);
|
||
remove.addEventListener("click", () => deleteComposeAttachment(attachment.id));
|
||
item.append(remove);
|
||
box.append(item);
|
||
}
|
||
if ($("#compose-pgp-mode")) updateComposePgpUi();
|
||
}
|
||
|
||
function composeRecoveryKey() {
|
||
return `rustbox.composeRecovery.${state.accountId || "default"}`;
|
||
}
|
||
|
||
function draftHasContent() {
|
||
return Boolean(
|
||
$("#compose-to")?.value.trim() || $("#compose-cc")?.value.trim() || $("#compose-bcc")?.value.trim() ||
|
||
$("#compose-subject")?.value.trim() || $("#compose-editor")?.innerText.trim() || state.composeAttachments.length
|
||
);
|
||
}
|
||
|
||
function composeAttachmentContentUrl(draftId, attachmentId) {
|
||
const query = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : "";
|
||
return `${apiPath(`/drafts/${encodeURIComponent(draftId)}/attachments/${encodeURIComponent(attachmentId)}/content`)}${query}`;
|
||
}
|
||
|
||
function markdownInlineToSafeHtml(value) {
|
||
const source = String(value ?? "");
|
||
const code = [];
|
||
let html = escapeHtml(source).replace(/`([^`\n]+)`/g, (_, body) => {
|
||
const token = `@@RUSTBOX_CODE_${code.length}@@`;
|
||
code.push(`<code>${body}</code>`);
|
||
return token;
|
||
});
|
||
html = html.replace(/\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g, (_, label, rawUrl) => {
|
||
let url = rawUrl.replaceAll("&", "&");
|
||
try {
|
||
const parsed = new URL(url, window.location.origin);
|
||
if (!["http:", "https:", "mailto:"].includes(parsed.protocol)) return label;
|
||
url = parsed.protocol === "mailto:" ? url : parsed.href;
|
||
} catch (_) { return label; }
|
||
return `<a href="${escapeHtml(url)}" rel="noopener noreferrer">${label}</a>`;
|
||
});
|
||
html = html
|
||
.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>")
|
||
.replace(/__([^_\n]+)__/g, "<strong>$1</strong>")
|
||
.replace(/~~([^~\n]+)~~/g, "<del>$1</del>")
|
||
.replace(/(^|[\s(])\*([^*\n]+)\*(?=$|[\s).,!?:;])/g, "$1<em>$2</em>")
|
||
.replace(/(^|[\s(])_([^_\n]+)_(?=$|[\s).,!?:;])/g, "$1<em>$2</em>");
|
||
code.forEach((snippet, index) => { html = html.replace(`@@RUSTBOX_CODE_${index}@@`, snippet); });
|
||
return html;
|
||
}
|
||
|
||
function markdownToSafeHtml(markdown) {
|
||
const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n");
|
||
const out = [];
|
||
let paragraph = [];
|
||
let listType = null;
|
||
let inCode = false;
|
||
let codeLines = [];
|
||
const flushParagraph = () => {
|
||
if (!paragraph.length) return;
|
||
out.push(`<p>${paragraph.map(markdownInlineToSafeHtml).join("<br>")}</p>`);
|
||
paragraph = [];
|
||
};
|
||
const closeList = () => {
|
||
if (!listType) return;
|
||
out.push(`</${listType}>`);
|
||
listType = null;
|
||
};
|
||
for (const line of lines) {
|
||
if (/^\s*```/.test(line)) {
|
||
flushParagraph(); closeList();
|
||
if (inCode) { out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`); codeLines = []; inCode = false; }
|
||
else { inCode = true; }
|
||
continue;
|
||
}
|
||
if (inCode) { codeLines.push(line); continue; }
|
||
if (!line.trim()) { flushParagraph(); closeList(); continue; }
|
||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||
if (heading) { flushParagraph(); closeList(); const level = heading[1].length; out.push(`<h${level}>${markdownInlineToSafeHtml(heading[2])}</h${level}>`); continue; }
|
||
if (/^\s*(?:---+|\*\*\*+)\s*$/.test(line)) { flushParagraph(); closeList(); out.push("<hr>"); continue; }
|
||
const quote = line.match(/^>\s?(.*)$/);
|
||
if (quote) { flushParagraph(); closeList(); out.push(`<blockquote>${markdownInlineToSafeHtml(quote[1])}</blockquote>`); continue; }
|
||
const unordered = line.match(/^\s*[-+*]\s+(.+)$/);
|
||
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
||
if (unordered || ordered) {
|
||
flushParagraph();
|
||
const wanted = unordered ? "ul" : "ol";
|
||
if (listType !== wanted) { closeList(); listType = wanted; out.push(`<${wanted}>`); }
|
||
out.push(`<li>${markdownInlineToSafeHtml((unordered || ordered)[1])}</li>`);
|
||
continue;
|
||
}
|
||
paragraph.push(line);
|
||
}
|
||
if (inCode) out.push(`<pre><code>${escapeHtml(codeLines.join("\n"))}</code></pre>`);
|
||
flushParagraph(); closeList();
|
||
return out.join("\n");
|
||
}
|
||
|
||
function composeFontCss(value = state.prefs.composeFontFamily) {
|
||
return ({
|
||
system: '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif',
|
||
humanist: '"Trebuchet MS","Segoe UI",sans-serif',
|
||
serif: 'Georgia,"Times New Roman",serif',
|
||
mono: 'ui-monospace,SFMono-Regular,Menlo,Consolas,monospace',
|
||
})[value] || '-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif';
|
||
}
|
||
|
||
function wrapComposeHtmlFont(html) {
|
||
if (!html) return null;
|
||
const family = composeFontCss().replace(/&/g, "&").replace(/"/g, """);
|
||
return `<div class="rustbox-compose-font" style="font-family:${family}">${html}</div>`;
|
||
}
|
||
|
||
function serializeComposeHtml(editor = $("#compose-editor")) {
|
||
if (!editor) return null;
|
||
const mode = $("#compose-mode").value;
|
||
if (mode === "markdown") {
|
||
const markdown = editor.innerText.trim();
|
||
return markdown ? wrapComposeHtmlFont(markdownToSafeHtml(markdown)) : null;
|
||
}
|
||
if (mode !== "html") return null;
|
||
const clone = editor.cloneNode(true);
|
||
clone.querySelectorAll(".rustbox-compose-font").forEach((wrapper) => wrapper.replaceWith(...wrapper.childNodes));
|
||
clone.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
|
||
const cid = image.dataset.rustboxCid || "";
|
||
if (cid) image.setAttribute("src", `cid:${cid}`);
|
||
image.removeAttribute("data-rustbox-cid");
|
||
image.removeAttribute("data-rustbox-align");
|
||
image.classList.remove("compose-inline-image-selected");
|
||
if (!image.className) image.removeAttribute("class");
|
||
});
|
||
const html = clone.innerHTML.trim();
|
||
return html ? wrapComposeHtmlFont(html) : null;
|
||
}
|
||
|
||
function hydrateComposeHtml(html, attachments = state.composeAttachments, draftId = state.composeDraftId) {
|
||
const host = document.createElement("div");
|
||
host.innerHTML = sanitizeMessageHtml(html || "");
|
||
if (host.children.length === 1 && host.firstElementChild?.classList.contains("rustbox-compose-font")) {
|
||
host.innerHTML = host.firstElementChild.innerHTML;
|
||
}
|
||
if (!draftId) return host.innerHTML;
|
||
const ids = new Set((attachments || []).map((attachment) => String(attachment.id || "")).filter(Boolean));
|
||
host.querySelectorAll("img[src]").forEach((image) => {
|
||
const src = image.getAttribute("src") || "";
|
||
if (!src.toLowerCase().startsWith("cid:")) return;
|
||
const cid = src.slice(4).replace(/^<|>$/g, "");
|
||
if (!ids.has(cid)) return;
|
||
state.inlineAttachmentIds.add(cid);
|
||
image.dataset.rustboxCid = cid;
|
||
image.setAttribute("src", composeAttachmentContentUrl(draftId, cid));
|
||
});
|
||
return host.innerHTML;
|
||
}
|
||
|
||
function composeDraftPayload() {
|
||
const editor = $("#compose-editor");
|
||
return {
|
||
id: ensureDraftId(),
|
||
to: $("#compose-to").value,
|
||
cc: $("#compose-cc").value,
|
||
bcc: $("#compose-bcc").value,
|
||
subject: $("#compose-subject").value,
|
||
text_body: editor.innerText,
|
||
html_body: serializeComposeHtml(editor),
|
||
in_reply_to: state.composeReplyMeta?.inReplyTo || null,
|
||
references: state.composeReplyMeta?.references || null,
|
||
request_mdn: Boolean($("#compose-mdn")?.checked),
|
||
priority: $("#compose-priority")?.value || "normal",
|
||
compose_format: $("#compose-mode")?.value || "html",
|
||
pgp_mode: $("#compose-pgp-mode")?.value || "none",
|
||
pgp_inline: Boolean($("#compose-pgp-inline")?.checked),
|
||
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key")?.checked),
|
||
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys")?.checked),
|
||
};
|
||
}
|
||
|
||
function readLocalDraftSnapshots() {
|
||
try {
|
||
const raw = localStorage.getItem(composeRecoveryKey());
|
||
if (!raw) return [];
|
||
const parsed = JSON.parse(raw);
|
||
const drafts = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.drafts) ? parsed.drafts : parsed?.id ? [parsed] : [];
|
||
return drafts.filter((draft) => draft && draft.id).sort((a, b) => String(b.saved_at || "").localeCompare(String(a.saved_at || "")));
|
||
} catch (_) { return []; }
|
||
}
|
||
|
||
function writeLocalDraftSnapshots(drafts) {
|
||
const kept = drafts.filter((draft) => draft?.id).slice(0, 10);
|
||
if (!kept.length) localStorage.removeItem(composeRecoveryKey());
|
||
else localStorage.setItem(composeRecoveryKey(), JSON.stringify({ drafts: kept }));
|
||
}
|
||
|
||
function storeLocalDraftSnapshot() {
|
||
if (!draftHasContent()) return;
|
||
try {
|
||
const payload = composeDraftPayload();
|
||
const snapshot = {
|
||
...payload,
|
||
compose_format: $("#compose-mode").value,
|
||
attachments: state.composeAttachments,
|
||
saved_at: new Date().toISOString(),
|
||
};
|
||
const drafts = readLocalDraftSnapshots().filter((draft) => draft.id !== payload.id);
|
||
drafts.unshift(snapshot);
|
||
writeLocalDraftSnapshots(drafts);
|
||
} catch (_) {}
|
||
}
|
||
|
||
function clearLocalDraftSnapshot(draftId = state.composeDraftId) {
|
||
if (!draftId) return;
|
||
writeLocalDraftSnapshots(readLocalDraftSnapshots().filter((draft) => draft.id !== draftId));
|
||
}
|
||
|
||
async function saveCurrentDraft(silent = false, force = false, syncImap = false) {
|
||
if (!force && !$("#compose-dialog").open) return false;
|
||
if (!force && !state.composeDirty) return true;
|
||
if (!draftHasContent()) { state.composeDirty = false; clearLocalDraftSnapshot(); return true; }
|
||
const payload = composeDraftPayload();
|
||
payload.sync_imap = Boolean(syncImap);
|
||
try {
|
||
await request("/drafts", { method:"POST", body:JSON.stringify(payload) });
|
||
state.composeDirty = false;
|
||
if (syncImap) state.composeNeedsRemoteSync = false;
|
||
clearLocalDraftSnapshot();
|
||
$("#compose-status").textContent = `Draft saved ${new Date().toLocaleTimeString([], {hour:"2-digit",minute:"2-digit"})}`;
|
||
if (!silent) toast("Draft saved", "success");
|
||
return true;
|
||
} catch (error) {
|
||
storeLocalDraftSnapshot();
|
||
if (!silent) toast(error.message, "error");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function scheduleDraftSave() {
|
||
state.composeDirty = true;
|
||
state.composeNeedsRemoteSync = true;
|
||
storeLocalDraftSnapshot();
|
||
clearTimeout(state.draftSaveTimer);
|
||
state.draftSaveTimer = setTimeout(() => saveCurrentDraft(true), Math.max(1000, Number(state.prefs.composeAutosaveMs || 3000)));
|
||
}
|
||
|
||
function setComposeUploadProgress(visible, percent = 0, label = "Uploading…") {
|
||
const box = $("#compose-upload-progress");
|
||
const bar = $("#compose-upload-bar");
|
||
const text = $("#compose-upload-label");
|
||
if (!box || !bar || !text) return;
|
||
const value = Math.max(0, Math.min(100, Number(percent) || 0));
|
||
box.classList.toggle("hidden", !visible);
|
||
bar.style.width = `${value}%`;
|
||
text.textContent = label;
|
||
}
|
||
|
||
async function uploadComposeFiles(files, options = {}) {
|
||
const list = [...(files || [])];
|
||
const announce = options.announce !== false;
|
||
if (!list.length) return [];
|
||
if (state.composeUploading) { if (announce) toast("Another upload is already in progress", "warning"); return []; }
|
||
state.composeUploading = true;
|
||
setComposeUploadProgress(true, 0, `Uploading ${list.length} file${list.length === 1 ? "" : "s"}…`);
|
||
await saveCurrentDraft(true, true);
|
||
const id = ensureDraftId();
|
||
const form = new FormData();
|
||
list.forEach((file) => form.append("file", file, file.name));
|
||
try {
|
||
const data = await uploadRequest(`/drafts/${encodeURIComponent(id)}/attachments`, form, (percent) => {
|
||
setComposeUploadProgress(true, percent, `Uploading ${list.length} file${list.length === 1 ? "" : "s"}… ${percent}%`);
|
||
});
|
||
const added = data.attachments || [];
|
||
state.composeAttachments.push(...added);
|
||
renderComposeAttachments();
|
||
state.composeDirty = true;
|
||
state.composeNeedsRemoteSync = true;
|
||
storeLocalDraftSnapshot();
|
||
setComposeUploadProgress(true, 100, "Upload complete");
|
||
if (announce) toast(`${added.length} attachment${added.length === 1 ? "" : "s"} added`, "success");
|
||
setTimeout(() => setComposeUploadProgress(false), 450);
|
||
return added;
|
||
} catch (error) {
|
||
setComposeUploadProgress(false);
|
||
toast(error.message, "error");
|
||
return [];
|
||
} finally {
|
||
state.composeUploading = false;
|
||
}
|
||
}
|
||
|
||
function previewMarkdown() {
|
||
const editor = $("#compose-editor");
|
||
const preview = $("#markdown-preview-content");
|
||
if (!editor || !preview) return;
|
||
preview.innerHTML = markdownToSafeHtml(editor.innerText);
|
||
$("#markdown-preview-dialog").showModal();
|
||
}
|
||
|
||
function setComposeMode(mode, persist = false) {
|
||
const value = ["html", "text", "markdown"].includes(mode) ? mode : "html";
|
||
const select = $("#compose-mode");
|
||
if (select) select.value = value;
|
||
const editor = $("#compose-editor");
|
||
editor.dataset.mode = value;
|
||
editor.classList.toggle("plain-mode", value !== "html");
|
||
$$(".editor-toolbar [data-command], #editor-link, #editor-image, #editor-font, #editor-size, #editor-color").forEach((control) => { control.disabled = value !== "html"; });
|
||
const imageEdit = $("#editor-image-edit");
|
||
if (imageEdit) imageEdit.disabled = value !== "html" || !state.composeSelectedImage;
|
||
$("#compose-markdown-preview")?.classList.toggle("hidden", value !== "markdown");
|
||
if (persist) savePreference("composeFormat", value);
|
||
}
|
||
|
||
function captureEditorRange(editor = $("#compose-editor")) {
|
||
const selection = window.getSelection();
|
||
if (!editor || !selection || !selection.rangeCount) return null;
|
||
const range = selection.getRangeAt(0);
|
||
const container = range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement;
|
||
if (container !== editor && !editor.contains(container)) return null;
|
||
return range.cloneRange();
|
||
}
|
||
|
||
function restoreEditorRange(range, editor = $("#compose-editor")) {
|
||
editor.focus();
|
||
if (!range) return;
|
||
try {
|
||
const selection = window.getSelection();
|
||
selection.removeAllRanges();
|
||
selection.addRange(range);
|
||
} catch (_) {}
|
||
}
|
||
|
||
function setSelectedInlineImage(image) {
|
||
if (state.composeSelectedImage && state.composeSelectedImage !== image) {
|
||
state.composeSelectedImage.classList?.remove("compose-inline-image-selected");
|
||
}
|
||
state.composeSelectedImage = image && image.isConnected ? image : null;
|
||
state.composeSelectedImage?.classList.add("compose-inline-image-selected");
|
||
const edit = $("#editor-image-edit");
|
||
if (edit) edit.disabled = $("#compose-mode")?.value !== "html" || !state.composeSelectedImage;
|
||
}
|
||
|
||
function inlineImageAlignment(image) {
|
||
if (!image) return "inline";
|
||
if (image.dataset.rustboxAlign) return image.dataset.rustboxAlign;
|
||
const left = image.style.marginLeft;
|
||
const right = image.style.marginRight;
|
||
if (left === "auto" && right === "auto") return "center";
|
||
if (left === "auto" && right === "0px") return "right";
|
||
if (left === "0px" && right === "auto") return "left";
|
||
return "inline";
|
||
}
|
||
|
||
function applyInlineImageDisplay(image, widthPercent, alignment) {
|
||
if (!image) return;
|
||
const width = Math.max(10, Math.min(100, Number(widthPercent) || 100));
|
||
image.style.width = `${width}%`;
|
||
image.style.maxWidth = "100%";
|
||
image.style.height = "auto";
|
||
image.style.float = "none";
|
||
image.style.marginTop = "0";
|
||
image.style.marginBottom = "0";
|
||
image.dataset.rustboxAlign = alignment;
|
||
if (alignment === "center") {
|
||
image.style.display = "block";
|
||
image.style.marginLeft = "auto";
|
||
image.style.marginRight = "auto";
|
||
} else if (alignment === "right") {
|
||
image.style.display = "block";
|
||
image.style.marginLeft = "auto";
|
||
image.style.marginRight = "0px";
|
||
} else if (alignment === "left") {
|
||
image.style.display = "block";
|
||
image.style.marginLeft = "0px";
|
||
image.style.marginRight = "auto";
|
||
} else {
|
||
image.style.display = "inline-block";
|
||
image.style.marginLeft = "0";
|
||
image.style.marginRight = "0";
|
||
}
|
||
}
|
||
|
||
function imageCropValues() {
|
||
const value = (id) => Math.max(0, Math.min(45, Number($(id)?.value) || 0));
|
||
return { top:value("#image-crop-top"), right:value("#image-crop-right"), bottom:value("#image-crop-bottom"), left:value("#image-crop-left") };
|
||
}
|
||
|
||
function updateImageEditOutputs() {
|
||
const width = Number($("#image-edit-width")?.value || 100);
|
||
const widthOut = $("#image-edit-width-value");
|
||
if (widthOut) widthOut.textContent = `${width}%`;
|
||
for (const side of ["top", "right", "bottom", "left"]) {
|
||
const input = $(`#image-crop-${side}`);
|
||
const output = $(`#image-crop-${side}-value`);
|
||
if (input && output) output.textContent = `${input.value}%`;
|
||
}
|
||
}
|
||
|
||
function drawInlineImageCropPreview() {
|
||
const bitmap = state.imageEditorBitmap;
|
||
const canvas = $("#image-edit-preview");
|
||
if (!bitmap || !canvas) return;
|
||
const crop = imageCropValues();
|
||
const sx = Math.round(bitmap.width * crop.left / 100);
|
||
const sy = Math.round(bitmap.height * crop.top / 100);
|
||
const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100));
|
||
const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100));
|
||
const scale = Math.min(1, 760 / sw, 560 / sh);
|
||
canvas.width = Math.max(1, Math.round(sw * scale));
|
||
canvas.height = Math.max(1, Math.round(sh * scale));
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, canvas.width, canvas.height);
|
||
updateImageEditOutputs();
|
||
}
|
||
|
||
function resetImageCropControls() {
|
||
for (const side of ["top", "right", "bottom", "left"]) {
|
||
const input = $(`#image-crop-${side}`);
|
||
if (input) input.value = "0";
|
||
}
|
||
drawInlineImageCropPreview();
|
||
}
|
||
|
||
async function decodeInlineImageBlob(blob) {
|
||
if (typeof createImageBitmap === "function") return createImageBitmap(blob);
|
||
const url = URL.createObjectURL(blob);
|
||
try {
|
||
const image = new Image();
|
||
image.decoding = "async";
|
||
await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error("Could not decode image")); image.src = url; });
|
||
return image;
|
||
} finally {
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
}
|
||
|
||
async function openInlineImageEditor(image = state.composeSelectedImage) {
|
||
if (!image?.dataset?.rustboxCid) return toast("Select an inline image first", "info");
|
||
setSelectedInlineImage(image);
|
||
const cid = image.dataset.rustboxCid;
|
||
const attachment = state.composeAttachments.find((item) => String(item.id) === String(cid));
|
||
if (!attachment) return toast("The inline image attachment is no longer available", "error");
|
||
try {
|
||
state.imageEditorBitmap?.close?.();
|
||
const response = await fetch(composeAttachmentContentUrl(ensureDraftId(), cid), { credentials:"same-origin", cache:"no-store" });
|
||
if (!response.ok) throw new Error(`Could not load image (${response.status})`);
|
||
state.imageEditorBlob = await response.blob();
|
||
state.imageEditorBitmap = await decodeInlineImageBlob(state.imageEditorBlob);
|
||
const editorWidth = Math.max(1, $("#compose-editor")?.clientWidth || image.parentElement?.clientWidth || image.clientWidth || 1);
|
||
const stylePercent = /^\d+(?:\.\d+)?%$/.test(image.style.width || "") ? Number.parseFloat(image.style.width) : null;
|
||
const measuredPercent = Math.round((image.getBoundingClientRect().width / editorWidth) * 100 / 5) * 5;
|
||
$("#image-edit-width").value = String(Math.max(10, Math.min(100, stylePercent || measuredPercent || 100)));
|
||
$("#image-edit-align").value = inlineImageAlignment(image);
|
||
resetImageCropControls();
|
||
const cropControls = $("#image-crop-controls");
|
||
const isGif = String(attachment.content_type || state.imageEditorBlob.type || "").toLowerCase() === "image/gif";
|
||
cropControls?.classList.toggle("image-crop-disabled", isGif);
|
||
cropControls?.querySelectorAll("input").forEach((input) => { input.disabled = isGif; });
|
||
const help = $("#image-edit-help");
|
||
if (help) help.textContent = isGif
|
||
? "Size and position are available. Cropping animated GIF files is disabled so the animation is not flattened."
|
||
: "Resizing changes only the displayed size. Cropping creates a new CID image and replaces the previous inline attachment.";
|
||
drawInlineImageCropPreview();
|
||
$("#image-edit-dialog").showModal();
|
||
} catch (error) {
|
||
toast(error.message || "Could not open the image editor", "error");
|
||
}
|
||
}
|
||
|
||
function croppedImageFilename(filename, mime) {
|
||
const base = String(filename || "image").replace(/\.[^.]+$/, "") || "image";
|
||
const ext = mime === "image/jpeg" ? "jpg" : mime === "image/webp" ? "webp" : "png";
|
||
return `${base}-crop.${ext}`;
|
||
}
|
||
|
||
async function cropInlineImageToFile(bitmap, sourceBlob, crop, filename) {
|
||
const sx = Math.round(bitmap.width * crop.left / 100);
|
||
const sy = Math.round(bitmap.height * crop.top / 100);
|
||
const sw = Math.max(1, Math.round(bitmap.width * (100 - crop.left - crop.right) / 100));
|
||
const sh = Math.max(1, Math.round(bitmap.height * (100 - crop.top - crop.bottom) / 100));
|
||
if (sw * sh > 40_000_000) throw new Error("The crop is too large to process safely in the browser");
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = sw;
|
||
canvas.height = sh;
|
||
const ctx = canvas.getContext("2d");
|
||
ctx.drawImage(bitmap, sx, sy, sw, sh, 0, 0, sw, sh);
|
||
const sourceType = String(sourceBlob?.type || "").toLowerCase();
|
||
const outputType = ["image/jpeg", "image/webp", "image/png"].includes(sourceType) ? sourceType : "image/png";
|
||
const blob = await new Promise((resolve, reject) => canvas.toBlob((value) => value ? resolve(value) : reject(new Error("Could not create cropped image")), outputType, outputType === "image/png" ? undefined : 0.92));
|
||
return new File([blob], croppedImageFilename(filename, outputType), { type:outputType, lastModified:Date.now() });
|
||
}
|
||
|
||
async function applyInlineImageEditor(event) {
|
||
event.preventDefault();
|
||
const image = state.composeSelectedImage;
|
||
const bitmap = state.imageEditorBitmap;
|
||
if (!image?.dataset?.rustboxCid || !bitmap) return;
|
||
const width = Number($("#image-edit-width").value || 100);
|
||
const alignment = $("#image-edit-align").value || "inline";
|
||
const crop = imageCropValues();
|
||
const hasCrop = Object.values(crop).some((value) => value > 0);
|
||
const oldCid = image.dataset.rustboxCid;
|
||
const oldAttachment = state.composeAttachments.find((item) => String(item.id) === String(oldCid));
|
||
const applyButton = $("#image-edit-apply");
|
||
setBusy(applyButton, true);
|
||
try {
|
||
if (hasCrop) {
|
||
const type = String(oldAttachment?.content_type || state.imageEditorBlob?.type || "").toLowerCase();
|
||
if (type === "image/gif") throw new Error("Cropping animated GIF files is disabled to preserve animation");
|
||
const file = await cropInlineImageToFile(bitmap, state.imageEditorBlob, crop, oldAttachment?.filename || image.alt || "image");
|
||
const uploaded = await uploadComposeFiles([file], { announce:false });
|
||
const replacement = uploaded[0];
|
||
if (!replacement) throw new Error("Could not upload the cropped image");
|
||
state.inlineAttachmentIds.add(String(replacement.id));
|
||
image.dataset.rustboxCid = replacement.id;
|
||
image.src = composeAttachmentContentUrl(ensureDraftId(), replacement.id);
|
||
image.alt = replacement.filename || image.alt || "image";
|
||
state.inlineAttachmentIds.delete(String(oldCid));
|
||
await deleteComposeAttachment(oldCid);
|
||
}
|
||
applyInlineImageDisplay(image, width, alignment);
|
||
setSelectedInlineImage(image);
|
||
scheduleDraftSave();
|
||
$("#image-edit-dialog").close();
|
||
toast(hasCrop ? "Image cropped" : "Image layout updated", "success");
|
||
} catch (error) {
|
||
toast(error.message || "Could not update image", "error");
|
||
} finally {
|
||
setBusy(applyButton, false);
|
||
}
|
||
}
|
||
|
||
function scheduleUnusedInlineAttachmentCleanup() {
|
||
clearTimeout(state.inlineImageCleanupTimer);
|
||
state.inlineImageCleanupTimer = setTimeout(cleanupUnusedInlineAttachments, 3500);
|
||
}
|
||
|
||
async function cleanupUnusedInlineAttachments() {
|
||
if (!state.composeDraftId || !state.inlineAttachmentIds.size) return;
|
||
const used = new Set($$("#compose-editor img[data-rustbox-cid]").map((image) => String(image.dataset.rustboxCid || "")).filter(Boolean));
|
||
const unused = [...state.inlineAttachmentIds].filter((id) => !used.has(String(id)));
|
||
if (!unused.length) return;
|
||
let changed = false;
|
||
for (const id of unused) {
|
||
try {
|
||
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method:"DELETE" });
|
||
state.composeAttachments = state.composeAttachments.filter((attachment) => String(attachment.id) !== String(id));
|
||
state.inlineAttachmentIds.delete(String(id));
|
||
changed = true;
|
||
} catch (error) {
|
||
console.warn("Could not remove unused inline attachment", id, error);
|
||
}
|
||
}
|
||
if (changed) renderComposeAttachments();
|
||
}
|
||
|
||
function editorRangeFromPoint(x, y) {
|
||
const editor = $("#compose-editor");
|
||
if (!editor) return null;
|
||
if (document.caretRangeFromPoint) {
|
||
const range = document.caretRangeFromPoint(x, y);
|
||
if (range && editor.contains(range.commonAncestorContainer.nodeType === Node.ELEMENT_NODE ? range.commonAncestorContainer : range.commonAncestorContainer.parentElement)) return range;
|
||
}
|
||
if (document.caretPositionFromPoint) {
|
||
const position = document.caretPositionFromPoint(x, y);
|
||
if (position?.offsetNode) {
|
||
const range = document.createRange();
|
||
range.setStart(position.offsetNode, position.offset);
|
||
range.collapse(true);
|
||
if (editor.contains(position.offsetNode.nodeType === Node.ELEMENT_NODE ? position.offsetNode : position.offsetNode.parentElement)) return range;
|
||
}
|
||
}
|
||
return captureEditorRange(editor);
|
||
}
|
||
|
||
async function uploadInlineImage(files, insertionRange = null) {
|
||
const images = [...(files || [])].filter((item) => /^image\/(png|jpeg|gif|webp)$/i.test(item.type));
|
||
if (!images.length) return toast("Choose a PNG, JPEG, GIF or WebP image", "error");
|
||
const uploaded = await uploadComposeFiles(images);
|
||
if (!uploaded.length) return;
|
||
setComposeMode("html");
|
||
const editor = $("#compose-editor");
|
||
restoreEditorRange(insertionRange, editor);
|
||
const draftId = ensureDraftId();
|
||
let lastInsertedId = null;
|
||
for (const attachment of uploaded) {
|
||
const previewUrl = composeAttachmentContentUrl(draftId, attachment.id);
|
||
state.inlineAttachmentIds.add(String(attachment.id));
|
||
lastInsertedId = String(attachment.id);
|
||
const html = `<img src="${escapeHtml(previewUrl)}" data-rustbox-cid="${escapeHtml(attachment.id)}" data-rustbox-align="inline" alt="${escapeHtml(attachment.filename || "image")}" style="max-width:100%;height:auto">`;
|
||
document.execCommand("insertHTML", false, html);
|
||
}
|
||
if (lastInsertedId) {
|
||
const inserted = [...editor.querySelectorAll("img[data-rustbox-cid]")].reverse().find((image) => String(image.dataset.rustboxCid) === lastInsertedId);
|
||
if (inserted) setSelectedInlineImage(inserted);
|
||
}
|
||
scheduleDraftSave();
|
||
}
|
||
|
||
function applyRecoveredDraft(draft, sourceLabel = "Saved draft") {
|
||
state.composeSignatureGeneration += 1;
|
||
state.composeDraftId = draft.id || null;
|
||
state.composeAttachments = Array.isArray(draft.attachments) ? draft.attachments : [];
|
||
state.inlineAttachmentIds = new Set();
|
||
setSelectedInlineImage(null);
|
||
state.composeReplyMeta = {
|
||
inReplyTo: draft.in_reply_to || null,
|
||
references: draft.references || null,
|
||
};
|
||
$("#compose-to").value = draft.to || "";
|
||
$("#compose-cc").value = draft.cc || "";
|
||
$("#compose-bcc").value = draft.bcc || "";
|
||
$("#compose-subject").value = draft.subject || "";
|
||
$("#compose-priority").value = ["high", "normal", "low"].includes(draft.priority) ? draft.priority : "normal";
|
||
$("#compose-mdn").checked = Boolean(draft.request_mdn);
|
||
$("#compose-pgp-mode").value = ["none","sign","encrypt","sign-encrypt"].includes(draft.pgp_mode) ? draft.pgp_mode : "none";
|
||
$("#compose-pgp-inline").checked = Boolean(draft.pgp_inline);
|
||
$("#compose-pgp-attach-key").checked = Boolean(draft.pgp_attach_public_key);
|
||
$("#compose-pgp-attach-recipient-keys").checked = Boolean(draft.pgp_attach_recipient_keys);
|
||
$("#compose-pgp-passphrase").value = "";
|
||
updateComposePgpUi();
|
||
const mode = draft.compose_format || (draft.html_body ? "html" : "text");
|
||
setComposeMode(mode);
|
||
const editor = $("#compose-editor");
|
||
if (mode === "html" && draft.html_body) editor.innerHTML = hydrateComposeHtml(draft.html_body, state.composeAttachments, state.composeDraftId);
|
||
else editor.textContent = draft.text_body || "";
|
||
renderComposeAttachments();
|
||
state.composeDirty = sourceLabel === "Browser recovery";
|
||
state.composeNeedsRemoteSync = sourceLabel === "Browser recovery";
|
||
if (state.composeDirty) storeLocalDraftSnapshot(); else clearLocalDraftSnapshot();
|
||
$("#compose-title").textContent = "Recovered draft";
|
||
$("#compose-status").textContent = `${sourceLabel}${draft.updated_at ? ` · ${draft.updated_at}` : ""}`;
|
||
setTimeout(() => editor.focus(), 20);
|
||
}
|
||
|
||
async function recoverDraft() {
|
||
if (state.composeDirty && draftHasContent()) {
|
||
const saved = await saveCurrentDraft(true, true);
|
||
if (!saved) return toast("Save the current draft before recovering another one", "error");
|
||
}
|
||
const localDrafts = readLocalDraftSnapshots();
|
||
let serverDrafts = [];
|
||
try {
|
||
const data = await request("/drafts", { method:"GET" });
|
||
serverDrafts = data.drafts || [];
|
||
} catch (error) {
|
||
if (!localDrafts.length) return toast(error.message, "error");
|
||
}
|
||
const options = [];
|
||
for (const local of localDrafts) {
|
||
const localSubject = local.subject || "(no subject)";
|
||
options.push({ value:`local:${local.id}`, label:`Browser recovery — ${localSubject}` });
|
||
}
|
||
for (const draft of serverDrafts.slice(0, 50)) {
|
||
const subject = draft.subject || "(no subject)";
|
||
const recipient = draft.to || "no recipient";
|
||
options.push({ value:`server:${draft.id}`, label:`${subject} — ${recipient}` });
|
||
}
|
||
if (!options.length) return toast("No recoverable drafts found", "info");
|
||
const selected = await appSelect("Recover draft", options, { value:options[0].value, confirmLabel:"Recover" });
|
||
if (!selected) return;
|
||
if (selected.startsWith("local:")) {
|
||
const id = selected.slice("local:".length);
|
||
const local = localDrafts.find((draft) => draft.id === id);
|
||
if (!local) return toast("Browser recovery snapshot is no longer available", "error");
|
||
applyRecoveredDraft(local, "Browser recovery");
|
||
toast("Recovered unsaved browser draft", "success");
|
||
return;
|
||
}
|
||
const id = selected.slice("server:".length);
|
||
try {
|
||
const draft = await request(`/drafts/${encodeURIComponent(id)}`, { method:"GET" });
|
||
applyRecoveredDraft(draft, "Saved draft");
|
||
toast("Draft recovered", "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function closeComposer() {
|
||
clearTimeout(state.draftSaveTimer);
|
||
clearTimeout(state.inlineImageCleanupTimer);
|
||
await cleanupUnusedInlineAttachments();
|
||
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
|
||
if (state.composeNeedsRemoteSync && draftHasContent()) {
|
||
const saved = await saveCurrentDraft(true, true, true);
|
||
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
|
||
} else if (state.composeDirty && draftHasContent()) {
|
||
const saved = await saveCurrentDraft(true, true);
|
||
if (!saved) toast("Draft kept in this browser for recovery", "warning", 5200);
|
||
}
|
||
if ($("#compose-dialog").open) $("#compose-dialog").close();
|
||
state.composeSignatureGeneration += 1;
|
||
}
|
||
|
||
function attachmentReminderNeeded() {
|
||
if (state.prefs.attachmentReminder === false) return false;
|
||
if (state.composeAttachments.some((attachment) => !state.inlineAttachmentIds.has(String(attachment.id)))) return false;
|
||
const text = `${$("#compose-subject").value} ${$("#compose-editor").innerText}`.toLowerCase();
|
||
return /\b(attach(ed|ment|ments)?|załącz(am|nik|niki|niku|one|ony)?|zalacz(am|nik|niki|niku|one|ony)?)\b/i.test(text);
|
||
}
|
||
|
||
async function deleteComposeAttachment(id) {
|
||
if (!state.composeDraftId) return;
|
||
try {
|
||
await request(`/drafts/${encodeURIComponent(state.composeDraftId)}/attachments/${encodeURIComponent(id)}`, { method:"DELETE" });
|
||
state.composeAttachments = state.composeAttachments.filter((a) => a.id !== id);
|
||
state.inlineAttachmentIds.delete(String(id));
|
||
if (state.composeSelectedImage?.dataset?.rustboxCid === String(id)) setSelectedInlineImage(null);
|
||
$("#compose-editor")?.querySelectorAll("img[data-rustbox-cid]").forEach((image) => {
|
||
if (image.dataset.rustboxCid === id) image.remove();
|
||
});
|
||
renderComposeAttachments();
|
||
scheduleDraftSave();
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function selectIdentityForMessage(detail) {
|
||
const select = $("#compose-from");
|
||
if (!select || !detail || !state.identities.length) return;
|
||
const addresses = new Set();
|
||
for (const entry of [...(detail.to_addresses || []), ...(detail.cc_addresses || [])]) {
|
||
if (entry?.address) addresses.add(String(entry.address).trim().toLowerCase());
|
||
}
|
||
const raw = String(detail.raw_headers || "");
|
||
for (const match of raw.matchAll(/^(?:delivered-to|envelope-to):\s*(.+)$/gim)) {
|
||
const value = match[1] || "";
|
||
const email = value.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i)?.[0];
|
||
if (email) addresses.add(email.toLowerCase());
|
||
}
|
||
const identity = state.identities.find((candidate) => addresses.has(String(candidate.email || "").trim().toLowerCase()));
|
||
if (identity) select.value = String(identity.id);
|
||
}
|
||
|
||
function setComposeFullscreen(enabled, persist = true) {
|
||
const dialog = $("#compose-dialog");
|
||
const button = $("#compose-fullscreen");
|
||
if (!dialog || !button) return;
|
||
dialog.classList.toggle("compose-fullscreen", Boolean(enabled));
|
||
button.setAttribute("aria-pressed", enabled ? "true" : "false");
|
||
button.title = enabled ? "Exit full screen" : "Full screen";
|
||
button.textContent = enabled ? "↙" : "⛶";
|
||
if (persist) {
|
||
try { localStorage.setItem("rustbox.composeFullscreen", enabled ? "1" : "0"); } catch (_) {}
|
||
}
|
||
}
|
||
|
||
function toggleComposeFullscreen() {
|
||
setComposeFullscreen(!$("#compose-dialog").classList.contains("compose-fullscreen"));
|
||
}
|
||
|
||
function openCompose(mode = "new") {
|
||
if (mode === "forward" && state.prefs.forwardMode === "attachment") {
|
||
void forwardAsAttachment();
|
||
return;
|
||
}
|
||
const detail = state.currentDetail;
|
||
clearTimeout(state.draftSaveTimer);
|
||
$("#compose-form").reset();
|
||
renderComposeIdentities();
|
||
if (detail && ["reply", "reply-all", "forward"].includes(mode)) selectIdentityForMessage(detail);
|
||
state.composeDraftId = null;
|
||
state.composeAttachments = [];
|
||
state.composeSignatureGeneration += 1;
|
||
state.inlineAttachmentIds = new Set();
|
||
setSelectedInlineImage(null);
|
||
state.composeDirty = false;
|
||
state.composeNeedsRemoteSync = false;
|
||
state.composeReplyMeta = null;
|
||
setComposeUploadProgress(false);
|
||
renderComposeAttachments();
|
||
$("#compose-error").classList.add("hidden");
|
||
$("#compose-status").textContent = "";
|
||
$("#compose-title").textContent = mode === "reply" ? "Reply" : mode === "reply-all" ? "Reply all" : mode === "forward" ? "Forward" : "New message";
|
||
const editor = $("#compose-editor");
|
||
editor.replaceChildren();
|
||
editor.spellcheck = state.prefs.spellcheckEnabled !== false;
|
||
setComposeMode(state.prefs.composeFormat || "html");
|
||
$("#compose-priority").value = ["high", "normal", "low"].includes(state.prefs.defaultPriority) ? state.prefs.defaultPriority : "normal";
|
||
$("#compose-mdn").checked = Boolean(state.prefs.requestMdnDefault);
|
||
state.pgpRecipientStatus = [];
|
||
$("#compose-pgp-passphrase").value = "";
|
||
updateComposePgpUi(true);
|
||
|
||
if (detail && (mode === "reply" || mode === "reply-all")) {
|
||
state.composeReplyMeta = { inReplyTo: detail.message_id || null, references: buildReferences(detail) };
|
||
$("#compose-to").value = detail.from_address || "";
|
||
if (mode === "reply-all") {
|
||
const own = ownAddressSet();
|
||
const cc = [...(detail.to_addresses || []), ...(detail.cc_addresses || [])]
|
||
.map((entry) => entry.address || "")
|
||
.filter((address) => address && address.toLowerCase() !== String(detail.from_address || "").toLowerCase() && !own.has(address.toLowerCase()));
|
||
$("#compose-cc").value = [...new Set(cc)].join(", ");
|
||
}
|
||
$("#compose-subject").value = /^re:/i.test(detail.subject || "") ? detail.subject : `Re: ${detail.subject || ""}`;
|
||
const quoteStyle = state.prefs.quoteStyle || "standard";
|
||
const quote = quoteStyle === "none" ? null : el("div", "compose-quote");
|
||
if (quote) {
|
||
const compact = quoteStyle === "compact";
|
||
if ($("#compose-mode").value === "html") {
|
||
const attribution = compact
|
||
? `<strong>${escapeHtml(detail.from_address || "")}</strong> · ${escapeHtml(formatDate(detail.date, true))}`
|
||
: `On ${escapeHtml(formatDate(detail.date, true))}, ${escapeHtml(detail.from_address || "")} wrote:`;
|
||
quote.innerHTML = `<blockquote>${attribution}<br>${quoteTextAsHtml(detail.text || "")}</blockquote>`;
|
||
} else {
|
||
const quoted = cleanPlainMessageText(detail.text || "").split("\n").map((line) => `> ${line}`).join("\n");
|
||
const attribution = compact
|
||
? `${detail.from_address || ""} · ${formatDate(detail.date, true)}`
|
||
: `On ${formatDate(detail.date, true)}, ${detail.from_address || ""} wrote:`;
|
||
quote.textContent = `\n\n${attribution}\n${quoted}`;
|
||
}
|
||
}
|
||
const entry = el("div", "compose-reply-entry");
|
||
entry.innerHTML = "<br>";
|
||
if (state.prefs.replyPosition === "below" && quote) { editor.append(quote, entry); }
|
||
else { editor.append(entry); if (quote) editor.append(quote); }
|
||
}
|
||
if (detail && mode === "forward") {
|
||
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
|
||
const quote = el("div", "compose-quote");
|
||
if ($("#compose-mode").value === "html") {
|
||
quote.innerHTML = `<blockquote><strong>Forwarded message</strong><br>From: ${escapeHtml(detail.from_address || "")}<br>Date: ${escapeHtml(formatDate(detail.date, true))}<br>Subject: ${escapeHtml(detail.subject || "")}<br><br>${quoteTextAsHtml(detail.text || "")}</blockquote>`;
|
||
} else {
|
||
quote.textContent = `\n\nForwarded message\nFrom: ${detail.from_address || ""}\nDate: ${formatDate(detail.date, true)}\nSubject: ${detail.subject || ""}\n\n${cleanPlainMessageText(detail.text || "")}`;
|
||
}
|
||
editor.append(quote);
|
||
}
|
||
insertIdentitySignature(editor, mode);
|
||
let fullScreen = $("#compose-dialog").classList.contains("compose-fullscreen");
|
||
try { fullScreen = localStorage.getItem("rustbox.composeFullscreen") === "1"; } catch (_) {}
|
||
setComposeFullscreen(fullScreen, false);
|
||
$("#compose-dialog").showModal();
|
||
setTimeout(() => (mode === "new" ? $("#compose-to") : editor).focus(), 30);
|
||
}
|
||
|
||
function buildReferences(detail) {
|
||
if (!detail) return null;
|
||
const values = [detail.references, detail.message_id].filter(Boolean);
|
||
return values.length ? values.join(" ") : null;
|
||
}
|
||
|
||
async function sendMessage(event) {
|
||
event.preventDefault();
|
||
if (state.composeUploading) return toast("Wait for the attachment upload to finish", "warning");
|
||
clearTimeout(state.inlineImageCleanupTimer);
|
||
await cleanupUnusedInlineAttachments();
|
||
const send = $("#send-btn");
|
||
const errorBox = $("#compose-error");
|
||
errorBox.classList.add("hidden");
|
||
if (attachmentReminderNeeded() && !await appConfirm("The message mentions an attachment but no file is attached. Send anyway?", { title:"Missing attachment", confirmLabel:"Send anyway" })) return;
|
||
const pgpMode = $("#compose-pgp-mode").value || "none";
|
||
const pgpInline = Boolean($("#compose-pgp-inline").checked);
|
||
if (pgpInline && state.composeAttachments.length) return toast("Inline OpenPGP cannot carry attachments. Disable inline mode to use PGP/MIME.", "warning");
|
||
if (pgpInline && $("#compose-mode").value !== "text" && !await appConfirm("Inline OpenPGP sends the plain-text representation and drops HTML/Markdown formatting. Continue?", { title:"Inline OpenPGP", confirmLabel:"Continue" })) return;
|
||
if (pgpMode === "encrypt" || pgpMode === "sign-encrypt") {
|
||
const statuses = await checkComposePgpRecipients(false);
|
||
const missing = statuses.filter((item) => !item.hasKey);
|
||
const untrusted = statuses.filter((item) => item.hasKey && item.trust === "untrusted");
|
||
const notTrusted = state.pgpPrefs.requireTrustedEncryption
|
||
? statuses.filter((item) => item.hasKey && !["trusted", "verified"].includes(item.trust))
|
||
: [];
|
||
if (missing.length) return toast(`Missing OpenPGP key for ${missing.map((item) => item.email).join(", ")}`, "warning");
|
||
if (untrusted.length) return toast(`OpenPGP key marked untrusted for ${untrusted.map((item) => item.email).join(", ")}`, "warning");
|
||
if (notTrusted.length) return toast(`OpenPGP policy requires trusted keys: ${notTrusted.map((item) => item.email).join(", ")}`, "warning");
|
||
}
|
||
if ($("#compose-pgp-attach-recipient-keys").checked) {
|
||
const visibleRecipients = composePgpVisibleRecipients();
|
||
if (!visibleRecipients.length) return toast("Add at least one To/Cc recipient before attaching recipient OpenPGP keys", "warning");
|
||
const statuses = await checkComposePgpRecipients(false, visibleRecipients);
|
||
if (!statuses.some((item) => item.hasKey)) return toast("No OpenPGP public key is available for any To/Cc recipient", "warning");
|
||
}
|
||
setBusy(send, true);
|
||
const editor = $("#compose-editor");
|
||
try {
|
||
await request("/messages/send", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
to: parseRecipients($("#compose-to").value),
|
||
cc: parseRecipients($("#compose-cc").value),
|
||
bcc: parseRecipients($("#compose-bcc").value),
|
||
subject: $("#compose-subject").value,
|
||
text_body: editor.innerText,
|
||
html_body: serializeComposeHtml(editor),
|
||
in_reply_to: state.composeReplyMeta?.inReplyTo || null,
|
||
references: state.composeReplyMeta?.references || null,
|
||
draft_id: state.composeDraftId,
|
||
from_identity_id: $("#compose-from").value ? Number($("#compose-from").value) : null,
|
||
request_mdn: Boolean($("#compose-mdn").checked),
|
||
priority: $("#compose-priority").value || "normal",
|
||
pgp_mode: $("#compose-pgp-mode").value || "none",
|
||
pgp_inline: Boolean($("#compose-pgp-inline").checked),
|
||
pgp_passphrase: $("#compose-pgp-passphrase").value || "",
|
||
pgp_attach_public_key: Boolean($("#compose-pgp-attach-key").checked),
|
||
pgp_attach_recipient_keys: Boolean($("#compose-pgp-attach-recipient-keys").checked),
|
||
}),
|
||
});
|
||
$("#compose-dialog").close();
|
||
clearLocalDraftSnapshot(state.composeDraftId);
|
||
state.composeDraftId = null;
|
||
state.composeAttachments = [];
|
||
state.inlineAttachmentIds = new Set();
|
||
setSelectedInlineImage(null);
|
||
state.composeReplyMeta = null;
|
||
state.composeDirty = false;
|
||
state.composeNeedsRemoteSync = false;
|
||
state.pgpRecipientStatus = [];
|
||
$("#compose-pgp-passphrase").value = "";
|
||
toast("Message sent", "success");
|
||
await loadFolders();
|
||
} catch (error) {
|
||
state.composeDirty = true;
|
||
state.composeNeedsRemoteSync = true;
|
||
storeLocalDraftSnapshot();
|
||
await saveCurrentDraft(true, true, true);
|
||
errorBox.textContent = error.message;
|
||
errorBox.classList.remove("hidden");
|
||
} finally { setBusy(send, false); }
|
||
}
|
||
|
||
async function updateFlag(flag, add) {
|
||
if (!state.currentDetail) return;
|
||
const detail = state.currentDetail;
|
||
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ flags: [flag], add }),
|
||
});
|
||
const flags = new Set(detail.flags || []);
|
||
add ? flags.add(flag) : flags.delete(flag);
|
||
detail.flags = [...flags];
|
||
const item = state.messages.find((msg) => msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder);
|
||
if (item) item.flags = detail.flags.join(" ");
|
||
renderMessages();
|
||
renderMessageDetail(detail);
|
||
}
|
||
|
||
async function toggleUnread() {
|
||
if (!state.currentDetail) return;
|
||
const seen = (state.currentDetail.flags || []).includes("\\Seen");
|
||
try { await updateFlag("\\Seen", !seen); toast(seen ? "Marked unread" : "Marked read", "success"); } catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function toggleFlagged() {
|
||
if (!state.currentDetail) return;
|
||
const flagged = (state.currentDetail.flags || []).includes("\\Flagged");
|
||
try { await updateFlag("\\Flagged", !flagged); toast(flagged ? "Flag removed" : "Message flagged", "success"); } catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function moveUndoAction(result, label = "Undo") {
|
||
const undo = result?.undo_move;
|
||
if (!undo?.from_folder || !undo?.to_folder || !Number.isFinite(Number(undo.uid))) return null;
|
||
return {
|
||
label,
|
||
onClick: async () => {
|
||
await request("/messages/move", {
|
||
method: "POST",
|
||
body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }),
|
||
});
|
||
state.selected = null;
|
||
state.currentDetail = null;
|
||
state.selectedMessages.clear();
|
||
closeReader();
|
||
await refreshFolderListOnly();
|
||
await refreshCurrentMessageView();
|
||
toast("Move undone", "success");
|
||
},
|
||
};
|
||
}
|
||
|
||
function bulkMoveUndoAction(undoMoves) {
|
||
if (!Array.isArray(undoMoves) || !undoMoves.length) return null;
|
||
return {
|
||
label: "Undo",
|
||
onClick: async () => {
|
||
let restored = 0;
|
||
for (const undo of [...undoMoves].reverse()) {
|
||
await request("/messages/move", {
|
||
method: "POST",
|
||
body: JSON.stringify({ from_folder: undo.from_folder, to_folder: undo.to_folder, uid: Number(undo.uid) }),
|
||
});
|
||
restored += 1;
|
||
}
|
||
state.selected = null;
|
||
state.currentDetail = null;
|
||
state.selectedMessages.clear();
|
||
closeReader();
|
||
await refreshFolderListOnly();
|
||
await refreshCurrentMessageView();
|
||
toast(`Undo completed: ${restored} message${restored === 1 ? "" : "s"}`, "success");
|
||
},
|
||
};
|
||
}
|
||
|
||
async function moveMessageByDrag(fromFolder, uid, targetFolder) {
|
||
if (!targetFolder || targetFolder === fromFolder) return;
|
||
const result = await request("/messages/move", { method:"POST", body:JSON.stringify({ from_folder:fromFolder, to_folder:targetFolder, uid }) });
|
||
const wasCurrentFolder = state.folder === fromFolder;
|
||
if (wasCurrentFolder) {
|
||
state.messages = state.messages.filter((message) => !(message.uid === uid && (message.folder || state.folder) === fromFolder));
|
||
state.selectedMessages.delete(`${fromFolder}\u0000${uid}`);
|
||
state.total = Math.max(0, state.total - 1);
|
||
if (state.currentDetail?.uid === uid && state.currentDetail?.folder === fromFolder) {
|
||
state.selected = null;
|
||
state.currentDetail = null;
|
||
closeReader();
|
||
}
|
||
renderMessages();
|
||
}
|
||
await refreshFolderListOnly();
|
||
toast(`Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result));
|
||
}
|
||
|
||
async function moveCurrentTo(targetFolder, successMessage = null) {
|
||
if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return;
|
||
const detail = state.currentDetail;
|
||
try {
|
||
const result = await request("/messages/move", {
|
||
method: "POST",
|
||
body: JSON.stringify({ from_folder: detail.folder, to_folder: targetFolder, uid: detail.uid }),
|
||
});
|
||
state.messages = state.messages.filter((msg) => !(msg.uid === detail.uid && (msg.folder || state.folder) === detail.folder));
|
||
state.selectedMessages.delete(`${detail.folder}\u0000${detail.uid}`);
|
||
state.total = Math.max(0, state.total - 1);
|
||
state.selected = null;
|
||
state.currentDetail = null;
|
||
closeReader();
|
||
renderMessages();
|
||
await refreshFolderListOnly();
|
||
toast(successMessage || `Moved to ${targetFolder}`, "success", 9000, moveUndoAction(result));
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function copyCurrentTo(targetFolder) {
|
||
if (!state.currentDetail || !targetFolder || targetFolder === state.currentDetail.folder) return;
|
||
const detail = state.currentDetail;
|
||
try {
|
||
await request("/messages/copy", { method:"POST", body:JSON.stringify({ from_folder:detail.folder, to_folder:targetFolder, uid:detail.uid }) });
|
||
await refreshFolderListOnly();
|
||
toast(`Copied to ${targetFolder}`);
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
const SPAM_DEFAULT_FOLDER_KEY = "rustbox.defaultSpamFolder";
|
||
|
||
function configuredBrowserSpamFolder() {
|
||
const preferred = (localStorage.getItem(SPAM_DEFAULT_FOLDER_KEY) || "").trim();
|
||
if (!preferred) return null;
|
||
const match = state.folders.find((folder) => String(folder.name || "").toLowerCase() === preferred.toLowerCase());
|
||
return match?.name || null;
|
||
}
|
||
|
||
function autoDetectSpecialFolder(role) {
|
||
const attr = { sent:"\\Sent", drafts:"\\Drafts", trash:"\\Trash", junk:"\\Junk", archive:"\\Archive" }[role];
|
||
if (attr) {
|
||
const byAttr = state.folders.find((folder) => (folder.attributes || []).some((value) => String(value).toLowerCase() === attr.toLowerCase()));
|
||
if (byAttr) return byAttr.name;
|
||
}
|
||
const names = { sent:["sent"], drafts:["draft"], trash:["trash","deleted"], junk:["junk","spam"], archive:["archive"] }[role] || [];
|
||
return state.folders.find((folder) => names.some((name) => String(folder.name || "").toLowerCase().includes(name)))?.name || null;
|
||
}
|
||
|
||
function detectSpecialFolder(role) {
|
||
const configured = state.specialFolders?.[role];
|
||
if (configured && state.folders.some((folder) => folder.name === configured)) return configured;
|
||
if (role === "junk") {
|
||
const browserDefault = configuredBrowserSpamFolder();
|
||
if (browserDefault) return browserDefault;
|
||
}
|
||
return autoDetectSpecialFolder(role);
|
||
}
|
||
|
||
function isJunkFolder(folder) {
|
||
const junk = detectSpecialFolder("junk");
|
||
return Boolean(junk && String(folder || "").toLowerCase() === junk.toLowerCase());
|
||
}
|
||
|
||
function inboxFolder() {
|
||
return state.folders.find((folder) => String(folder.name || "").toUpperCase() === "INBOX")?.name || "INBOX";
|
||
}
|
||
|
||
async function archiveCurrent() {
|
||
const archive = detectSpecialFolder("archive");
|
||
if (!archive) return toast("Configure an Archive folder in Settings → Folders", "error");
|
||
await moveCurrentTo(archive, "Message archived");
|
||
}
|
||
|
||
async function junkCurrent() {
|
||
if (!state.currentDetail) return;
|
||
const junk = detectSpecialFolder("junk");
|
||
if (!junk) return toast("Configure a Junk folder in Settings → Folders", "error");
|
||
if (isJunkFolder(state.currentDetail.folder)) {
|
||
await moveCurrentTo(inboxFolder(), tr("marked_not_spam", "Marked as not spam"));
|
||
} else {
|
||
await moveCurrentTo(junk, tr("marked_spam", "Marked as spam"));
|
||
}
|
||
}
|
||
|
||
async function deleteCurrent() {
|
||
if (!state.selected) return;
|
||
const { folder, uid } = state.selected;
|
||
const trash = detectSpecialFolder("trash");
|
||
if (trash && folder !== trash) {
|
||
await moveCurrentTo(trash);
|
||
return;
|
||
}
|
||
if (!await appConfirm("Permanently delete this message?", { title:"Delete message permanently", confirmLabel:"Delete permanently", danger:true })) return;
|
||
try {
|
||
await request(`/messages/${encodeURIComponent(folder)}/${uid}`, { method: "DELETE" });
|
||
state.messages = state.messages.filter((msg) => !(msg.uid === uid && (msg.folder || state.folder) === folder));
|
||
state.selectedMessages.delete(`${folder}\u0000${uid}`);
|
||
state.total = Math.max(0, state.total - 1);
|
||
state.selected = null;
|
||
state.currentDetail = null;
|
||
closeReader();
|
||
renderMessages();
|
||
await refreshFolderListOnly();
|
||
toast("Message deleted", "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function performBulk(action, targetFolder = null) {
|
||
const items = selectedMessages();
|
||
if (!items.length) return;
|
||
const bulkJunkToInbox = action === "junk" && items.every((message) => isJunkFolder(message.folder || state.folder));
|
||
if (action === "archive" && !detectSpecialFolder("archive")) {
|
||
toast("Configure an Archive folder in Settings → Folders", "error");
|
||
return;
|
||
}
|
||
if (action === "junk" && !bulkJunkToInbox && !detectSpecialFolder("junk")) {
|
||
toast("Configure a Junk folder in Settings → Folders", "error");
|
||
return;
|
||
}
|
||
const permanentDelete = action === "delete" && items.every((message) => (message.folder || state.folder) === detectSpecialFolder("trash"));
|
||
if (permanentDelete && !await appConfirm(`Permanently delete ${items.length} selected messages?`, { title:"Delete selected messages", confirmLabel:"Delete permanently", danger:true })) return;
|
||
let completed = 0;
|
||
const undoMoves = [];
|
||
let movedOperations = 0;
|
||
try {
|
||
for (const message of items) {
|
||
const folder = message.folder || state.folder;
|
||
if (action === "read" || action === "unread" || action === "flag" || action === "unflag") {
|
||
const flag = action === "read" || action === "unread" ? "\\Seen" : "\\Flagged";
|
||
const add = action === "read" || action === "flag";
|
||
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method:"PATCH", body:JSON.stringify({ flags:[flag], add }) });
|
||
} else if (action === "move" || action === "archive" || action === "junk" || action === "delete") {
|
||
let destination = targetFolder;
|
||
if (action === "archive") destination = detectSpecialFolder("archive");
|
||
if (action === "junk") destination = bulkJunkToInbox ? inboxFolder() : detectSpecialFolder("junk");
|
||
if (action === "delete") destination = detectSpecialFolder("trash");
|
||
if (destination && destination !== folder) {
|
||
movedOperations += 1;
|
||
const result = await request("/messages/move", { method:"POST", body:JSON.stringify({ from_folder:folder, to_folder:destination, uid:message.uid }) });
|
||
if (result?.undo_move) undoMoves.push(result.undo_move);
|
||
} else if (action === "delete") {
|
||
await request(`/messages/${encodeURIComponent(folder)}/${message.uid}`, { method:"DELETE" });
|
||
}
|
||
} else if (action === "copy" && targetFolder && targetFolder !== folder) {
|
||
await request("/messages/copy", { method:"POST", body:JSON.stringify({ from_folder:folder, to_folder:targetFolder, uid:message.uid }) });
|
||
}
|
||
completed += 1;
|
||
$("#bulk-count").textContent = `${completed}/${items.length}`;
|
||
}
|
||
state.selectedMessages.clear();
|
||
state.searchAllResults = null;
|
||
await refreshFolderListOnly();
|
||
await refreshCurrentMessageView();
|
||
const bulkMessages = { junk: bulkJunkToInbox ? tr("marked_not_spam", "Marked as not spam") : tr("marked_spam", "Marked as spam"), archive: "Archived", read: "Marked read", unread: "Marked unread", flag: "Flagged", unflag: "Unflagged", delete: "Deleted", move: "Moved", copy: "Copied" };
|
||
const undoAction = movedOperations > 0 && undoMoves.length === movedOperations ? bulkMoveUndoAction(undoMoves) : null;
|
||
toast(`${bulkMessages[action] || "Updated"}: ${completed} message${completed === 1 ? "" : "s"}`, "success", undoAction ? 10000 : null, undoAction);
|
||
} catch (error) {
|
||
toast(`${completed}/${items.length} completed: ${error.message}`, "error");
|
||
state.searchAllResults = null;
|
||
await refreshCurrentMessageView();
|
||
}
|
||
}
|
||
|
||
async function refreshFolderListOnly() {
|
||
try {
|
||
const data = await request("/folders", { method: "GET" });
|
||
state.folders = data.folders || [];
|
||
updateUnreadAppBadge();
|
||
renderFolders();
|
||
} catch (_) {}
|
||
}
|
||
|
||
async function refreshCurrentMessageView() {
|
||
if (state.searchContext) {
|
||
await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop:state.searchContext.scrollTop });
|
||
} else {
|
||
await loadMessages(true);
|
||
}
|
||
}
|
||
|
||
async function runSearch(params, label = "Search", options = {}) {
|
||
const cleanParams = new URLSearchParams(params.toString());
|
||
cleanParams.delete("all");
|
||
const queryString = cleanParams.toString();
|
||
if (!queryString) return;
|
||
$("#folder-title").textContent = label;
|
||
$("#message-list").innerHTML = `<div class="empty-list">Searching…</div>`;
|
||
state.selectedMessages.clear();
|
||
state.searchAllResults = null;
|
||
state.searchContext = { params:queryString, label, scrollTop:Number(options.restoreScrollTop) || 0 };
|
||
saveActiveSearchContext();
|
||
renderBulkToolbar();
|
||
try {
|
||
const data = await request(`/search?${queryString}`, { method: "GET" });
|
||
state.messages = data.results || [];
|
||
state.total = data.total_count || state.messages.length;
|
||
renderMessages();
|
||
$("#folder-title").textContent = label;
|
||
$("#message-count").textContent = `${state.total} result${state.total === 1 ? "" : "s"}`;
|
||
const restoreScrollTop = Number(options.restoreScrollTop) || 0;
|
||
if (restoreScrollTop > 0) requestAnimationFrame(() => { $("#message-list").scrollTop = restoreScrollTop; });
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function searchMail(event) {
|
||
event.preventDefault();
|
||
const query = $("#search-input").value.trim();
|
||
if (!query) {
|
||
clearActiveSearchContext();
|
||
return loadMessages(true);
|
||
}
|
||
const params = new URLSearchParams({ q: query, limit: "100" });
|
||
await runSearch(params, `Search: ${query}`);
|
||
}
|
||
|
||
function populateAdvancedSearchFolders() {
|
||
const select = $("#search-folder");
|
||
const current = select.value;
|
||
select.replaceChildren(new Option("All folders", ""));
|
||
for (const folder of orderedFolders()) select.add(new Option(folder.name, folder.name));
|
||
if ([...select.options].some((option) => option.value === current)) select.value = current;
|
||
}
|
||
|
||
function buildAdvancedSearchParams() {
|
||
const params = new URLSearchParams();
|
||
const set = (name, value) => { if (String(value || "").trim()) params.set(name, String(value).trim()); };
|
||
set("q", $("#search-text").value);
|
||
set("folder", $("#search-folder").value);
|
||
set("from", $("#search-from").value);
|
||
set("to", $("#search-to").value);
|
||
set("subject", $("#search-subject").value);
|
||
const dateFrom = $("#search-date-from").value;
|
||
const dateTo = $("#search-date-to").value;
|
||
if (dateFrom) params.set("date_from", String(Math.floor(new Date(`${dateFrom}T00:00:00`).getTime() / 1000)));
|
||
if (dateTo) params.set("date_to", String(Math.floor(new Date(`${dateTo}T23:59:59`).getTime() / 1000)));
|
||
const read = $("#search-read").value;
|
||
if (read) params.set("unread", read === "unread" ? "true" : "false");
|
||
set("flagged", $("#search-flagged").value);
|
||
set("has_attachment", $("#search-attachment").value);
|
||
params.set("sort", $("#search-sort").value || "date_desc");
|
||
params.set("limit", "250");
|
||
return params;
|
||
}
|
||
|
||
function resetAdvancedSearch() {
|
||
$("#advanced-search-form").reset();
|
||
$("#search-sort").value = "date_desc";
|
||
populateAdvancedSearchFolders();
|
||
}
|
||
|
||
async function openAdvancedSearch() {
|
||
populateAdvancedSearchFolders();
|
||
$("#advanced-search-dialog").showModal();
|
||
await loadSavedSearches();
|
||
}
|
||
|
||
async function submitAdvancedSearch(event) {
|
||
event.preventDefault();
|
||
const params = buildAdvancedSearchParams();
|
||
const meaningful = [...params.keys()].some((key) => !["sort", "limit"].includes(key));
|
||
if (!meaningful) return toast("Choose at least one search criterion", "error");
|
||
$("#advanced-search-dialog").close();
|
||
await runSearch(params, "Advanced search");
|
||
}
|
||
|
||
async function loadSavedSearches() {
|
||
try {
|
||
const data = await request("/search/saved", { method:"GET" });
|
||
state.savedSearches = data.searches || [];
|
||
renderSavedSearches();
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function renderSavedSearches() {
|
||
const list = $("#saved-search-list");
|
||
list.replaceChildren();
|
||
if (!state.savedSearches.length) { list.append(el("div", "empty-list", "No saved searches")); return; }
|
||
for (const saved of state.savedSearches) {
|
||
const row = el("div", "saved-search-item");
|
||
const name = el("strong", "", saved.name);
|
||
const run = el("button", "button quiet", "Run"); run.type = "button";
|
||
const remove = el("button", "button quiet danger", "Delete"); remove.type = "button";
|
||
run.addEventListener("click", async () => { $("#advanced-search-dialog").close(); await runSearch(new URLSearchParams(saved.query_string), `Search: ${saved.name}`); });
|
||
remove.addEventListener("click", async () => {
|
||
if (!await appConfirm(`Delete saved search “${saved.name}”?`, { title:"Delete saved search", confirmLabel:"Delete", danger:true })) return;
|
||
try {
|
||
await request(`/search/saved/${saved.id}`, { method:"DELETE" });
|
||
await loadSavedSearches();
|
||
toast("Saved search deleted", "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
});
|
||
row.append(name, run, remove); list.append(row);
|
||
}
|
||
}
|
||
|
||
async function saveAdvancedSearch() {
|
||
const params = buildAdvancedSearchParams();
|
||
const meaningful = [...params.keys()].some((key) => !["sort", "limit"].includes(key));
|
||
if (!meaningful) return toast("Choose at least one search criterion", "error");
|
||
const name = await appPrompt("Save search", { message:"Name this search so you can run it again later.", placeholder:"Search name", confirmLabel:"Save" });
|
||
if (!name?.trim()) return;
|
||
try {
|
||
await request("/search/saved", { method:"POST", body:JSON.stringify({ name:name.trim(), query_string:params.toString() }) });
|
||
await loadSavedSearches();
|
||
toast("Search saved");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function openFolderDialog() {
|
||
$("#current-folder-name").value = state.folder;
|
||
$("#new-folder-name").value = "";
|
||
const current = state.folders.find((folder) => folder.name === state.folder);
|
||
$("#subscribe-folder-btn").textContent = current?.is_subscribed ? "Unsubscribe" : "Subscribe";
|
||
$("#folder-dialog").showModal();
|
||
}
|
||
|
||
async function createFolder() {
|
||
const name = $("#new-folder-name").value.trim();
|
||
if (!name) return;
|
||
try {
|
||
await request("/folders", { method: "POST", body: JSON.stringify({ name }) });
|
||
$("#new-folder-name").value = "";
|
||
await refreshFolderListOnly();
|
||
toast("Folder created");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function renameFolder() {
|
||
const current = state.folder;
|
||
const next = await appPrompt("Rename folder", { value:current, confirmLabel:"Rename", required:true });
|
||
if (!next || next === current) return;
|
||
try {
|
||
await request(`/folders/${encodeURIComponent(current)}`, { method: "PATCH", body: JSON.stringify({ new_name: next }) });
|
||
state.folder = next;
|
||
$("#current-folder-name").value = next;
|
||
await loadFolders();
|
||
toast("Folder renamed");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function deleteFolder() {
|
||
const current = state.folder;
|
||
if (!await appConfirm(`Delete folder “${current}”?`, { title:"Delete folder", confirmLabel:"Delete", danger:true })) return;
|
||
try {
|
||
await request(`/folders/${encodeURIComponent(current)}`, { method: "DELETE" });
|
||
$("#folder-dialog").close();
|
||
state.folder = "INBOX";
|
||
await loadFolders();
|
||
toast("Folder deleted");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function hideContextMenu() {
|
||
const menu = $("#context-menu");
|
||
menu.classList.add("hidden");
|
||
menu.replaceChildren();
|
||
}
|
||
|
||
function showContextMenu(x, y, items) {
|
||
const menu = $("#context-menu");
|
||
menu.replaceChildren();
|
||
for (const item of items) {
|
||
if (item === "separator") { menu.append(el("div", "separator")); continue; }
|
||
const button = el("button", item.danger ? "danger" : "", item.label);
|
||
button.type = "button";
|
||
button.addEventListener("click", async () => { hideContextMenu(); await item.action(); });
|
||
menu.append(button);
|
||
}
|
||
menu.classList.remove("hidden");
|
||
const rect = menu.getBoundingClientRect();
|
||
menu.style.left = `${Math.max(6, Math.min(x, innerWidth - rect.width - 6))}px`;
|
||
menu.style.top = `${Math.max(6, Math.min(y, innerHeight - rect.height - 6))}px`;
|
||
}
|
||
|
||
async function ensureMessageOpen(message) {
|
||
const folder = message.folder || state.folder;
|
||
if (!state.currentDetail || state.currentDetail.uid !== message.uid || state.currentDetail.folder !== folder) {
|
||
await openMessage(folder, message.uid);
|
||
}
|
||
}
|
||
|
||
async function promptFolder(label, exclude = null) {
|
||
const options = orderedFolders().map((folder) => folder.name).filter((name) => name !== exclude);
|
||
if (!options.length) return null;
|
||
return appSelect(label, options.map((name) => ({ value:name, label:name })), { value:options[0], confirmLabel:"Choose folder" });
|
||
}
|
||
|
||
function openMessageContextMenu(x, y, message) {
|
||
const flags = Array.isArray(message.flags) ? message.flags.join(" ") : String(message.flags || "");
|
||
const seen = flags.includes("\\Seen");
|
||
const flagged = flags.includes("\\Flagged");
|
||
showContextMenu(x, y, [
|
||
{ label: "Open", action: () => openMessage(message.folder || state.folder, message.uid) },
|
||
{ label: "Reply", action: async () => { await ensureMessageOpen(message); openCompose("reply"); } },
|
||
{ label: "Reply all", action: async () => { await ensureMessageOpen(message); openCompose("reply-all"); } },
|
||
{ label: "Forward", action: async () => { await ensureMessageOpen(message); openCompose("forward"); } },
|
||
{ label: "Forward as attachment", action: async () => { await ensureMessageOpen(message); await forwardAsAttachment(); } },
|
||
"separator",
|
||
{ label: seen ? "Mark unread" : "Mark read", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Seen", !seen); } },
|
||
{ label: flagged ? "Unflag" : "Flag", action: async () => { await ensureMessageOpen(message); await updateFlag("\\Flagged", !flagged); } },
|
||
"separator",
|
||
{ label: "Archive", action: async () => { await ensureMessageOpen(message); await archiveCurrent(); } },
|
||
{ label: "Move to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Move to folder", message.folder || state.folder); if (target) await moveCurrentTo(target); } },
|
||
{ label: "Copy to…", action: async () => { await ensureMessageOpen(message); const target = await promptFolder("Copy to folder", message.folder || state.folder); if (target) await copyCurrentTo(target); } },
|
||
{ label: "Add label…", action: async () => { const tag = await promptTag("Add label"); if (tag) await addTagToMessage(message, tag); } },
|
||
{ label: "Remove label…", action: async () => { const attached = state.tags.filter((tag) => (message.tags || []).some((item) => item.id === tag.id)); const tag = await promptTag("Remove label", attached); if (tag) await removeTagFromMessage(message, tag); } },
|
||
{ label: isJunkFolder(message.folder || state.folder) ? tr("mark_not_spam", "Mark as not spam") : tr("mark_spam", "Mark as spam"), action: async () => { await ensureMessageOpen(message); await junkCurrent(); } },
|
||
{ label: "Create filter from sender", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("sender"); } },
|
||
{ label: "Create filter from domain", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("domain"); } },
|
||
{ label: "Create filter from subject", action: async () => { await ensureMessageOpen(message); await createFilterFromCurrentMessage("subject"); } },
|
||
{ label: "Add sender to contacts", action: async () => { await ensureMessageOpen(message); await addSenderToContacts(); } },
|
||
{ label: "Search messages from sender", action: async () => { await ensureMessageOpen(message); await searchFromCurrentSender(); } },
|
||
{ label: "Unsubscribe from mailing list", action: async () => { await ensureMessageOpen(message); await openListUnsubscribe(); } },
|
||
"separator",
|
||
{ label: "Show headers", action: async () => { await ensureMessageOpen(message); showHeaders(); } },
|
||
{ label: "Show source", action: async () => { await ensureMessageOpen(message); await showMessageSource(); } },
|
||
{ label: "Copy Message-ID", action: async () => { await ensureMessageOpen(message); await copyMessageId(); } },
|
||
{ label: "Copy sender address", action: async () => { await ensureMessageOpen(message); await copySenderAddress(); } },
|
||
{ label: "Download .eml", action: async () => { await ensureMessageOpen(message); downloadEml(); } },
|
||
{ label: "Print", action: async () => { await ensureMessageOpen(message); printCurrentMessage(); } },
|
||
{ label: "Delete", danger: true, action: async () => { await ensureMessageOpen(message); await deleteCurrent(); } },
|
||
]);
|
||
}
|
||
|
||
async function configureFolderFlaggedOnTop(folder) {
|
||
try {
|
||
const current = await request(`/folders/${encodeURIComponent(folder.name)}/preferences`, { method:"GET" });
|
||
const value = current.flagged_on_top === true ? "on" : current.flagged_on_top === false ? "off" : "inherit";
|
||
const choice = await appSelect("Flagged messages on top", [
|
||
{ value:"inherit", label:`Use mailbox default (${current.effective_flagged_on_top ? "on" : "off"})` },
|
||
{ value:"on", label:"Always on for this folder" },
|
||
{ value:"off", label:"Always off for this folder" },
|
||
], { message:`Folder: ${folder.name}`, value, confirmLabel:"Save" });
|
||
if (choice === null) return;
|
||
const flagged_on_top = choice === "inherit" ? null : choice === "on";
|
||
await request(`/folders/${encodeURIComponent(folder.name)}/preferences`, { method:"PUT", body:JSON.stringify({ flagged_on_top }) });
|
||
if (state.folder === folder.name && !state.searchContext) await loadMessages(true);
|
||
toast("Folder list preference saved", "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function openFolderContextMenu(x, y, folder) {
|
||
const subscribed = Boolean(folder.is_subscribed);
|
||
showContextMenu(x, y, [
|
||
{ label: "Open", action: async () => { state.folder = folder.name; renderFolders(); await loadMessages(true); } },
|
||
{ label: "Create subfolder", action: async () => {
|
||
const delimiter = folder.delimiter || "/";
|
||
const child = await appPrompt("Create subfolder", { message:`Parent folder: ${folder.name}`, placeholder:"Subfolder name", confirmLabel:"Create", required:true });
|
||
if (!child) return;
|
||
try {
|
||
await request("/folders", { method:"POST", body:JSON.stringify({ name:`${folder.name}${delimiter}${child}` }) });
|
||
await refreshFolderListOnly();
|
||
toast("Subfolder created", "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
} },
|
||
{ label: subscribed ? "Unsubscribe" : "Subscribe", action: () => setFolderSubscription(folder.name, !subscribed) },
|
||
{ label: "Flagged on top…", action: () => configureFolderFlaggedOnTop(folder) },
|
||
{ label: "Mark all read", action: () => markFolderAllRead(folder.name) },
|
||
{ label: "Import .eml…", action: () => importEmlToFolder(folder.name) },
|
||
{ label: "Empty folder", danger: true, action: () => emptyFolder(folder.name) },
|
||
"separator",
|
||
{ label: "Properties", action: () => showFolderProperties(folder) },
|
||
{ label: "Rename", action: async () => { state.folder = folder.name; await renameFolder(); } },
|
||
{ label: "Delete", danger: true, action: async () => { state.folder = folder.name; await deleteFolder(); } },
|
||
]);
|
||
}
|
||
|
||
function importEmlToFolder(folderName) {
|
||
const input = document.createElement("input");
|
||
input.type = "file";
|
||
input.accept = ".eml,message/rfc822";
|
||
input.multiple = false;
|
||
input.addEventListener("change", async () => {
|
||
const file = input.files?.[0];
|
||
if (!file) return;
|
||
if (file.size > 50 * 1024 * 1024) return toast(".eml exceeds the 50 MB import limit", "error");
|
||
const form = new FormData();
|
||
form.append("file", file, file.name || "message.eml");
|
||
try {
|
||
await request(`/folders/${encodeURIComponent(folderName)}/import-eml`, { method:"POST", body:form });
|
||
if (state.folder === folderName) await loadMessages(true);
|
||
await refreshFolderListOnly();
|
||
toast(`Imported ${file.name || "message.eml"}`);
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}, { once:true });
|
||
input.click();
|
||
}
|
||
|
||
function showFolderProperties(folder) {
|
||
const roles = Object.entries(state.specialFolders || {}).filter(([, value]) => value === folder.name).map(([key]) => key);
|
||
const dialog = document.createElement("dialog");
|
||
dialog.className = "dialog";
|
||
const card = el("div", "dialog-card folder-properties-card");
|
||
const header = el("header", "dialog-header");
|
||
header.append(el("strong", "", "Folder properties"));
|
||
const close = el("button", "button quiet", "Close"); close.type="button"; close.addEventListener("click", () => dialog.close());
|
||
header.append(close);
|
||
const body = el("div", "folder-properties-list");
|
||
const rows = [
|
||
["Name", folder.name || ""],
|
||
["Subscribed", folder.is_subscribed ? "Yes" : "No"],
|
||
["Messages", String(folder.total_count ?? folder.exists ?? 0)],
|
||
["Unread", String(folder.unread_count ?? 0)],
|
||
["Delimiter", folder.delimiter || "(none)"],
|
||
["Special role", roles.length ? roles.join(", ") : "None"],
|
||
["Attributes", Array.isArray(folder.attributes) ? folder.attributes.join(" ") : String(folder.attributes || "None")],
|
||
];
|
||
for (const [label,value] of rows) { const row=el("div","folder-property-row"); row.append(el("span","muted",label),el("strong","",value)); body.append(row); }
|
||
card.append(header, body); dialog.append(card); document.body.append(dialog);
|
||
dialog.addEventListener("close", () => dialog.remove(), { once:true });
|
||
dialog.showModal();
|
||
}
|
||
|
||
async function markFolderAllRead(name) {
|
||
try {
|
||
await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method:"POST", body:"{}" });
|
||
if (state.folder === name) await loadMessages(true);
|
||
await refreshFolderListOnly();
|
||
toast("All messages marked read");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function emptyFolder(name) {
|
||
if (!await appConfirm(`Permanently remove every message from “${name}”?`, { title:"Empty folder", confirmLabel:"Empty folder", danger:true })) return;
|
||
try {
|
||
await request(`/folders/${encodeURIComponent(name)}/empty`, { method:"POST", body:"{}" });
|
||
if (state.folder === name) { state.selected=null; state.currentDetail=null; closeReader(); await loadMessages(true); }
|
||
await refreshFolderListOnly();
|
||
toast("Folder emptied");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function setFolderSubscription(name, subscribed) {
|
||
try {
|
||
await request(`/folders/${encodeURIComponent(name)}/subscribe`, { method:"PATCH", body:JSON.stringify({ subscribed }) });
|
||
await refreshFolderListOnly();
|
||
toast(subscribed ? "Folder subscribed" : "Folder unsubscribed");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function toggleCurrentFolderSubscription() {
|
||
const folder = state.folders.find((item) => item.name === state.folder);
|
||
if (!folder) return;
|
||
await setFolderSubscription(folder.name, !folder.is_subscribed);
|
||
}
|
||
|
||
function rawHeaderValue(name, raw = state.currentDetail?.raw_headers || "") {
|
||
if (!raw) return "";
|
||
const unfolded = raw.replace(/\r?\n[ \t]+/g, " ");
|
||
const prefix = `${String(name).toLowerCase()}:`;
|
||
const line = unfolded.split(/\r?\n/).find((item) => item.toLowerCase().startsWith(prefix));
|
||
return line ? line.slice(line.indexOf(":") + 1).trim() : "";
|
||
}
|
||
|
||
async function openListUnsubscribe() {
|
||
const detail = state.currentDetail;
|
||
if (!detail) return;
|
||
const value = rawHeaderValue("List-Unsubscribe");
|
||
if (!value) return toast("This message does not provide List-Unsubscribe", "error");
|
||
const targets = [...value.matchAll(/<([^>]+)>/g)].map((match) => match[1].trim()).filter(Boolean);
|
||
const httpsTarget = targets.find((item) => /^https:\/\//i.test(item));
|
||
const mailtoTarget = targets.find((item) => /^mailto:/i.test(item));
|
||
const oneClick = rawHeaderValue("List-Unsubscribe-Post")
|
||
.split(",")
|
||
.some((item) => item.trim().toLowerCase() === "list-unsubscribe=one-click");
|
||
|
||
if (oneClick && httpsTarget) {
|
||
const confirmed = await appConfirm("Unsubscribe from this mailing list?", {
|
||
title: "One-click unsubscribe",
|
||
confirmLabel: "Unsubscribe",
|
||
danger: true,
|
||
});
|
||
if (!confirmed) return;
|
||
try {
|
||
await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/unsubscribe`, { method:"POST" });
|
||
toast("Unsubscribe request sent", "success");
|
||
} catch (error) {
|
||
toast(error.message, "error");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const target = httpsTarget || mailtoTarget;
|
||
if (!target) return toast("No supported HTTPS or mailto unsubscribe target", "error");
|
||
if (/^https:\/\//i.test(target)) {
|
||
const confirmed = await appConfirm("This mailing list does not support one-click unsubscribe. Open its external unsubscribe page?", {
|
||
title: "Open unsubscribe page",
|
||
confirmLabel: "Open page",
|
||
});
|
||
if (confirmed) window.open(target, "_blank", "noopener,noreferrer");
|
||
} else {
|
||
window.location.href = target;
|
||
}
|
||
}
|
||
|
||
function showHeaders() {
|
||
const detail = state.currentDetail;
|
||
if (!detail) return;
|
||
const summary = $("#headers-summary");
|
||
summary.replaceChildren();
|
||
const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", ");
|
||
const cc = (detail.cc_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", ");
|
||
const fields = [
|
||
["From", detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address],
|
||
["To", to], ["Cc", cc], ["Date", detail.date ? formatDate(detail.date, true) : rawHeaderValue("Date")],
|
||
["Message-ID", detail.message_id || rawHeaderValue("Message-ID")],
|
||
["In-Reply-To", detail.in_reply_to || rawHeaderValue("In-Reply-To")],
|
||
["References", detail.references || rawHeaderValue("References")],
|
||
["Return-Path", rawHeaderValue("Return-Path")], ["Delivered-To", rawHeaderValue("Delivered-To")],
|
||
["List-ID", rawHeaderValue("List-ID")], ["List-Unsubscribe", rawHeaderValue("List-Unsubscribe")],
|
||
["List-Unsubscribe-Post", rawHeaderValue("List-Unsubscribe-Post")],
|
||
["User-Agent", rawHeaderValue("User-Agent") || rawHeaderValue("X-Mailer")],
|
||
["Authentication-Results", rawHeaderValue("Authentication-Results")], ["Content-Type", rawHeaderValue("Content-Type")],
|
||
];
|
||
for (const [label, value] of fields) {
|
||
if (!String(value || "").trim()) continue;
|
||
const row = el("div", "header-summary-row");
|
||
row.append(el("span", "muted", label), el("code", "", String(value)));
|
||
summary.append(row);
|
||
}
|
||
$("#headers-content").textContent = detail.raw_headers || "No raw headers available.";
|
||
$("#headers-dialog").showModal();
|
||
}
|
||
|
||
async function fetchEmlBlob(detail = state.currentDetail) {
|
||
if (!detail) throw new Error("No message selected");
|
||
const headers = new Headers();
|
||
if (state.accountId) headers.set("X-Active-Account", state.accountId);
|
||
const response = await fetch(apiPath(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/source`), { credentials:"include", headers });
|
||
if (!response.ok) throw new Error(`Message source failed (${response.status})`);
|
||
return response.blob();
|
||
}
|
||
|
||
async function showMessageSource() {
|
||
const detail = state.currentDetail;
|
||
if (!detail) return;
|
||
try {
|
||
const blob = await fetchEmlBlob(detail);
|
||
const source = await blob.text();
|
||
const dialog = document.createElement("dialog");
|
||
dialog.className = "dialog";
|
||
const card = el("div", "dialog-card source-card");
|
||
const header = el("header", "dialog-header");
|
||
header.append(el("strong", "", "Message source"));
|
||
const close = el("button", "button quiet", "Close"); close.type="button"; close.addEventListener("click", () => dialog.close());
|
||
header.append(close);
|
||
const pre = el("pre", "message-source-content"); pre.textContent = source;
|
||
const actions = el("footer", "dialog-actions");
|
||
const copy = el("button", "button quiet", "Copy source"); copy.type="button";
|
||
copy.addEventListener("click", async () => { try { await navigator.clipboard.writeText(source); toast("Message source copied"); } catch (_) { toast("Could not copy source", "error"); } });
|
||
const download = el("button", "button primary", "Download .eml"); download.type="button"; download.addEventListener("click", downloadEml);
|
||
actions.append(copy, download);
|
||
card.append(header, pre, actions); dialog.append(card); document.body.append(dialog);
|
||
dialog.addEventListener("close", () => dialog.remove(), { once:true });
|
||
dialog.showModal();
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function printCurrentMessage() {
|
||
const detail = state.currentDetail;
|
||
if (!detail) return toast("Select a message before printing", "warning");
|
||
const to = (detail.to_addresses || []).map((entry) => entry.name ? `${entry.name} <${entry.address}>` : entry.address).join(", ");
|
||
const body = detail.html ? sanitizeMessageHtml(detail.html) : `<pre>${escapeHtml(cleanPlainMessageText(detail.text || ""))}</pre>`;
|
||
const frame = document.createElement("iframe");
|
||
frame.className = "print-frame";
|
||
frame.setAttribute("aria-hidden", "true");
|
||
const html = `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data:; style-src 'unsafe-inline'"><title>${escapeHtml(detail.subject || "Message")}</title><style>body{margin:24px;color:#111;background:#fff;font:14px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}.meta{padding-bottom:14px;margin-bottom:18px;border-bottom:1px solid #bbb}.meta h1{font-size:20px;margin:0 0 12px}.meta div{margin:3px 0}.label{display:inline-block;width:58px;color:#555}img{max-width:100%;height:auto}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:13px/1.55 ui-monospace,monospace}blockquote{border-left:2px solid #bbb;margin-left:0;padding-left:12px}@page{margin:14mm}@media print{body{margin:0}}</style></head><body><section class="meta"><h1>${escapeHtml(detail.subject || "(no subject)")}</h1><div><span class="label">From</span>${escapeHtml(detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address || "")}</div><div><span class="label">To</span>${escapeHtml(to)}</div><div><span class="label">Date</span>${escapeHtml(formatDate(detail.date, true))}</div></section>${body}</body></html>`;
|
||
frame.addEventListener("load", () => {
|
||
const cleanup = () => frame.remove();
|
||
try {
|
||
frame.contentWindow?.addEventListener("afterprint", cleanup, { once:true });
|
||
frame.contentWindow?.focus();
|
||
frame.contentWindow?.print();
|
||
toast("Print dialog opened", "info");
|
||
setTimeout(cleanup, 60000);
|
||
} catch (_) {
|
||
cleanup();
|
||
toast("Could not open the print dialog", "error");
|
||
}
|
||
}, { once:true });
|
||
frame.srcdoc = html;
|
||
document.body.append(frame);
|
||
}
|
||
|
||
async function copyMessageId() {
|
||
const value = state.currentDetail?.message_id;
|
||
if (!value) return toast("Message-ID is not available", "error");
|
||
try { await navigator.clipboard.writeText(value); toast("Message-ID copied"); }
|
||
catch (_) { toast("Could not copy Message-ID", "error"); }
|
||
}
|
||
|
||
async function copySenderAddress() {
|
||
const value = state.currentDetail?.from_address;
|
||
if (!value) return;
|
||
try { await navigator.clipboard.writeText(value); toast("Sender address copied"); }
|
||
catch (_) { toast("Could not copy address", "error"); }
|
||
}
|
||
|
||
async function downloadEml() {
|
||
const detail = state.currentDetail;
|
||
if (!detail) return;
|
||
try {
|
||
const blob = await fetchEmlBlob(detail);
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = `message-${detail.uid}.eml`;
|
||
link.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 30000);
|
||
toast("Downloading .eml", "info");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function forwardAsAttachment() {
|
||
const detail = state.currentDetail;
|
||
if (!detail) return;
|
||
try {
|
||
const blob = await fetchEmlBlob(detail);
|
||
openCompose("new");
|
||
$("#compose-title").textContent = "Forward as attachment";
|
||
$("#compose-subject").value = /^fwd:/i.test(detail.subject || "") ? detail.subject : `Fwd: ${detail.subject || ""}`;
|
||
const safe = String(detail.subject || `message-${detail.uid}`).replace(/[\\/:*?"<>|]+/g, "_").slice(0, 120) || `message-${detail.uid}`;
|
||
const file = new File([blob], `${safe}.eml`, { type:"message/rfc822" });
|
||
await uploadComposeFiles([file]);
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
|
||
function openCurrentSenderMenu(x, y) {
|
||
const detail = state.currentDetail;
|
||
if (!detail?.from_address) return;
|
||
showContextMenu(x, y, [
|
||
{ label: "Compose to sender", action: () => { openCompose("new"); $("#compose-to").value = detail.from_name ? `${detail.from_name} <${detail.from_address}>` : detail.from_address; $("#compose-subject").value = ""; } },
|
||
{ label: "Add to contacts", action: addSenderToContacts },
|
||
{ label: "Copy address", action: copySenderAddress },
|
||
{ label: "Search messages", action: searchFromCurrentSender },
|
||
"separator",
|
||
{ label: "Create filter", action: async () => createFilterFromCurrentMessage("sender") },
|
||
]);
|
||
}
|
||
|
||
async function addSenderToContacts() {
|
||
const detail = state.currentDetail;
|
||
if (!detail?.from_address) return;
|
||
try {
|
||
await request("/contacts", { method:"POST", body:JSON.stringify({ id:null, email:detail.from_address, name:detail.from_name || "", company:"", notes:"", is_favorite:false, source:"message" }) });
|
||
toast("Sender added to contacts");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function searchFromCurrentSender() {
|
||
const detail = state.currentDetail;
|
||
if (!detail?.from_address) return;
|
||
const query = `from:${detail.from_address}`;
|
||
$("#search-input").value = query;
|
||
await runSearch(new URLSearchParams({ q:query, limit:"100" }), `From: ${detail.from_address}`);
|
||
}
|
||
|
||
async function copyHeaders() {
|
||
if (!state.currentDetail?.raw_headers) return;
|
||
try { await navigator.clipboard.writeText(state.currentDetail.raw_headers); toast("Headers copied"); }
|
||
catch (_) { toast("Could not copy headers", "error"); }
|
||
}
|
||
|
||
function quickFilterSource(detail, kind) {
|
||
const sender = String(detail?.from_address || "").trim();
|
||
const domain = sender.includes("@") ? sender.split("@").pop() : sender;
|
||
const subject = String(detail?.subject || "").trim();
|
||
if (kind === "domain") return { kind:"domain", value:domain, match:"is" };
|
||
if (kind === "subject") return { kind:"subject", value:subject, match:"contains" };
|
||
return { kind:"sender", value:sender, match:"is" };
|
||
}
|
||
|
||
function buildQuickFilterRule({ kind, value, match, action, target, stop, markRead = false }) {
|
||
if (!value.trim()) throw new Error("Filter value is required");
|
||
const matchToken = match === "contains" ? ":contains" : ":is";
|
||
let condition;
|
||
if (kind === "domain") condition = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
|
||
else if (kind === "subject") condition = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
|
||
else condition = `address ${matchToken} "from" "${sieveQuote(value)}"`;
|
||
|
||
const requirements = new Set();
|
||
let command;
|
||
if (action === "move") {
|
||
if (!target) throw new Error("Choose a destination folder");
|
||
requirements.add("fileinto");
|
||
command = `fileinto "${sieveQuote(target)}";`;
|
||
} else if (action === "copy") {
|
||
if (!target) throw new Error("Choose a destination folder");
|
||
requirements.add("fileinto");
|
||
requirements.add("copy");
|
||
command = `fileinto :copy "${sieveQuote(target)}";`;
|
||
} else if (action === "mark-read") {
|
||
requirements.add("imap4flags");
|
||
command = 'addflag "\\Seen";';
|
||
} else if (action === "flag") {
|
||
requirements.add("imap4flags");
|
||
command = 'addflag "\\Flagged";';
|
||
} else if (action === "discard") command = "discard;";
|
||
else command = "keep;";
|
||
|
||
if (markRead && action !== "mark-read") requirements.add("imap4flags");
|
||
const ruleId = newSieveVisualRuleId();
|
||
const commands = [command, markRead && action !== "mark-read" ? 'addflag "\\Seen";' : "", stop ? "stop;" : ""].filter(Boolean).join("\n ");
|
||
return {
|
||
requirements:[...requirements],
|
||
rule:`# RUSTBOX-RULE-BEGIN ${ruleId}\n# Created from message\nif ${condition} {\n ${commands}\n}\n# RUSTBOX-RULE-END`,
|
||
};
|
||
}
|
||
|
||
async function prepareQuickFilterAdvanced(config) {
|
||
if ($("#settings-dialog").open) $("#settings-dialog").close();
|
||
selectSettingsTab("sieve");
|
||
$("#settings-dialog").showModal();
|
||
await loadSieve();
|
||
const scripts = state.sieveScripts || [];
|
||
const targetScript = config.script || scripts.find((script) => script.active)?.name || scripts.find((script) => script.name === "rustbox")?.name || "rustbox";
|
||
if (scripts.some((script) => script.name === targetScript)) await openSieveScript(targetScript);
|
||
else {
|
||
$("#sieve-name").value = targetScript;
|
||
$("#sieve-editor").value = "";
|
||
renderManagedSieveRules();
|
||
}
|
||
state.sieveEditingRuleId = null;
|
||
const suggestedName = config.kind === "subject" ? "subject_filter" : config.kind === "domain" ? "sender_domain" : "sender_filter";
|
||
$("#sieve-builder-name").value = suggestedName;
|
||
$("#sieve-builder-enabled").checked = true;
|
||
$("#sieve-builder-stop").checked = Boolean(config.stop);
|
||
$("#sieve-builder-condition").value = config.kind === "domain" ? "from-domain" : config.kind === "subject" ? "subject" : "from";
|
||
$("#sieve-builder-match").value = config.match;
|
||
$("#sieve-builder-value").value = config.value;
|
||
$("#sieve-builder-action").value = config.action === "flag" ? "add-flag" : config.action;
|
||
$("#sieve-builder-target").value = config.action === "flag" ? "\\Flagged" : config.target || "";
|
||
$("#sieve-builder-condition-2").value = "";
|
||
$("#sieve-builder-condition-3").value = "";
|
||
$("#sieve-builder-extra-conditions").replaceChildren();
|
||
$("#sieve-builder-action-2").value = config.markRead && config.action !== "mark-read" ? "mark-read" : "";
|
||
$("#sieve-builder-extra-actions").replaceChildren();
|
||
updateSieveBuilder();
|
||
$("#sieve-result").textContent = "Quick-filter values loaded. Review the rule, add it, validate, then save.";
|
||
showSieveRuleDialog("new");
|
||
}
|
||
|
||
async function createFilterFromCurrentMessage(kind = "sender") {
|
||
const detail = state.currentDetail;
|
||
if (!detail) return;
|
||
|
||
let sieveInfo;
|
||
let scripts;
|
||
try {
|
||
[sieveInfo, scripts] = await Promise.all([
|
||
request("/sieve/status", { method:"GET" }),
|
||
request("/sieve/scripts", { method:"GET" }),
|
||
]);
|
||
state.sieveInfo = sieveInfo;
|
||
state.sieveScripts = scripts.scripts || [];
|
||
} catch (error) {
|
||
toast(`ManageSieve unavailable: ${error.message}`, "error");
|
||
return;
|
||
}
|
||
|
||
const source = quickFilterSource(detail, kind);
|
||
const activeScript = state.sieveScripts.find((script) => script.active);
|
||
const defaultScript = activeScript?.name || state.sieveScripts.find((script) => script.name === "rustbox")?.name || "rustbox";
|
||
const defaultTarget = detectSpecialFolder("archive") || orderedFolders().find((folder) => folder.name !== (detail.folder || state.folder))?.name || "";
|
||
const extensions = new Set((sieveInfo.extensions || []).map((value) => String(value).toLowerCase()));
|
||
|
||
const dialog = el("dialog", "dialog quick-filter-dialog");
|
||
const form = el("form", "dialog-card quick-filter-card");
|
||
form.method = "dialog";
|
||
const header = el("header", "dialog-header");
|
||
header.append(el("strong", "", "Create filter from message"));
|
||
const close = el("button", "icon-button"); close.type = "button"; close.innerHTML = svgIcon("x"); close.setAttribute("aria-label", "Close");
|
||
header.append(close);
|
||
const body = el("div", "quick-filter-body");
|
||
const grid = el("div", "quick-filter-grid");
|
||
|
||
const makeField = (labelText, control) => { const label = el("label"); label.append(el("span", "", labelText), control); return label; };
|
||
const condition = document.createElement("select");
|
||
condition.add(new Option("Sender address", "sender"));
|
||
condition.add(new Option("Sender domain", "domain"));
|
||
condition.add(new Option("Subject", "subject"));
|
||
condition.value = source.kind;
|
||
const match = document.createElement("select");
|
||
match.add(new Option("is exactly", "is"));
|
||
match.add(new Option("contains", "contains"));
|
||
match.value = source.match;
|
||
const value = document.createElement("input"); value.value = source.value; value.required = true;
|
||
const action = document.createElement("select");
|
||
[
|
||
["Move to folder", "move", ["fileinto"]],
|
||
["Copy to folder", "copy", ["fileinto", "copy"]],
|
||
["Mark as read", "mark-read", ["imap4flags"]],
|
||
["Flag message", "flag", ["imap4flags"]],
|
||
["Discard", "discard", []],
|
||
["Keep", "keep", []],
|
||
].forEach(([label, actionValue, requires]) => {
|
||
const option = new Option(label, actionValue);
|
||
option.disabled = requires.some((requirement) => !extensions.has(requirement));
|
||
action.add(option);
|
||
});
|
||
if (action.options[0]?.disabled) action.value = [...action.options].find((option) => !option.disabled)?.value || "keep";
|
||
const target = document.createElement("select");
|
||
target.replaceChildren(...orderedFolders().map((folder) => new Option(folder.name, folder.name)));
|
||
target.value = defaultTarget;
|
||
const script = document.createElement("select");
|
||
const scriptNames = new Set(state.sieveScripts.map((item) => item.name));
|
||
for (const item of state.sieveScripts) script.add(new Option(`${item.name}${item.active ? " · active" : ""}`, item.name));
|
||
if (!scriptNames.has("rustbox")) script.add(new Option("rustbox · new", "rustbox"));
|
||
script.value = defaultScript;
|
||
const stop = document.createElement("input"); stop.type = "checkbox"; stop.checked = true;
|
||
const markRead = document.createElement("input"); markRead.type = "checkbox"; markRead.disabled = !extensions.has("imap4flags");
|
||
const activate = document.createElement("input"); activate.type = "checkbox"; activate.checked = !activeScript;
|
||
|
||
const targetField = makeField("Destination folder", target);
|
||
grid.append(
|
||
makeField("Match", condition), makeField("Comparison", match),
|
||
makeField("Value", value), makeField("Action", action),
|
||
targetField, makeField("Sieve script", script)
|
||
);
|
||
body.append(grid);
|
||
const toggles = el("div", "appearance-toggles quick-filter-toggles");
|
||
const stopLabel = el("label", "checkbox-row"); stopLabel.append(stop, el("span", "", "Stop processing after this rule"));
|
||
const readLabel = el("label", "checkbox-row"); readLabel.append(markRead, el("span", "", "Also mark matching mail as read"));
|
||
if (markRead.disabled) readLabel.title = "Server does not advertise imap4flags";
|
||
const activateLabel = el("label", "checkbox-row"); activateLabel.append(activate, el("span", "", "Activate selected script after saving"));
|
||
toggles.append(stopLabel, readLabel, activateLabel);
|
||
body.append(toggles);
|
||
const preview = el("div", "quick-filter-preview"); body.append(preview);
|
||
const hint = el("div", "muted quick-filter-hint"); body.append(hint);
|
||
|
||
const actions = el("footer", "dialog-actions");
|
||
const cancel = el("button", "button quiet", "Cancel"); cancel.type = "button";
|
||
const advanced = el("button", "button quiet", "Advanced editor"); advanced.type = "button";
|
||
const save = el("button", "button primary", "Create filter"); save.type = "submit";
|
||
actions.append(cancel, advanced, save);
|
||
form.append(header, body, actions); dialog.append(form); document.body.append(dialog);
|
||
|
||
const sourceValues = {
|
||
sender: quickFilterSource(detail, "sender"),
|
||
domain: quickFilterSource(detail, "domain"),
|
||
subject: quickFilterSource(detail, "subject"),
|
||
};
|
||
const update = () => {
|
||
const needsFolder = ["move", "copy"].includes(action.value);
|
||
targetField.classList.toggle("hidden", !needsFolder);
|
||
const requires = action.value === "copy" ? ["fileinto", "copy"] : action.value === "move" ? ["fileinto"] : ["mark-read", "flag"].includes(action.value) ? ["imap4flags"] : [];
|
||
const missing = requires.filter((item) => !extensions.has(item));
|
||
const conditionLabel = condition.value === "domain" ? "sender domain" : condition.value === "subject" ? "subject" : "sender";
|
||
const actionLabel = action.selectedOptions[0]?.textContent || action.value;
|
||
const displayValue = value.value.trim() || "…";
|
||
preview.textContent = `If ${conditionLabel} ${match.value === "contains" ? "contains" : "is"} “${displayValue}” → ${actionLabel}${needsFolder && target.value ? ` “${target.value}”` : ""}${markRead.checked && action.value !== "mark-read" ? " + mark as read" : ""}.`;
|
||
hint.textContent = missing.length
|
||
? `Server does not advertise: ${missing.join(", ")}`
|
||
: `Will add this rule to “${script.value}”${activate.checked ? " and activate the script" : ""}. It applies to new incoming mail; existing messages are not changed.`;
|
||
save.disabled = missing.length > 0;
|
||
};
|
||
condition.addEventListener("change", () => { const next = sourceValues[condition.value]; value.value = next.value; match.value = next.match; update(); });
|
||
match.addEventListener("change", update);
|
||
value.addEventListener("input", update);
|
||
action.addEventListener("change", update);
|
||
target.addEventListener("change", update);
|
||
script.addEventListener("change", update);
|
||
activate.addEventListener("change", update);
|
||
markRead.addEventListener("change", update);
|
||
|
||
let settled = false;
|
||
const cleanup = () => { if (settled) return; settled = true; if (dialog.open) dialog.close(); else dialog.remove(); };
|
||
close.addEventListener("click", cleanup);
|
||
cancel.addEventListener("click", cleanup);
|
||
dialog.addEventListener("cancel", (event) => { event.preventDefault(); cleanup(); });
|
||
dialog.addEventListener("close", () => dialog.remove(), { once:true });
|
||
advanced.addEventListener("click", async () => {
|
||
const config = { kind:condition.value, match:match.value, value:value.value.trim(), action:action.value, target:target.value, script:script.value, markRead:markRead.checked };
|
||
cleanup();
|
||
await prepareQuickFilterAdvanced(config);
|
||
});
|
||
form.addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
if (!form.reportValidity()) return;
|
||
setBusy(save, true);
|
||
try {
|
||
const config = { kind:condition.value, match:match.value, value:value.value.trim(), action:action.value, target:target.value, stop:stop.checked, markRead:markRead.checked };
|
||
const built = buildQuickFilterRule(config);
|
||
const existing = state.sieveScripts.find((item) => item.name === script.value);
|
||
let current = "";
|
||
if (existing) current = (await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method:"GET" })).content || "";
|
||
const content = mergeSieveRequirements([current.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
|
||
const validation = await request("/sieve/check", { method:"POST", body:JSON.stringify({ content }) });
|
||
if (!validation.valid) throw new Error(validation.error || "Filter validation failed");
|
||
await request(`/sieve/scripts/${encodeURIComponent(script.value)}`, { method:"PUT", body:JSON.stringify({ content }) });
|
||
if (activate.checked || existing?.active) await request(`/sieve/scripts/${encodeURIComponent(script.value)}/activate`, { method:"POST", body:"{}" });
|
||
cleanup();
|
||
toast(`Filter added to ${script.value}`, "success");
|
||
} catch (error) {
|
||
hint.textContent = error.message;
|
||
toast(error.message, "error");
|
||
} finally { setBusy(save, false); }
|
||
});
|
||
|
||
update();
|
||
dialog.showModal();
|
||
setTimeout(() => value.focus(), 20);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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"); }
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
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"]);
|
||
} 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 {
|
||
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 };
|
||
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();
|
||
toast(`Notification permission: ${result}`);
|
||
}
|
||
|
||
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 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;
|
||
}
|
||
}
|
||
|
||
async function loadSieve() {
|
||
const status = $("#sieve-status");
|
||
status.textContent = "Connecting…";
|
||
try {
|
||
const [info, scripts] = await Promise.all([request("/sieve/status", { method: "GET" }), request("/sieve/scripts", { method: "GET" })]);
|
||
status.textContent = `${info.host}:${info.port} · ${info.tlsMode} · ${info.implementation || "ManageSieve"}`;
|
||
state.sieveInfo = info;
|
||
state.sieveScripts = scripts.scripts || [];
|
||
renderSieveScripts();
|
||
const selectedName = $("#sieve-name")?.value.trim();
|
||
const target = state.sieveScripts.find((script) => script.name === selectedName)
|
||
|| state.sieveScripts.find((script) => script.active)
|
||
|| state.sieveScripts[0];
|
||
if (target) await openSieveScript(target.name);
|
||
else {
|
||
$("#sieve-editor").value = "";
|
||
clearSieveRuleBuilder();
|
||
updateSieveBuilder();
|
||
}
|
||
} catch (error) {
|
||
status.textContent = error.message;
|
||
toast(error.message, "error");
|
||
}
|
||
}
|
||
|
||
function sieveQuote(value) {
|
||
return String(value ?? "").replaceAll("\\", "\\\\").replaceAll('"', '\\"').replace(/[\r\n]+/g, " ");
|
||
}
|
||
|
||
function sieveBuilderRequirements(condition, action) {
|
||
const requirements = new Set();
|
||
if (["move", "copy"].includes(action)) requirements.add("fileinto");
|
||
if (["copy", "redirect-copy"].includes(action)) requirements.add("copy");
|
||
if (["mark-read", "add-flag", "set-flags"].includes(action)) requirements.add("imap4flags");
|
||
return [...requirements];
|
||
}
|
||
|
||
function mergeSieveRequirements(script, requirements) {
|
||
const found = new Set(requirements);
|
||
const without = String(script || "").replace(/^\s*require\s+(?:\[([^\]]*)\]|"([^"]+)")\s*;\s*$/gim, (_line, list, single) => {
|
||
const raw = single ? [`"${single}"`] : (list || "").split(",");
|
||
for (const item of raw) {
|
||
const match = item.match(/"([^"]+)"/);
|
||
if (match) found.add(match[1]);
|
||
}
|
||
return "";
|
||
}).trim();
|
||
const header = found.size ? `require [${[...found].sort().map((item) => `"${sieveQuote(item)}"`).join(",")}];` : "";
|
||
return [header, without].filter(Boolean).join("\n\n").trim() + "\n";
|
||
}
|
||
|
||
function sieveMatchLabel(match) {
|
||
return match === "is" ? "is exactly" : match === "matches" ? "matches pattern" : "contains";
|
||
}
|
||
|
||
function sieveConditionLabel(condition, header = "") {
|
||
if (condition === "from") return "From address";
|
||
if (condition === "from-domain") return "From domain";
|
||
if (condition === "to") return "To / Cc address";
|
||
if (condition === "header-from") return "From header";
|
||
if (condition === "header-to") return "To header";
|
||
if (condition === "subject") return "Subject";
|
||
if (condition === "list-id") return "List-ID";
|
||
return header || "Header";
|
||
}
|
||
|
||
function buildSieveCondition(suffix = "", required = true) {
|
||
const condition = $(`#sieve-builder-condition${suffix}`).value;
|
||
const value = $(`#sieve-builder-value${suffix}`).value.trim();
|
||
if (!condition || !value) {
|
||
if (required) throw new Error("Rule value is required");
|
||
return null;
|
||
}
|
||
const match = $(`#sieve-builder-match${suffix}`).value;
|
||
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
|
||
let test;
|
||
let header = "";
|
||
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
|
||
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
|
||
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
|
||
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
|
||
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
|
||
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
|
||
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
|
||
else {
|
||
header = $(`#sieve-builder-header${suffix}`).value.trim();
|
||
if (!/^[A-Za-z0-9-]+$/.test(header)) {
|
||
const label = suffix === "-3" ? "Third custom" : suffix === "-2" ? "Second custom" : "Custom";
|
||
throw new Error(`${label} header name is invalid`);
|
||
}
|
||
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
|
||
}
|
||
const negate = $(`#sieve-builder-negate${suffix}`).checked;
|
||
if (negate) test = `not ${test}`;
|
||
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”`;
|
||
return { test, condition, match, value, header, negate, summary };
|
||
}
|
||
|
||
function sieveFlagValues(target) {
|
||
return String(target || "").split(",").map((value) => value.trim()).filter(Boolean);
|
||
}
|
||
|
||
function buildSieveActionFromValues(action, target, label = "Action") {
|
||
let command;
|
||
let summary;
|
||
if (action === "move") {
|
||
if (!target) throw new Error(`${label} target folder is required`);
|
||
command = `fileinto "${sieveQuote(target)}";`;
|
||
summary = `Move to “${target}”`;
|
||
} else if (action === "copy") {
|
||
if (!target) throw new Error(`${label} target folder is required`);
|
||
command = `fileinto :copy "${sieveQuote(target)}";`;
|
||
summary = `Copy to “${target}”`;
|
||
} else if (action === "redirect" || action === "redirect-copy") {
|
||
if (!target || !target.includes("@")) throw new Error(`${label} redirect address is required`);
|
||
command = `redirect${action === "redirect-copy" ? " :copy" : ""} "${sieveQuote(target)}";`;
|
||
summary = action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}`;
|
||
} else if (action === "discard") {
|
||
command = "discard;";
|
||
summary = "Discard the message";
|
||
} else if (action === "mark-read") {
|
||
command = 'addflag "\\Seen";';
|
||
summary = "Mark as read";
|
||
} else if (action === "add-flag") {
|
||
if (!target) throw new Error(`${label} IMAP flag is required`);
|
||
command = `addflag "${sieveQuote(target)}";`;
|
||
summary = `Add IMAP flag ${target}`;
|
||
} else if (action === "set-flags") {
|
||
const flags = sieveFlagValues(target);
|
||
if (!flags.length) throw new Error(`${label} IMAP flags are required`);
|
||
command = `setflag [${flags.map((flag) => `"${sieveQuote(flag)}"`).join(",")}];`;
|
||
summary = `Set IMAP flags ${flags.join(", ")}`;
|
||
} else {
|
||
command = "keep;";
|
||
summary = "Keep in the inbox";
|
||
}
|
||
return { action, target, command, summary, requirements: sieveBuilderRequirements("", action) };
|
||
}
|
||
|
||
function buildSieveAction(suffix = "", required = true) {
|
||
const action = $(`#sieve-builder-action${suffix}`).value;
|
||
const target = $(`#sieve-builder-target${suffix}`)?.value.trim() || "";
|
||
if (!action) {
|
||
if (required) throw new Error("Action is required");
|
||
return null;
|
||
}
|
||
return buildSieveActionFromValues(action, target, suffix ? "Second action" : "Action");
|
||
}
|
||
|
||
function newSieveVisualRuleId() {
|
||
const random = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
return `rule-${random.replace(/[^A-Za-z0-9_-]/g, "")}`;
|
||
}
|
||
|
||
function sieveUnescape(value) {
|
||
return String(value || "").replace(/\\([\\"])/g, "$1");
|
||
}
|
||
|
||
function sieveQuotedValues(value) {
|
||
const values = [];
|
||
const pattern = /"((?:\\.|[^"\\])*)"/g;
|
||
let match;
|
||
while ((match = pattern.exec(String(value || "")))) values.push(sieveUnescape(match[1]));
|
||
return values;
|
||
}
|
||
|
||
function splitSieveTopLevel(value) {
|
||
const result = [];
|
||
let start = 0;
|
||
let depth = 0;
|
||
let bracket = 0;
|
||
let quoted = false;
|
||
let escaped = false;
|
||
const text = String(value || "");
|
||
for (let index = 0; index < text.length; index += 1) {
|
||
const char = text[index];
|
||
if (quoted) {
|
||
if (escaped) escaped = false;
|
||
else if (char === "\\") escaped = true;
|
||
else if (char === '"') quoted = false;
|
||
continue;
|
||
}
|
||
if (char === '"') { quoted = true; continue; }
|
||
if (char === "(") depth += 1;
|
||
else if (char === ")") depth = Math.max(0, depth - 1);
|
||
else if (char === "[") bracket += 1;
|
||
else if (char === "]") bracket = Math.max(0, bracket - 1);
|
||
else if (char === "," && depth === 0 && bracket === 0) {
|
||
result.push(text.slice(start, index).trim());
|
||
start = index + 1;
|
||
}
|
||
}
|
||
result.push(text.slice(start).trim());
|
||
return result.filter(Boolean);
|
||
}
|
||
|
||
function splitSieveStatements(value) {
|
||
const result = [];
|
||
let start = 0;
|
||
let quoted = false;
|
||
let escaped = false;
|
||
const text = String(value || "");
|
||
for (let index = 0; index < text.length; index += 1) {
|
||
const char = text[index];
|
||
if (quoted) {
|
||
if (escaped) escaped = false;
|
||
else if (char === "\\") escaped = true;
|
||
else if (char === '"') quoted = false;
|
||
continue;
|
||
}
|
||
if (char === '"') { quoted = true; continue; }
|
||
if (char === "#") {
|
||
while (index < text.length && text[index] !== "\n") index += 1;
|
||
continue;
|
||
}
|
||
if (char === ";") {
|
||
const statement = text.slice(start, index + 1).trim();
|
||
if (statement) result.push(statement);
|
||
start = index + 1;
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function findSieveClosingBrace(source, openIndex) {
|
||
let depth = 0;
|
||
let quoted = false;
|
||
let escaped = false;
|
||
for (let index = openIndex; index < source.length; index += 1) {
|
||
const char = source[index];
|
||
if (quoted) {
|
||
if (escaped) escaped = false;
|
||
else if (char === "\\") escaped = true;
|
||
else if (char === '"') quoted = false;
|
||
continue;
|
||
}
|
||
if (char === '"') { quoted = true; continue; }
|
||
if (char === "#") {
|
||
while (index < source.length && source[index] !== "\n") index += 1;
|
||
continue;
|
||
}
|
||
if (char === "{") depth += 1;
|
||
else if (char === "}") {
|
||
depth -= 1;
|
||
if (depth === 0) return index;
|
||
}
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
function parseSieveConditionToken(value) {
|
||
let raw = String(value || "").trim();
|
||
const negate = /^not\s+/i.test(raw);
|
||
if (negate) raw = raw.replace(/^not\s+/i, "").trim();
|
||
const match = raw.match(/^(header|address)\s+(?:(:domain)\s+)?:(contains|is|matches)\s+([\s\S]+)$/i);
|
||
if (!match) return { raw:value, supported:false, summary:String(value || "").trim() || "Unsupported condition" };
|
||
const values = sieveQuotedValues(match[4]);
|
||
if (values.length < 2) return { raw:value, supported:false, summary:String(value || "").trim() };
|
||
const targetValue = values.at(-1);
|
||
const fields = values.slice(0, -1).map((item) => item.toLowerCase());
|
||
const primaryField = fields[0] || "";
|
||
let kind = "header";
|
||
let header = primaryField;
|
||
const testType = match[1].toLowerCase();
|
||
if (testType === "address" && primaryField === "from") kind = match[2] ? "from-domain" : "from";
|
||
else if (testType === "address" && (fields.includes("to") || fields.includes("cc"))) kind = "to";
|
||
else if (testType === "header" && primaryField === "from") kind = "header-from";
|
||
else if (testType === "header" && primaryField === "to" && fields.length === 1) kind = "header-to";
|
||
else if (primaryField === "subject") kind = "subject";
|
||
else if (primaryField === "list-id") kind = "list-id";
|
||
const comparator = match[3].toLowerCase();
|
||
const summary = `${negate ? "NOT " : ""}${sieveConditionLabel(kind, header)} ${sieveMatchLabel(comparator)} “${targetValue}”`;
|
||
return { raw:value, supported:true, kind, header, match:comparator, value:targetValue, negate, summary };
|
||
}
|
||
|
||
function parseSieveConditionExpression(value) {
|
||
const raw = String(value || "").trim();
|
||
const group = raw.match(/^(allof|anyof)\s*\(([\s\S]*)\)$/i);
|
||
if (!group) {
|
||
const condition = parseSieveConditionToken(raw);
|
||
return { logic:"allof", conditions:[condition], supported:condition.supported, mixed:false, summary:condition.summary };
|
||
}
|
||
const logic = group[1].toLowerCase();
|
||
const parts = splitSieveTopLevel(group[2]);
|
||
const conditions = [];
|
||
let supported = true;
|
||
let mixed = false;
|
||
const summaries = [];
|
||
for (const part of parts) {
|
||
const parsed = parseSieveConditionExpression(part);
|
||
if (parsed.conditions.length > 1 && parsed.logic !== logic) mixed = true;
|
||
conditions.push(...parsed.conditions);
|
||
supported = supported && parsed.supported;
|
||
mixed = mixed || parsed.mixed;
|
||
summaries.push(parsed.summary);
|
||
}
|
||
return { logic, conditions, supported, mixed, summary:summaries.join(logic === "anyof" ? " OR " : " AND ") };
|
||
}
|
||
|
||
function parseSieveActionStatement(statement) {
|
||
const raw = String(statement || "").trim().replace(/;\s*$/, "");
|
||
let match = raw.match(/^fileinto\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
|
||
if (match) {
|
||
const target = sieveUnescape(match[2]);
|
||
const action = match[1] ? "copy" : "move";
|
||
return { action, target, supported:true, summary:action === "copy" ? `Copy to “${target}”` : `Move to “${target}”` };
|
||
}
|
||
match = raw.match(/^redirect\s+(:copy\s+)?"((?:\\.|[^"\\])*)"$/i);
|
||
if (match) {
|
||
const target = sieveUnescape(match[2]);
|
||
const action = match[1] ? "redirect-copy" : "redirect";
|
||
return { action, target, supported:true, summary:action === "redirect-copy" ? `Redirect a copy to ${target}` : `Redirect to ${target}` };
|
||
}
|
||
match = raw.match(/^addflag\s+([\s\S]+)$/i);
|
||
if (match) {
|
||
const flags = sieveQuotedValues(match[1]);
|
||
const target = flags.join(", ");
|
||
const markRead = flags.length === 1 && flags[0] === "\\Seen";
|
||
return { action:markRead ? "mark-read" : "add-flag", target:markRead ? "" : target, supported:Boolean(flags.length), summary:markRead ? "Mark as read" : `Add IMAP flag ${target}` };
|
||
}
|
||
match = raw.match(/^setflag\s+([\s\S]+)$/i);
|
||
if (match) {
|
||
const flags = sieveQuotedValues(match[1]);
|
||
const target = flags.join(", ");
|
||
return { action:"set-flags", target, supported:Boolean(flags.length), summary:`Set IMAP flags ${target}` };
|
||
}
|
||
if (/^discard$/i.test(raw)) return { action:"discard", target:"", supported:true, summary:"Discard the message" };
|
||
if (/^keep$/i.test(raw)) return { action:"keep", target:"", supported:true, summary:"Keep in the inbox" };
|
||
if (/^stop$/i.test(raw)) return { action:"stop", target:"", supported:true, stop:true, summary:"Stop processing more rules" };
|
||
return { action:"unsupported", target:"", supported:false, summary:raw || "Unsupported action", raw };
|
||
}
|
||
|
||
function parseSieveRules(script = $("#sieve-editor")?.value || "") {
|
||
const source = String(script || "");
|
||
const markerPattern = /^\s*#\s*(?:rule:\[([^\]\r\n]+)\]|RUSTBOX-RULE-BEGIN\s+([A-Za-z0-9_-]+))\s*$/gim;
|
||
const markers = [];
|
||
let marker;
|
||
while ((marker = markerPattern.exec(source))) markers.push({ index:marker.index, end:markerPattern.lastIndex, name:marker[1]?.trim() || "", rustboxId:marker[2] || "" });
|
||
const rules = [];
|
||
const nameCounts = new Map();
|
||
for (let markerIndex = 0; markerIndex < markers.length; markerIndex += 1) {
|
||
const info = markers[markerIndex];
|
||
const boundary = markers[markerIndex + 1]?.index ?? source.length;
|
||
const segment = source.slice(info.end, boundary);
|
||
const ifMatch = /\bif\s+([\s\S]*?)\{/i.exec(segment);
|
||
if (!ifMatch) continue;
|
||
const openBrace = info.end + ifMatch.index + ifMatch[0].lastIndexOf("{");
|
||
const closeBrace = findSieveClosingBrace(source, openBrace);
|
||
if (closeBrace < 0 || closeBrace >= boundary) continue;
|
||
const conditionOffset = ifMatch[0].indexOf(ifMatch[1]);
|
||
const conditionStart = info.end + ifMatch.index + conditionOffset;
|
||
const conditionEnd = conditionStart + ifMatch[1].length;
|
||
const rawCondition = ifMatch[1].trim();
|
||
const disabledMatch = rawCondition.match(/^false\s*#\s*([\s\S]+)$/i);
|
||
const enabled = !disabledMatch;
|
||
const actualCondition = (disabledMatch ? disabledMatch[1] : rawCondition).trim();
|
||
const condition = parseSieveConditionExpression(actualCondition);
|
||
const body = source.slice(openBrace + 1, closeBrace);
|
||
const parsedActions = splitSieveStatements(body).map(parseSieveActionStatement);
|
||
const stop = parsedActions.some((action) => action.stop);
|
||
const actions = parsedActions.filter((action) => !action.stop);
|
||
let end = closeBrace + 1;
|
||
if (info.rustboxId) {
|
||
const tail = source.slice(end, boundary);
|
||
const endMarker = new RegExp(`^\\s*#\\s*RUSTBOX-RULE-END\\s*$`, "im").exec(tail);
|
||
if (endMarker) end += endMarker.index + endMarker[0].length;
|
||
}
|
||
const fallbackName = info.rustboxId ? `RustBox ${info.rustboxId.replace(/^rule-/, "").slice(0, 12)}` : `Rule ${rules.length + 1}`;
|
||
const name = info.name || fallbackName;
|
||
const count = (nameCounts.get(name) || 0) + 1;
|
||
nameCounts.set(name, count);
|
||
const id = `${info.rustboxId ? "rustbox" : "named"}:${name}:${count}`;
|
||
const editable = condition.supported && !condition.mixed && actions.every((action) => action.supported);
|
||
rules.push({
|
||
id, name, enabled, start:info.index, end, full:source.slice(info.index, end), rawCondition, actualCondition,
|
||
conditionStart, conditionEnd, condition, actions, stop, editable, rustboxId:info.rustboxId,
|
||
});
|
||
}
|
||
return rules;
|
||
}
|
||
|
||
function setSieveConditionControls(suffix, condition) {
|
||
const kind = condition?.kind || "";
|
||
$(`#sieve-builder-condition${suffix}`).value = kind;
|
||
$(`#sieve-builder-match${suffix}`).value = condition?.match || "contains";
|
||
$(`#sieve-builder-value${suffix}`).value = condition?.value || "";
|
||
$(`#sieve-builder-negate${suffix}`).checked = Boolean(condition?.negate);
|
||
const header = $(`#sieve-builder-header${suffix}`);
|
||
if (header) header.value = condition?.header || "";
|
||
}
|
||
|
||
function setSieveDynamicCondition(row, condition, logic) {
|
||
row.querySelector(".sieve-dynamic-logic").value = logic === "anyof" ? "anyof" : "allof";
|
||
row.querySelector(".sieve-dynamic-kind").value = condition.kind || "from";
|
||
row.querySelector(".sieve-dynamic-match").value = condition.match || "contains";
|
||
row.querySelector(".sieve-dynamic-header").value = condition.header || "";
|
||
row.querySelector(".sieve-dynamic-value").value = condition.value || "";
|
||
row.querySelector(".sieve-dynamic-negate").checked = Boolean(condition.negate);
|
||
}
|
||
|
||
function setSieveActionControls(suffix, action) {
|
||
$(`#sieve-builder-action${suffix}`).value = action?.action || (suffix ? "" : "move");
|
||
const target = $(`#sieve-builder-target${suffix}`);
|
||
if (target) target.value = action?.target || "";
|
||
}
|
||
|
||
function setSieveDynamicAction(row, action) {
|
||
row.querySelector(".sieve-dynamic-action").value = action.action || "keep";
|
||
row.querySelector(".sieve-dynamic-target").value = action.target || "";
|
||
configureSieveDynamicAction(row);
|
||
}
|
||
|
||
function showSieveRuleDialog(mode = "new", ruleName = "") {
|
||
const dialog = $("#sieve-rule-dialog");
|
||
if (!dialog) return;
|
||
$("#sieve-rule-dialog-title").textContent = mode === "edit" ? `Edit filter · ${ruleName}` : "Create filter";
|
||
$("#sieve-rule-dialog-subtitle").textContent = mode === "edit"
|
||
? "Change the rule visually; the live preview shows exactly what will be written to Sieve."
|
||
: "Build the rule visually and verify the preview before applying it.";
|
||
if (!dialog.open) dialog.showModal();
|
||
}
|
||
|
||
function openNewSieveRuleDialog() {
|
||
clearSieveRuleBuilder();
|
||
showSieveRuleDialog("new");
|
||
setTimeout(() => $("#sieve-builder-name")?.focus(), 20);
|
||
}
|
||
|
||
function loadSieveRuleIntoBuilder(rule) {
|
||
if (!rule?.editable) return toast("This rule uses Sieve syntax the visual editor cannot safely rewrite. Edit it in Advanced Sieve source.", "warning");
|
||
state.sieveEditingRuleId = rule.id;
|
||
$("#sieve-builder-name").value = rule.name;
|
||
$("#sieve-builder-enabled").checked = rule.enabled;
|
||
$("#sieve-builder-stop").checked = rule.stop;
|
||
$("#sieve-builder-extra-conditions").replaceChildren();
|
||
$("#sieve-builder-extra-actions").replaceChildren();
|
||
const conditions = rule.condition.conditions;
|
||
setSieveConditionControls("", conditions[0]);
|
||
setSieveConditionControls("-2", conditions[1] || null);
|
||
setSieveConditionControls("-3", conditions[2] || null);
|
||
$("#sieve-builder-logic").value = rule.condition.logic === "anyof" ? "anyof" : "allof";
|
||
$("#sieve-builder-logic-3").value = rule.condition.logic === "anyof" ? "anyof" : "allof";
|
||
for (const condition of conditions.slice(3)) {
|
||
const row = createSieveDynamicCondition();
|
||
setSieveDynamicCondition(row, condition, rule.condition.logic);
|
||
$("#sieve-builder-extra-conditions").append(row);
|
||
}
|
||
setSieveActionControls("", rule.actions[0] || { action:"keep", target:"" });
|
||
setSieveActionControls("-2", rule.actions[1] || null);
|
||
for (const action of rule.actions.slice(2)) {
|
||
const row = createSieveDynamicAction();
|
||
setSieveDynamicAction(row, action);
|
||
$("#sieve-builder-extra-actions").append(row);
|
||
}
|
||
$("#sieve-builder-replace").disabled = false;
|
||
updateSieveBuilder();
|
||
renderManagedSieveRules();
|
||
showSieveRuleDialog("edit", rule.name);
|
||
setTimeout(() => $("#sieve-builder-name")?.focus(), 20);
|
||
}
|
||
|
||
function clearSieveRuleBuilder() {
|
||
state.sieveEditingRuleId = null;
|
||
$("#sieve-builder-name").value = "";
|
||
$("#sieve-builder-enabled").checked = true;
|
||
$("#sieve-builder-stop").checked = true;
|
||
$("#sieve-builder-condition").value = "from";
|
||
$("#sieve-builder-match").value = "contains";
|
||
$("#sieve-builder-value").value = "";
|
||
$("#sieve-builder-header").value = "";
|
||
$("#sieve-builder-negate").checked = false;
|
||
for (const suffix of ["-2", "-3"]) {
|
||
$(`#sieve-builder-condition${suffix}`).value = "";
|
||
$(`#sieve-builder-match${suffix}`).value = "contains";
|
||
$(`#sieve-builder-value${suffix}`).value = "";
|
||
$(`#sieve-builder-header${suffix}`).value = "";
|
||
$(`#sieve-builder-negate${suffix}`).checked = false;
|
||
}
|
||
$("#sieve-builder-logic").value = "allof";
|
||
$("#sieve-builder-logic-3").value = "allof";
|
||
$("#sieve-builder-extra-conditions").replaceChildren();
|
||
$("#sieve-builder-action").value = "move";
|
||
$("#sieve-builder-target").value = "";
|
||
$("#sieve-builder-action-2").value = "";
|
||
$("#sieve-builder-target-2").value = "";
|
||
$("#sieve-builder-extra-actions").replaceChildren();
|
||
$("#sieve-builder-replace").disabled = true;
|
||
updateSieveBuilder();
|
||
renderManagedSieveRules();
|
||
if ($("#sieve-rule-dialog")?.open) {
|
||
$("#sieve-rule-dialog-title").textContent = "Create filter";
|
||
$("#sieve-rule-dialog-subtitle").textContent = "Build the rule visually and verify the preview before applying it.";
|
||
}
|
||
}
|
||
|
||
function moveParsedSieveRule(ruleId, delta) {
|
||
const editor = $("#sieve-editor");
|
||
const rules = parseSieveRules(editor.value);
|
||
const index = rules.findIndex((rule) => rule.id === ruleId);
|
||
const target = index + delta;
|
||
if (index < 0 || target < 0 || target >= rules.length) return;
|
||
const first = delta > 0 ? rules[index] : rules[target];
|
||
const second = delta > 0 ? rules[target] : rules[index];
|
||
const separator = editor.value.slice(first.end, second.start);
|
||
const replacement = second.full + separator + first.full;
|
||
editor.value = editor.value.slice(0, first.start) + replacement + editor.value.slice(second.end);
|
||
state.sieveEditingRuleId = null;
|
||
renderManagedSieveRules();
|
||
$("#sieve-result").textContent = "Rule order changed — save to apply";
|
||
}
|
||
|
||
function toggleSieveRuleEnabled(rule) {
|
||
const editor = $("#sieve-editor");
|
||
const source = editor.value;
|
||
const replacement = rule.enabled ? `false # ${rule.actualCondition}` : rule.actualCondition;
|
||
editor.value = source.slice(0, rule.conditionStart) + replacement + source.slice(rule.conditionEnd);
|
||
state.sieveEditingRuleId = null;
|
||
renderManagedSieveRules();
|
||
$("#sieve-result").textContent = `${rule.name} ${rule.enabled ? "disabled" : "enabled"} — save to apply`;
|
||
}
|
||
|
||
async function deleteSieveRule(rule) {
|
||
if (!await appConfirm(`Delete Sieve rule “${rule.name}”?`, { title:"Delete filter rule", confirmLabel:"Delete", danger:true })) return;
|
||
const editor = $("#sieve-editor");
|
||
editor.value = `${editor.value.slice(0, rule.start).trimEnd()}\n\n${editor.value.slice(rule.end).trimStart()}`.trim() + "\n";
|
||
state.sieveEditingRuleId = null;
|
||
clearSieveRuleBuilder();
|
||
renderManagedSieveRules();
|
||
$("#sieve-result").textContent = `Rule “${rule.name}” removed — save to apply`;
|
||
}
|
||
|
||
function renderManagedSieveRules() {
|
||
const list = $("#sieve-managed-rules");
|
||
if (!list) return;
|
||
const rules = parseSieveRules();
|
||
const count = $("#sieve-rule-count");
|
||
if (count) count.textContent = String(rules.length);
|
||
if (state.sieveEditingRuleId && !rules.some((rule) => rule.id === state.sieveEditingRuleId)) {
|
||
state.sieveEditingRuleId = null;
|
||
const update = $("#sieve-builder-replace");
|
||
if (update) update.disabled = true;
|
||
}
|
||
list.replaceChildren();
|
||
if (!rules.length) {
|
||
list.append(el("div", "muted", "No named rules found. Rules using # rule:[name] are parsed automatically."));
|
||
return;
|
||
}
|
||
rules.forEach((rule, index) => {
|
||
const item = el("article", `sieve-managed-rule${rule.enabled ? "" : " disabled"}${rule.editable ? "" : " unsupported"}${state.sieveEditingRuleId === rule.id ? " selected" : ""}`);
|
||
const main = el("div", "sieve-managed-rule-main");
|
||
const title = el("div", "sieve-managed-rule-title");
|
||
title.append(el("strong", "", rule.name), el("span", `sieve-rule-state ${rule.enabled ? "enabled" : ""}`, rule.enabled ? "ON" : "OFF"));
|
||
const summary = el("div", "sieve-managed-rule-summary");
|
||
const when = el("div"); when.innerHTML = `<b>When:</b> ${escapeHtml(rule.condition.summary || rule.actualCondition)}`;
|
||
const actionText = rule.actions.length ? rule.actions.map((action) => action.summary).join("; ") : "No action parsed";
|
||
const then = el("div"); then.innerHTML = `<b>Then:</b> ${escapeHtml(actionText)}${rule.stop ? "; stop processing more rules" : ""}`;
|
||
summary.append(when, then);
|
||
main.append(title, summary);
|
||
if (!rule.editable) main.append(el("div", "sieve-rule-warning", "Uses syntax not fully supported by the visual editor; source is preserved unchanged."));
|
||
const actions = el("div", "sieve-managed-rule-actions");
|
||
const edit = el("button", "button quiet small", "Edit"); edit.type="button"; edit.disabled=!rule.editable; edit.addEventListener("click", () => loadSieveRuleIntoBuilder(rule));
|
||
const toggle = el("button", "button quiet small", rule.enabled ? "Disable" : "Enable"); toggle.type="button"; toggle.addEventListener("click", () => toggleSieveRuleEnabled(rule));
|
||
const up = el("button", "button quiet small", "↑"); up.type="button"; up.title="Move rule up"; up.disabled=index===0; up.addEventListener("click", () => moveParsedSieveRule(rule.id, -1));
|
||
const down = el("button", "button quiet small", "↓"); down.type="button"; down.title="Move rule down"; down.disabled=index===rules.length-1; down.addEventListener("click", () => moveParsedSieveRule(rule.id, 1));
|
||
const remove = el("button", "button quiet small danger", "Delete"); remove.type="button"; remove.addEventListener("click", () => deleteSieveRule(rule));
|
||
actions.append(edit, toggle, up, down, remove);
|
||
item.append(main, actions);
|
||
list.append(item);
|
||
});
|
||
}
|
||
|
||
function createSieveDynamicCondition() {
|
||
const row = el("div", "sieve-dynamic-row sieve-dynamic-condition");
|
||
row.innerHTML = `<label><span>Join</span><select class="sieve-dynamic-logic"><option value="allof">AND</option><option value="anyof">OR</option></select></label><label><span>Condition</span><select class="sieve-dynamic-kind"><option value="from">From address</option><option value="from-domain">From domain</option><option value="to">To / Cc address</option><option value="header-from">From header</option><option value="header-to">To header</option><option value="subject">Subject</option><option value="list-id">List-ID</option><option value="header">Custom header</option></select></label><label><span>Match</span><select class="sieve-dynamic-match"><option value="contains">contains</option><option value="is">is exactly</option><option value="matches">wildcard matches</option></select></label><label class="sieve-dynamic-header-wrap hidden"><span>Header name</span><input class="sieve-dynamic-header" placeholder="X-Spam-Status"></label><label><span>Value</span><input class="sieve-dynamic-value" placeholder="value"></label><label class="checkbox-row"><input class="sieve-dynamic-negate" type="checkbox"><span>NOT / negate</span></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
|
||
const refresh = () => {
|
||
const kind = row.querySelector(".sieve-dynamic-kind").value;
|
||
row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", kind !== "header");
|
||
updateSieveBuilder();
|
||
};
|
||
row.querySelector(".sieve-dynamic-kind").addEventListener("change", refresh);
|
||
row.querySelectorAll("select,input").forEach((node) => node.addEventListener("input", updateSieveBuilder));
|
||
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
|
||
return row;
|
||
}
|
||
|
||
function buildSieveDynamicCondition(row) {
|
||
const condition = row.querySelector(".sieve-dynamic-kind").value;
|
||
const value = row.querySelector(".sieve-dynamic-value").value.trim();
|
||
if (!condition || !value) throw new Error("Every additional condition needs a value");
|
||
const match = row.querySelector(".sieve-dynamic-match").value;
|
||
const matchToken = match === "is" ? ":is" : match === "matches" ? ":matches" : ":contains";
|
||
let test;
|
||
let header = "";
|
||
if (condition === "from") test = `address ${matchToken} "from" "${sieveQuote(value)}"`;
|
||
else if (condition === "from-domain") test = `address :domain ${matchToken} "from" "${sieveQuote(value)}"`;
|
||
else if (condition === "to") test = `address ${matchToken} ["to", "cc"] "${sieveQuote(value)}"`;
|
||
else if (condition === "header-from") test = `header ${matchToken} "from" "${sieveQuote(value)}"`;
|
||
else if (condition === "header-to") test = `header ${matchToken} "to" "${sieveQuote(value)}"`;
|
||
else if (condition === "subject") test = `header ${matchToken} "subject" "${sieveQuote(value)}"`;
|
||
else if (condition === "list-id") test = `header ${matchToken} "list-id" "${sieveQuote(value)}"`;
|
||
else {
|
||
header = row.querySelector(".sieve-dynamic-header").value.trim();
|
||
if (!/^[A-Za-z0-9-]+$/.test(header)) throw new Error("Additional custom header name is invalid");
|
||
test = `header ${matchToken} "${sieveQuote(header)}" "${sieveQuote(value)}"`;
|
||
}
|
||
const negate = row.querySelector(".sieve-dynamic-negate").checked;
|
||
if (negate) test = `not ${test}`;
|
||
return { test, condition, match, value, header, negate, logic:row.querySelector(".sieve-dynamic-logic").value === "anyof" ? "anyof" : "allof", summary:`${negate ? "NOT " : ""}${sieveConditionLabel(condition, header)} ${sieveMatchLabel(match)} “${value}”` };
|
||
}
|
||
|
||
function createSieveDynamicAction() {
|
||
const row = el("div", "sieve-dynamic-row sieve-dynamic-action-row");
|
||
row.innerHTML = `<label><span>Action</span><select class="sieve-dynamic-action"><option value="move">Move to folder</option><option value="copy">Copy to folder</option><option value="redirect">Redirect</option><option value="redirect-copy">Redirect and keep copy</option><option value="discard">Discard</option><option value="mark-read">Mark read</option><option value="add-flag">Add IMAP flag</option><option value="set-flags">Set IMAP flags</option><option value="keep">Keep</option></select></label><label class="sieve-dynamic-target-wrap"><span class="sieve-dynamic-target-label">Folder</span><input class="sieve-dynamic-target" list="sieve-folder-options" placeholder="Archive"></label><button class="button quiet small sieve-dynamic-remove" type="button">Remove</button>`;
|
||
const refresh = () => { configureSieveDynamicAction(row); updateSieveBuilder(); };
|
||
row.querySelector(".sieve-dynamic-action").addEventListener("change", refresh);
|
||
row.querySelector(".sieve-dynamic-target").addEventListener("input", updateSieveBuilder);
|
||
row.querySelector(".sieve-dynamic-remove").addEventListener("click", () => { row.remove(); updateSieveBuilder(); });
|
||
configureSieveDynamicAction(row);
|
||
return row;
|
||
}
|
||
|
||
function configureSieveDynamicAction(row) {
|
||
const action = row.querySelector(".sieve-dynamic-action").value;
|
||
const wrap = row.querySelector(".sieve-dynamic-target-wrap");
|
||
const label = row.querySelector(".sieve-dynamic-target-label");
|
||
const target = row.querySelector(".sieve-dynamic-target");
|
||
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
|
||
wrap.classList.toggle("hidden", !needsTarget);
|
||
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
|
||
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
|
||
target.removeAttribute("list");
|
||
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
|
||
}
|
||
|
||
function buildSieveDynamicAction(row) {
|
||
return buildSieveActionFromValues(row.querySelector(".sieve-dynamic-action").value, row.querySelector(".sieve-dynamic-target").value.trim(), "Additional action");
|
||
}
|
||
|
||
function cleanSieveRuleName(value) {
|
||
const name = String(value || "").trim();
|
||
if (!name) throw new Error("Rule name is required");
|
||
if (/[\]\r\n]/.test(name)) throw new Error("Rule name cannot contain ] or a line break");
|
||
return name;
|
||
}
|
||
|
||
function buildSieveVisualRule() {
|
||
const name = cleanSieveRuleName($("#sieve-builder-name").value);
|
||
const enabled = $("#sieve-builder-enabled").checked;
|
||
const primary = buildSieveCondition("", true);
|
||
const secondary = buildSieveCondition("-2", false);
|
||
const tertiary = buildSieveCondition("-3", false);
|
||
let test = primary.test;
|
||
let summary = primary.summary;
|
||
if (secondary) {
|
||
const logic = $("#sieve-builder-logic").value === "anyof" ? "anyof" : "allof";
|
||
test = `${logic} (${test}, ${secondary.test})`;
|
||
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${secondary.summary}`;
|
||
}
|
||
if (tertiary) {
|
||
const logic = $("#sieve-builder-logic-3").value === "anyof" ? "anyof" : "allof";
|
||
test = `${logic} (${test}, ${tertiary.test})`;
|
||
summary = `${summary} ${logic === "anyof" ? "OR" : "AND"} ${tertiary.summary}`;
|
||
}
|
||
for (const row of $$("#sieve-builder-extra-conditions .sieve-dynamic-condition")) {
|
||
const extra = buildSieveDynamicCondition(row);
|
||
test = `${extra.logic} (${test}, ${extra.test})`;
|
||
summary = `${summary} ${extra.logic === "anyof" ? "OR" : "AND"} ${extra.summary}`;
|
||
}
|
||
|
||
const actions = [buildSieveAction("", true), buildSieveAction("-2", false), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").map(buildSieveDynamicAction)].filter(Boolean);
|
||
const commands = actions.map((item) => item.command);
|
||
const stop = $("#sieve-builder-stop").checked;
|
||
if (stop) commands.push("stop;");
|
||
const requirements = new Set();
|
||
for (const item of actions) for (const requirement of item.requirements) requirements.add(requirement);
|
||
const conditionCode = enabled ? test : `false # ${test}`;
|
||
return {
|
||
name, enabled, stop, requirements:[...requirements], conditionSummary:summary,
|
||
actionSummary:actions.map((item) => item.summary).join("; ") + (stop ? "; stop processing more rules" : ""),
|
||
rule:`# rule:[${name}]\nif ${conditionCode}\n{\n\t${commands.join("\n\t")}\n}`,
|
||
};
|
||
}
|
||
|
||
function configureSieveActionFields(suffix = "") {
|
||
const action = $(`#sieve-builder-action${suffix}`).value;
|
||
const wrap = $(`#sieve-builder-target-wrap${suffix}`);
|
||
const label = $(`#sieve-builder-target-label${suffix}`);
|
||
const target = $(`#sieve-builder-target${suffix}`);
|
||
const needsTarget = ["move", "copy", "redirect", "redirect-copy", "add-flag", "set-flags"].includes(action);
|
||
wrap?.classList.toggle("hidden", !needsTarget);
|
||
if (!target || !label) return;
|
||
label.textContent = ["redirect", "redirect-copy"].includes(action) ? "Address" : ["add-flag", "set-flags"].includes(action) ? "IMAP flag(s)" : "Folder";
|
||
target.placeholder = ["redirect", "redirect-copy"].includes(action) ? "user@example.com" : action === "set-flags" ? "\\Seen, \\Flagged" : action === "add-flag" ? "$Label1" : "Archive";
|
||
target.removeAttribute("list");
|
||
if (["move", "copy"].includes(action)) target.setAttribute("list", "sieve-folder-options");
|
||
}
|
||
|
||
function applySieveCapabilityMatrix(extensions) {
|
||
const supports = (name) => extensions.has(name.toLowerCase());
|
||
const actionAllowed = (action) => {
|
||
if (["move", "copy"].includes(action) && !supports("fileinto")) return false;
|
||
if (["copy", "redirect-copy"].includes(action) && !supports("copy")) return false;
|
||
if (["mark-read", "add-flag", "set-flags"].includes(action) && !supports("imap4flags")) return false;
|
||
return true;
|
||
};
|
||
for (const select of [$("#sieve-builder-action"), $("#sieve-builder-action-2"), ...$$("#sieve-builder-extra-actions .sieve-dynamic-action")]) {
|
||
if (!select) continue;
|
||
for (const option of select.options) option.disabled = Boolean(option.value) && !actionAllowed(option.value);
|
||
if (select.selectedOptions[0]?.disabled) select.value = select.id === "sieve-builder-action-2" ? "" : "keep";
|
||
}
|
||
$("#sieve-vacation-append").disabled = Boolean(state.sieveInfo) && !supports("vacation");
|
||
}
|
||
|
||
function renderSieveBuilderPreview() {
|
||
const preview = $("#sieve-builder-preview-text");
|
||
const code = $("#sieve-builder-preview-code");
|
||
const statePill = $("#sieve-builder-preview-state");
|
||
if (!preview || !code || !statePill) return;
|
||
try {
|
||
const built = buildSieveVisualRule();
|
||
preview.innerHTML = `<div><strong>When:</strong> ${escapeHtml(built.conditionSummary)}</div><div><strong>Then:</strong> ${escapeHtml(built.actionSummary || "No action")}</div>`;
|
||
code.textContent = built.rule;
|
||
statePill.textContent = built.enabled ? "Enabled" : "Disabled";
|
||
statePill.className = `sieve-status-pill ${built.enabled ? "ok" : ""}`;
|
||
} catch (error) {
|
||
preview.textContent = error.message;
|
||
code.textContent = "";
|
||
statePill.textContent = "Incomplete";
|
||
statePill.className = "sieve-status-pill error";
|
||
}
|
||
}
|
||
|
||
function updateSieveBuilder() {
|
||
const condition = $("#sieve-builder-condition").value;
|
||
$("#sieve-builder-header-wrap").classList.toggle("hidden", condition !== "header");
|
||
|
||
for (const suffix of ["-2", "-3"]) {
|
||
const nextCondition = $(`#sieve-builder-condition${suffix}`).value;
|
||
const enabled = Boolean(nextCondition);
|
||
$(`#sieve-builder-header-wrap${suffix}`).classList.toggle("hidden", nextCondition !== "header");
|
||
$(`#sieve-builder-match${suffix}`).disabled = !enabled;
|
||
$(`#sieve-builder-value${suffix}`).disabled = !enabled;
|
||
$(`#sieve-builder-negate${suffix}`).disabled = !enabled;
|
||
const customHeader = $(`#sieve-builder-header${suffix}`);
|
||
if (customHeader) customHeader.disabled = !enabled || nextCondition !== "header";
|
||
}
|
||
$("#sieve-builder-logic").disabled = !Boolean($("#sieve-builder-condition-2").value);
|
||
$("#sieve-builder-logic-3").disabled = !Boolean($("#sieve-builder-condition-3").value);
|
||
|
||
configureSieveActionFields("");
|
||
configureSieveActionFields("-2");
|
||
$$("#sieve-builder-extra-actions .sieve-dynamic-action-row").forEach(configureSieveDynamicAction);
|
||
$$("#sieve-builder-extra-conditions .sieve-dynamic-condition").forEach((row) => {
|
||
row.querySelector(".sieve-dynamic-header-wrap").classList.toggle("hidden", row.querySelector(".sieve-dynamic-kind").value !== "header");
|
||
});
|
||
|
||
const list = $("#sieve-folder-options");
|
||
list.replaceChildren(...state.folders.map((folder) => {
|
||
const option = document.createElement("option");
|
||
option.value = folder.name;
|
||
return option;
|
||
}));
|
||
|
||
const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase()));
|
||
applySieveCapabilityMatrix(extensions);
|
||
const actions = ["", "-2"].map((suffix) => $(`#sieve-builder-action${suffix}`)?.value).filter(Boolean)
|
||
.concat($$("#sieve-builder-extra-actions .sieve-dynamic-action").map((node) => node.value).filter(Boolean));
|
||
const requirements = new Set();
|
||
for (const actionName of actions) for (const requirement of sieveBuilderRequirements("", actionName)) requirements.add(requirement);
|
||
const missing = [...requirements].filter((requirement) => state.sieveInfo && !extensions.has(requirement.toLowerCase()));
|
||
$("#sieve-builder-capability").textContent = missing.length
|
||
? `Server does not advertise: ${missing.join(", ")}`
|
||
: requirements.size ? `Requires: ${[...requirements].join(", ")}` : "No optional extension required";
|
||
const vacationSupported = extensions.has("vacation");
|
||
const vacationScheduled = Boolean($("#sieve-vacation-start")?.value || $("#sieve-vacation-end")?.value);
|
||
const scheduleSupported = extensions.has("date") && extensions.has("relational");
|
||
$("#sieve-vacation-capability").textContent = state.sieveInfo
|
||
? !vacationSupported
|
||
? "Server does not advertise vacation"
|
||
: vacationScheduled && !scheduleSupported
|
||
? "Scheduled dates require date + relational"
|
||
: vacationScheduled
|
||
? "Server supports vacation scheduling"
|
||
: "Server supports vacation"
|
||
: "";
|
||
$("#sieve-builder-replace").disabled = !state.sieveEditingRuleId;
|
||
renderSieveBuilderPreview();
|
||
}
|
||
|
||
|
||
function buildSieveVacationRule() {
|
||
const subject = $("#sieve-vacation-subject").value.trim() || "Out of office";
|
||
const message = $("#sieve-vacation-message").value.trim();
|
||
const days = Math.max(1, Math.min(30, Number.parseInt($("#sieve-vacation-days").value, 10) || 7));
|
||
const start = $("#sieve-vacation-start").value.trim();
|
||
const end = $("#sieve-vacation-end").value.trim();
|
||
if (!message) throw new Error("Autoresponder message is required");
|
||
if (start && !/^\d{4}-\d{2}-\d{2}$/.test(start)) throw new Error("Vacation start date is invalid");
|
||
if (end && !/^\d{4}-\d{2}-\d{2}$/.test(end)) throw new Error("Vacation end date is invalid");
|
||
if (start && end && end < start) throw new Error("Vacation end date must not be before the start date");
|
||
|
||
const extensions = new Set((state.sieveInfo?.extensions || []).map((value) => value.toLowerCase()));
|
||
const requirements = new Set(["vacation"]);
|
||
const tests = [];
|
||
if (start || end) {
|
||
if (state.sieveInfo && (!extensions.has("date") || !extensions.has("relational"))) {
|
||
throw new Error("This server does not advertise the Sieve date + relational extensions required for scheduled vacation");
|
||
}
|
||
requirements.add("date");
|
||
requirements.add("relational");
|
||
if (start) tests.push(`currentdate :value "ge" "date" "${sieveQuote(start)}"`);
|
||
if (end) tests.push(`currentdate :value "le" "date" "${sieveQuote(end)}"`);
|
||
}
|
||
if ($("#sieve-vacation-skip-lists").checked) tests.push('not exists ["list-id"]');
|
||
if ($("#sieve-vacation-skip-auto").checked) tests.push('not header :matches "auto-submitted" ["auto-replied", "auto-generated"]');
|
||
const command = `vacation :days ${days} :subject "${sieveQuote(subject)}" "${sieveQuote(message)}";`;
|
||
const rule = tests.length
|
||
? `# RustBox autoresponder\nif allof (${tests.join(", ")}) {\n ${command}\n}`
|
||
: `# RustBox autoresponder\n${command}`;
|
||
return { requirements: [...requirements], rule };
|
||
}
|
||
|
||
async function appendSieveVacation() {
|
||
try {
|
||
const built = buildSieveVacationRule();
|
||
const editor = $("#sieve-editor");
|
||
editor.value = mergeSieveRequirements([editor.value.trim(), built.rule].filter(Boolean).join("\n\n"), built.requirements);
|
||
$("#sieve-result").textContent = "Autoresponder appended";
|
||
await validateSieve();
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function applySieveVisualRule(updateSelected) {
|
||
try {
|
||
const built = buildSieveVisualRule();
|
||
const editor = $("#sieve-editor");
|
||
let body = editor.value;
|
||
if (updateSelected) {
|
||
if (!state.sieveEditingRuleId) throw new Error("Select a parsed rule to update first");
|
||
const selected = parseSieveRules(body).find((rule) => rule.id === state.sieveEditingRuleId);
|
||
if (!selected) throw new Error("The selected rule no longer exists in the source");
|
||
body = `${body.slice(0, selected.start)}${built.rule}${body.slice(selected.end)}`;
|
||
} else {
|
||
body = [body.trim(), built.rule].filter(Boolean).join("\n\n");
|
||
}
|
||
editor.value = mergeSieveRequirements(body, built.requirements);
|
||
const parsed = parseSieveRules(editor.value);
|
||
const matching = parsed.find((rule) => rule.name === built.name);
|
||
state.sieveEditingRuleId = matching?.id || null;
|
||
renderManagedSieveRules();
|
||
updateSieveBuilder();
|
||
$("#sieve-result").textContent = updateSelected ? `Rule "${built.name}" updated - save to apply` : `Rule "${built.name}" added - save to apply`;
|
||
await validateSieve();
|
||
if ($("#sieve-rule-dialog")?.open) $("#sieve-rule-dialog").close();
|
||
} catch (error) {
|
||
toast(error.message, "error");
|
||
}
|
||
}
|
||
|
||
function renderSieveScripts() {
|
||
const list = $("#sieve-scripts");
|
||
list.replaceChildren();
|
||
if (!state.sieveScripts.length) list.append(el("div", "empty-list", "No scripts"));
|
||
for (const script of state.sieveScripts) {
|
||
const button = el("button", `sieve-script ${script.active ? "active" : ""}`);
|
||
button.type = "button";
|
||
button.innerHTML = `<span>${escapeHtml(script.name)}</span><span>${script.active ? "ACTIVE" : ""}</span>`;
|
||
button.addEventListener("click", () => openSieveScript(script.name));
|
||
list.append(button);
|
||
}
|
||
}
|
||
|
||
async function openSieveScript(name) {
|
||
try {
|
||
const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "GET" });
|
||
$("#sieve-name").value = data.name;
|
||
$("#sieve-editor").value = data.content;
|
||
state.sieveEditingRuleId = null;
|
||
clearSieveRuleBuilder();
|
||
renderManagedSieveRules();
|
||
$("#sieve-result").textContent = "Loaded";
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
function newSieveScript() {
|
||
$("#sieve-name").value = "rustbox";
|
||
$("#sieve-editor").value = `require ["fileinto"];\n`;
|
||
state.sieveEditingRuleId = null;
|
||
clearSieveRuleBuilder();
|
||
renderManagedSieveRules();
|
||
$("#sieve-result").textContent = "New script";
|
||
$("#sieve-name").focus();
|
||
}
|
||
|
||
async function validateSieve() {
|
||
try {
|
||
const data = await request("/sieve/check", { method: "POST", body: JSON.stringify({ content: $("#sieve-editor").value }) });
|
||
$("#sieve-result").textContent = data.valid ? (data.warning || "Valid script") : (data.error || "Invalid script");
|
||
return data.valid;
|
||
} catch (error) {
|
||
$("#sieve-result").textContent = error.message;
|
||
toast(error.message, "error");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function backupSieveScripts() {
|
||
try {
|
||
if (!state.sieveScripts.length) await loadSieve();
|
||
if (!state.sieveScripts.length) return toast("No Sieve scripts to back up", "info");
|
||
const scripts = [];
|
||
for (const script of state.sieveScripts) {
|
||
const data = await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method:"GET" });
|
||
scripts.push({ name:script.name, active:Boolean(script.active), content:data.content || "" });
|
||
}
|
||
const payload = {
|
||
format: "rustbox-sieve-backup",
|
||
version: 1,
|
||
exported_at: new Date().toISOString(),
|
||
scripts,
|
||
};
|
||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type:"application/json;charset=utf-8" });
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement("a");
|
||
link.href = url;
|
||
link.download = `rustbox-sieve-${new Date().toISOString().slice(0, 10)}.json`;
|
||
document.body.append(link);
|
||
link.click();
|
||
link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||
toast(`Backed up ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function restoreSieveBackup(file) {
|
||
if (!file) return;
|
||
try {
|
||
if (file.size > 2 * 1024 * 1024) throw new Error("Sieve backup is too large");
|
||
const payload = JSON.parse(await file.text());
|
||
if (payload?.format !== "rustbox-sieve-backup" || payload?.version !== 1 || !Array.isArray(payload.scripts)) {
|
||
throw new Error("This is not a supported RustBox Sieve backup");
|
||
}
|
||
const scripts = payload.scripts.slice(0, 100).map((script) => ({
|
||
name: String(script?.name || "").trim(),
|
||
active: Boolean(script?.active),
|
||
content: String(script?.content || ""),
|
||
}));
|
||
if (!scripts.length || scripts.some((script) => !script.name || script.name.length > 128 || /[\r\n]/.test(script.name))) {
|
||
throw new Error("Backup contains an invalid script name");
|
||
}
|
||
if (payload.scripts.length > 100) throw new Error("Backup contains too many scripts");
|
||
const activeCount = scripts.filter((script) => script.active).length;
|
||
if (activeCount > 1) throw new Error("Backup marks more than one Sieve script as active");
|
||
const existing = new Set(state.sieveScripts.map((script) => script.name));
|
||
const overwriteCount = scripts.filter((script) => existing.has(script.name)).length;
|
||
const confirmed = await appConfirm(
|
||
`Restore ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}${overwriteCount ? ` and overwrite ${overwriteCount} existing script${overwriteCount === 1 ? "" : "s"}` : ""}? Existing scripts not present in the backup will be kept.`,
|
||
{ title:"Restore Sieve backup", confirmLabel:"Restore", danger:overwriteCount > 0 }
|
||
);
|
||
if (!confirmed) return;
|
||
|
||
// Validate the complete backup against the current server before writing any script.
|
||
for (const script of scripts) {
|
||
const result = await request("/sieve/check", { method:"POST", body:JSON.stringify({ content:script.content }) });
|
||
if (!result.valid) throw new Error(`Script “${script.name}” is not valid on this server: ${result.error || "validation failed"}`);
|
||
}
|
||
for (const script of scripts) {
|
||
await request(`/sieve/scripts/${encodeURIComponent(script.name)}`, { method:"PUT", body:JSON.stringify({ content:script.content }) });
|
||
}
|
||
const active = scripts.find((script) => script.active);
|
||
if (active) await request(`/sieve/scripts/${encodeURIComponent(active.name)}/activate`, { method:"POST", body:"{}" });
|
||
await loadSieve();
|
||
toast(`Restored ${scripts.length} Sieve script${scripts.length === 1 ? "" : "s"}`, "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
finally { $("#sieve-restore-file").value = ""; }
|
||
}
|
||
|
||
async function saveSieve() {
|
||
const name = $("#sieve-name").value.trim();
|
||
if (!name) return toast("Script name is required", "error");
|
||
const existing = state.sieveScripts.find((script) => script.name === name);
|
||
if (existing?.active) {
|
||
const confirmed = await appConfirm(`“${name}” is the active Sieve script. Validate and overwrite it?`, { title:"Overwrite active filter script", confirmLabel:"Overwrite", danger:true });
|
||
if (!confirmed) return;
|
||
}
|
||
if (!await validateSieve()) return;
|
||
try {
|
||
const data = await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "PUT", body: JSON.stringify({ content: $("#sieve-editor").value }) });
|
||
$("#sieve-result").textContent = data.warning || "Saved";
|
||
await loadSieve();
|
||
toast(`Sieve script “${name}” saved`, "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function activateSieve() {
|
||
const name = $("#sieve-name").value.trim();
|
||
if (!name) return toast("Select a script first", "error");
|
||
try {
|
||
await request(`/sieve/scripts/${encodeURIComponent(name)}/activate`, { method: "POST", body: "{}" });
|
||
await loadSieve();
|
||
toast(`Sieve script “${name}” activated`, "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function deactivateSieve() {
|
||
try {
|
||
await request("/sieve/deactivate", { method: "POST", body: "{}" });
|
||
await loadSieve();
|
||
toast("Sieve script deactivated", "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
async function deleteSieve() {
|
||
const name = $("#sieve-name").value.trim();
|
||
if (!name || !await appConfirm(`Delete Sieve script “${name}”?`, { title:"Delete Sieve script", confirmLabel:"Delete", danger:true })) return;
|
||
try {
|
||
await request(`/sieve/scripts/${encodeURIComponent(name)}`, { method: "DELETE" });
|
||
$("#sieve-name").value = "";
|
||
$("#sieve-editor").value = "";
|
||
await loadSieve();
|
||
toast(`Sieve script “${name}” deleted`, "success");
|
||
} catch (error) { toast(error.message, "error"); }
|
||
}
|
||
|
||
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", () => $("#folder-pane").classList.toggle("open"));
|
||
$("#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 === "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-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); });
|
||
});
|
||
$("#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);
|
||
$$(`[data-appearance-preset]`).forEach((button) => button.addEventListener("click", () => applyAppearancePreset(button.dataset.appearancePreset)));
|
||
$("#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);
|
||
$("#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); });
|
||
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) closeReader();
|
||
});
|
||
matchMedia("(prefers-color-scheme: light)").addEventListener("change", () => { if (state.prefs.theme === "system") applyPreferences(); });
|
||
}
|
||
|
||
async function boot() {
|
||
applyPreferences();
|
||
bindEvents();
|
||
await loadAuthPolicy();
|
||
const authenticated = await loadAccounts();
|
||
if (authenticated) { await loadPreferences(); await loadMailPreferences(); await loadOpenPgp(); await loadIdentities(); await loadFolders(true); await loadSpecialFolders(); await loadTags(); await loadNotificationPreferences(); connectRealtime(); } else { await loadTranslations(state.prefs.language); }
|
||
}
|
||
|
||
boot();
|
||
})();
|