798 lines
37 KiB
JavaScript
798 lines
37 KiB
JavaScript
|
|
async function loadQuota() {
|
|
const summary = $("#quota-summary span");
|
|
const bar = $("#quota-bar");
|
|
const sidebar = $("#folder-quota");
|
|
const sidebarText = $("#folder-quota-text");
|
|
const sidebarBar = $("#folder-quota-bar");
|
|
if (!summary && !sidebarText) return;
|
|
if (summary) summary.textContent = tr("loading_quota", "Loading quota…");
|
|
if (bar) bar.style.width = "0%";
|
|
if (sidebarText) sidebarText.textContent = tr("loading", "Loading…");
|
|
if (sidebarBar) sidebarBar.style.width = "0%";
|
|
const setLevel = (level) => {
|
|
for (const box of [$("#quota-summary"), sidebar]) {
|
|
box?.classList.toggle("quota-warning", level === "warning");
|
|
box?.classList.toggle("quota-danger", level === "danger");
|
|
}
|
|
};
|
|
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));
|
|
if (summary) summary.textContent = `${formatBytes(used)} ${tr("of", "of")} ${formatBytes(limit)} (${percent.toFixed(1)}%)`;
|
|
if (bar) bar.style.width = `${percent}%`;
|
|
if (sidebarText) sidebarText.textContent = `${percent.toFixed(0)}% · ${formatBytes(used)} / ${formatBytes(limit)}`;
|
|
if (sidebarBar) sidebarBar.style.width = `${percent}%`;
|
|
const level = percent >= 95 ? "danger" : percent >= 80 ? "warning" : "normal";
|
|
setLevel(level);
|
|
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 {
|
|
if (summary) summary.textContent = `${formatBytes(used)} ${tr("used", "used")} · ${tr("server_limit_unavailable", "server limit unavailable")}`;
|
|
if (sidebarText) sidebarText.textContent = `${formatBytes(used)} ${tr("used", "used")}`;
|
|
setLevel("normal");
|
|
}
|
|
} catch (error) {
|
|
if (summary) summary.textContent = `${tr("quota_unavailable", "Quota unavailable")}: ${error.message}`;
|
|
if (sidebarText) sidebarText.textContent = tr("unavailable", "Unavailable");
|
|
setLevel("normal");
|
|
}
|
|
}
|
|
|
|
function openSettings(tab = "appearance") {
|
|
if (!settingsTabAvailable(tab)) tab = "appearance";
|
|
selectSettingsTab(tab);
|
|
$("#settings-dialog").showModal();
|
|
if (tab === "sieve") loadSieve();
|
|
if (tab === "identities") { loadIdentities(); if (state.authPolicy.pgpEnabled !== false) loadOpenPgp(); }
|
|
if (tab === "openpgp" && state.authPolicy.pgpEnabled !== false) 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) {
|
|
if (!settingsTabAvailable(name)) name = "appearance";
|
|
$$(".settings-tab").forEach((button) => button.classList.toggle("active", button.dataset.tab === name));
|
|
$$(".settings-panel").forEach((panel) => panel.classList.toggle("hidden", panel.dataset.panel !== name));
|
|
}
|
|
|
|
function updateNotificationPermissionStatus() {
|
|
const status = $("#notify-permission-status");
|
|
if (!status) return;
|
|
status.textContent = !("Notification" in window) ? "Browser permission: unsupported" : `Browser permission: ${Notification.permission}`;
|
|
}
|
|
|
|
function notificationPreferencesForAccount(accountId) {
|
|
if (accountId && state.notificationPrefsByAccount.has(accountId)) return state.notificationPrefsByAccount.get(accountId);
|
|
if (accountId === state.accountId) return state.notificationPrefs;
|
|
return { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" };
|
|
}
|
|
|
|
async function loadNotificationPreferencesForAccount(accountId, { silent = true } = {}) {
|
|
if (!accountId) return null;
|
|
try {
|
|
const prefs = await request("/settings/notifications", { method: "GET", accountId });
|
|
const normalized = { ...notificationPreferencesForAccount(accountId), ...prefs };
|
|
state.notificationPrefsByAccount.set(accountId, normalized);
|
|
if (accountId === state.accountId) state.notificationPrefs = normalized;
|
|
return normalized;
|
|
} catch (error) {
|
|
if (!silent) toast(error.message, "error");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function loadCombinedNotificationPreferences() {
|
|
if (!multiAccountEnabled()) return;
|
|
const accountIds = [...state.notificationAccountIds].filter((accountId) => accountId !== state.accountId && state.accounts.some((account) => account.id === accountId));
|
|
await Promise.all(accountIds.map((accountId) => loadNotificationPreferencesForAccount(accountId)));
|
|
}
|
|
|
|
async function loadNotificationPreferences() {
|
|
void loadCalendarReminderDefaults();
|
|
const prefs = await loadNotificationPreferencesForAccount(state.accountId, { silent: false });
|
|
if (!prefs) return;
|
|
$("#notify-enabled").checked = Boolean(prefs.enabled);
|
|
$("#notify-sound").checked = Boolean(prefs.sound);
|
|
$("#notify-dnd-enabled").checked = Boolean(prefs.dnd_enabled);
|
|
$("#notify-dnd-start").value = prefs.dnd_start || "22:00";
|
|
$("#notify-dnd-end").value = prefs.dnd_end || "07:00";
|
|
renderNotificationFolders(prefs.folders || ["INBOX"]);
|
|
updateNotificationPermissionStatus();
|
|
await loadCombinedNotificationPreferences();
|
|
}
|
|
|
|
function renderNotificationFolders(selected) {
|
|
const set = new Set(selected || []);
|
|
const list = $("#notify-folders");
|
|
list.replaceChildren();
|
|
for (const folder of orderedFolders()) {
|
|
const label = el("label", "checkbox-row");
|
|
const input = document.createElement("input");
|
|
input.type = "checkbox";
|
|
input.value = folder.name;
|
|
input.checked = set.has(folder.name);
|
|
label.append(input, el("span", "", folder.name));
|
|
list.append(label);
|
|
}
|
|
}
|
|
|
|
async function saveNotificationPreferences() {
|
|
const folders = $$('#notify-folders input[type="checkbox"]:checked').map((input) => input.value);
|
|
try {
|
|
if ($("#notify-enabled").checked && "Notification" in window && Notification.permission === "default") await requestNotificationPermission();
|
|
const prefs = await request("/settings/notifications", { method: "PUT", body: JSON.stringify({ enabled: $("#notify-enabled").checked, sound: $("#notify-sound").checked, folders, dnd_enabled: $("#notify-dnd-enabled").checked, dnd_start: $("#notify-dnd-start").value || "22:00", dnd_end: $("#notify-dnd-end").value || "07:00" }) });
|
|
state.notificationPrefs = { ...state.notificationPrefs, ...prefs };
|
|
if (state.accountId) state.notificationPrefsByAccount.set(state.accountId, state.notificationPrefs);
|
|
updateNotificationPermissionStatus();
|
|
toast("Notification settings saved");
|
|
} catch (error) { toast(error.message, "error"); }
|
|
}
|
|
|
|
async function requestNotificationPermission() {
|
|
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
|
|
const result = await Notification.requestPermission();
|
|
updateNotificationPermissionStatus();
|
|
toast(`Notification permission: ${result}`, result === "granted" ? "success" : "warning");
|
|
return result;
|
|
}
|
|
|
|
async function testNotification() {
|
|
if (!("Notification" in window)) return toast("Notifications are not supported by this browser", "error");
|
|
let permission = Notification.permission;
|
|
if (permission === "default") permission = await requestNotificationPermission();
|
|
if (permission !== "granted") return toast("Browser notifications are not allowed", "warning");
|
|
const notification = new Notification("RustBox notification test", { body: "Browser notifications are working.", tag: "rustbox-test" });
|
|
notification.onclick = () => { window.focus(); notification.close(); };
|
|
if ($("#notify-sound")?.checked) playNotificationSound();
|
|
toast("Test notification sent", "success");
|
|
}
|
|
|
|
function dndActive(prefs = state.notificationPrefs) {
|
|
if (!prefs?.dnd_enabled) return false;
|
|
const toMinutes = (value) => {
|
|
const [h, m] = String(value || "00:00").split(":").map(Number);
|
|
return h * 60 + m;
|
|
};
|
|
const now = new Date();
|
|
const current = now.getHours() * 60 + now.getMinutes();
|
|
const start = toMinutes(prefs.dnd_start);
|
|
const end = toMinutes(prefs.dnd_end);
|
|
if (start === end) return true;
|
|
return start < end ? current >= start && current < end : current >= start || current < end;
|
|
}
|
|
|
|
function playNotificationSound() {
|
|
try {
|
|
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
if (!AudioContext) return;
|
|
const context = new AudioContext();
|
|
const oscillator = context.createOscillator();
|
|
const gain = context.createGain();
|
|
oscillator.frequency.value = 620;
|
|
gain.gain.setValueAtTime(0.0001, context.currentTime);
|
|
gain.gain.exponentialRampToValueAtTime(0.035, context.currentTime + 0.015);
|
|
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + 0.16);
|
|
oscillator.connect(gain); gain.connect(context.destination);
|
|
oscillator.start(); oscillator.stop(context.currentTime + 0.17);
|
|
oscillator.onended = () => context.close().catch(() => { });
|
|
} catch (_) { }
|
|
}
|
|
|
|
function renderSyncActivityIndicator() {
|
|
const indicator = $("#indexing-indicator");
|
|
if (!indicator) return;
|
|
const busy = Boolean(state.messageLoadPending > 0 || state.indexingActive || state.imapSyncActive);
|
|
indicator.classList.toggle("hidden", !busy);
|
|
indicator.classList.toggle("active", busy);
|
|
const parts = [];
|
|
if (state.messageLoadPending > 0) {
|
|
const folder = state.folder ? ` · ${state.folder}` : "";
|
|
parts.push(`Loading messages${folder}`);
|
|
}
|
|
if (state.imapSyncActive || state.imapSyncPending > 0) {
|
|
const folder = state.imapSyncFolder ? ` · ${state.imapSyncFolder}` : "";
|
|
const pending = state.imapSyncPending > 0 ? ` · ${state.imapSyncPending} headers pending in task` : "";
|
|
parts.push(`IMAP synchronization${folder}${pending}`);
|
|
}
|
|
if (state.imapSyncInventoryPending > 0) {
|
|
parts.push(`IMAP cache incomplete · ${state.imapSyncInventoryPending} message headers not cached yet`);
|
|
}
|
|
if (state.indexingActive || state.indexingPending > 0) {
|
|
const pending = state.indexingPending > 0 ? ` · ${state.indexingPending} messages pending` : "";
|
|
parts.push(`Search indexing${pending}`);
|
|
}
|
|
const label = parts.join(" | ") || "Mail synchronization";
|
|
indicator.title = label;
|
|
indicator.setAttribute("aria-label", label);
|
|
}
|
|
|
|
function setIndexingIndicator(active, pending = 0) {
|
|
state.indexingActive = Boolean(active);
|
|
state.indexingPending = Math.max(0, Number(pending) || 0);
|
|
renderSyncActivityIndicator();
|
|
}
|
|
|
|
function beginMessageLoadActivity() {
|
|
state.messageLoadPending = Math.max(0, Number(state.messageLoadPending) || 0) + 1;
|
|
renderSyncActivityIndicator();
|
|
}
|
|
|
|
function endMessageLoadActivity() {
|
|
state.messageLoadPending = Math.max(0, (Number(state.messageLoadPending) || 0) - 1);
|
|
renderSyncActivityIndicator();
|
|
}
|
|
|
|
function setImapSyncIndicator(active, folder = null, pending = 0) {
|
|
state.imapSyncActive = Boolean(active);
|
|
state.imapSyncFolder = folder || null;
|
|
state.imapSyncPending = Math.max(0, Number(pending) || 0);
|
|
renderSyncActivityIndicator();
|
|
}
|
|
|
|
function setImapSyncInventoryPending(pending = 0) {
|
|
state.imapSyncInventoryPending = Math.max(0, Number(pending) || 0);
|
|
renderSyncActivityIndicator();
|
|
}
|
|
|
|
function syncRealtimeWatchedFolder(force = false) {
|
|
const socket = state.ws;
|
|
if (!socket || socket.readyState !== WebSocket.OPEN || !state.accountId || !state.folder) return;
|
|
const key = `${state.accountId}|${state.folder}`;
|
|
if (!force && state.realtimeWatchedFolder === key) return;
|
|
try {
|
|
socket.send(JSON.stringify({ type: "watch_folder", accountId: state.accountId, folder: state.folder }));
|
|
state.realtimeWatchedFolder = key;
|
|
} catch (_) { }
|
|
}
|
|
|
|
function newMailNotificationKey(data, accountId) {
|
|
const uid = Number(data?.latest_uid);
|
|
if (Number.isFinite(uid) && uid > 0) return `${accountId || ""}|${data?.folder || "INBOX"}|${uid}`;
|
|
return `${accountId || ""}|${data?.folder || "INBOX"}|${data?.latest_sender || ""}|${data?.latest_subject || ""}|${data?.count || 1}`;
|
|
}
|
|
|
|
function realtimeNotificationSuppressionKey(folder, accountId = state.accountId) {
|
|
return `${accountId || ""}|${folder || "INBOX"}`;
|
|
}
|
|
|
|
function realtimeSuppressedMessageKey(folder, uid, accountId = state.accountId) {
|
|
return `${accountId || ""}|${folder || "INBOX"}|${Number(uid) || 0}`;
|
|
}
|
|
|
|
function suppressRealtimeNotificationForMessage(folder, uid, accountId = state.accountId, ttlMs = 15000) {
|
|
const numericUid = Number(uid);
|
|
if (!folder || !Number.isFinite(numericUid) || numericUid <= 0) return;
|
|
state.realtimeNotificationSuppressedMessages.set(
|
|
realtimeSuppressedMessageKey(folder, numericUid, accountId),
|
|
Date.now() + Math.max(3000, Number(ttlMs) || 15000),
|
|
);
|
|
}
|
|
|
|
function consumeRealtimeMessageNotificationSuppression(data, accountId) {
|
|
const now = Date.now();
|
|
for (const [key, expiresAt] of state.realtimeNotificationSuppressedMessages) {
|
|
if (Number(expiresAt) <= now) state.realtimeNotificationSuppressedMessages.delete(key);
|
|
}
|
|
const uid = Number(data?.latest_uid);
|
|
if (!Number.isFinite(uid) || uid <= 0) return false;
|
|
const key = realtimeSuppressedMessageKey(data?.folder, uid, accountId);
|
|
if (!state.realtimeNotificationSuppressedMessages.has(key)) return false;
|
|
state.realtimeNotificationSuppressedMessages.delete(key);
|
|
return true;
|
|
}
|
|
|
|
function suppressRealtimeNotificationsForFolder(folder, accountId = state.accountId) {
|
|
if (!folder) return () => { };
|
|
const key = realtimeNotificationSuppressionKey(folder, accountId);
|
|
state.realtimeNotificationSuppressions.set(key, (state.realtimeNotificationSuppressions.get(key) || 0) + 1);
|
|
let released = false;
|
|
return () => {
|
|
if (released) return;
|
|
released = true;
|
|
setTimeout(() => {
|
|
const count = state.realtimeNotificationSuppressions.get(key) || 0;
|
|
if (count <= 1) state.realtimeNotificationSuppressions.delete(key);
|
|
else state.realtimeNotificationSuppressions.set(key, count - 1);
|
|
}, 2500);
|
|
};
|
|
}
|
|
|
|
function realtimeFolderIsDrafts(folderName) {
|
|
const name = String(folderName || "");
|
|
if (!name) return false;
|
|
const configured = state.specialFolders?.drafts;
|
|
if (configured && name.toLowerCase() === String(configured).toLowerCase()) return true;
|
|
const meta = state.folders.find((folder) => String(folder?.name || "").toLowerCase() === name.toLowerCase());
|
|
const attrs = Array.isArray(meta?.attributes) ? meta.attributes : String(meta?.attributes || "").split(/\s+/);
|
|
if (attrs.some((value) => String(value).trim().toLowerCase() === "\\drafts")) return true;
|
|
const leaf = name.split(/[\/.]/).filter(Boolean).at(-1)?.toLowerCase();
|
|
return leaf === "draft" || leaf === "drafts";
|
|
}
|
|
|
|
function realtimeNewMailNotificationsSuppressed(data, accountId) {
|
|
if (!state.realtimeNotificationsReady) return true;
|
|
if (realtimeFolderIsDrafts(data?.folder)) return true;
|
|
if (consumeRealtimeMessageNotificationSuppression(data, accountId)) return true;
|
|
return (state.realtimeNotificationSuppressions.get(realtimeNotificationSuppressionKey(data?.folder, accountId)) || 0) > 0;
|
|
}
|
|
|
|
function scheduleRealtimeNotificationsReady() {
|
|
if (!state.realtimeNotificationsArmRequested || state.realtimeNotificationsReady) return;
|
|
clearTimeout(state.realtimeNotificationsReadyTimer);
|
|
state.realtimeNotificationsReadyTimer = setTimeout(() => {
|
|
state.realtimeNotificationsReadyTimer = null;
|
|
state.realtimeNotificationsReady = true;
|
|
}, 3000);
|
|
}
|
|
|
|
function armRealtimeNotifications() {
|
|
state.realtimeNotificationsArmRequested = true;
|
|
if (state.ws?.readyState === WebSocket.OPEN) scheduleRealtimeNotificationsReady();
|
|
}
|
|
|
|
function resetRealtimeNotificationGate() {
|
|
clearTimeout(state.realtimeNotificationsReadyTimer);
|
|
state.realtimeNotificationsReadyTimer = null;
|
|
state.realtimeNotificationsReady = false;
|
|
state.realtimeNotificationsArmRequested = false;
|
|
state.realtimeNotificationSuppressions.clear();
|
|
state.realtimeNotificationSuppressedMessages.clear();
|
|
state.realtimeNotifiedMessages.clear();
|
|
}
|
|
|
|
function isDuplicateNewMailNotification(data, accountId) {
|
|
const now = Date.now();
|
|
for (const [key, seenAt] of state.realtimeNotifiedMessages) {
|
|
if (now - seenAt > 10 * 60 * 1000) state.realtimeNotifiedMessages.delete(key);
|
|
}
|
|
const key = newMailNotificationKey(data, accountId);
|
|
if (state.realtimeNotifiedMessages.has(key)) return true;
|
|
state.realtimeNotifiedMessages.set(key, now);
|
|
return false;
|
|
}
|
|
|
|
function toastNewMail(data, accountId) {
|
|
const count = Math.max(1, Number(data?.count) || 1);
|
|
const folder = data?.folder || "INBOX";
|
|
const account = state.accounts.find((item) => item.id === accountId);
|
|
const accountPrefix = accountId && accountId !== state.accountId && account?.email ? `${account.email} · ` : "";
|
|
const sender = data?.latest_sender ? `${data.latest_sender}: ` : "";
|
|
const subject = data?.latest_subject || (count === 1 ? "New message" : `${count} new messages`);
|
|
const message = count === 1
|
|
? `${accountPrefix}${folder} · ${sender}${subject}`
|
|
: `${accountPrefix}${folder} · ${count} new messages${data?.latest_subject ? ` · ${data.latest_subject}` : ""}`;
|
|
toast(message, "info", 8000, { label: "Open", onClick: () => openRealtimeNotificationTarget(accountId, data) });
|
|
}
|
|
|
|
async function openRealtimeNotificationTarget(accountId, data) {
|
|
window.focus();
|
|
const folder = data?.folder || "INBOX";
|
|
const uid = Number(data?.latest_uid);
|
|
if (accountId && accountId !== state.accountId) await switchAccount(accountId);
|
|
if (folder !== state.folder) {
|
|
state.folder = folder;
|
|
renderFolders();
|
|
await loadMessages(true);
|
|
}
|
|
if (Number.isFinite(uid) && uid > 0) await openMessage(folder, uid);
|
|
}
|
|
|
|
function notifyNewMail(data, accountId) {
|
|
const prefs = notificationPreferencesForAccount(accountId) || {};
|
|
if (!prefs.enabled || dndActive(prefs)) return;
|
|
const folders = prefs.folders || [];
|
|
if (folders.length && !folders.includes(data.folder)) return;
|
|
const uid = Number(data.latest_uid);
|
|
const alreadyOpen = Number(data.count || 1) === 1 && accountId === state.accountId && data.folder === state.currentDetail?.folder && Number.isFinite(uid) && uid === Number(state.currentDetail?.uid) && document.visibilityState === "visible" && document.hasFocus();
|
|
if (alreadyOpen) return;
|
|
if ("Notification" in window && Notification.permission === "granted") {
|
|
const account = state.accounts.find((item) => item.id === accountId);
|
|
const title = data.latest_sender || account?.email || "New mail";
|
|
const body = data.latest_subject || `${data.count || 1} new message(s) in ${data.folder}`;
|
|
const notificationTag = `rustbox-${accountId}-${data.folder}-${Number.isFinite(uid) && uid > 0 ? uid : "new"}`;
|
|
const notification = new Notification(title, { body, tag: notificationTag, renotify: false });
|
|
notification.onclick = () => { notification.close(); void openRealtimeNotificationTarget(accountId, data); };
|
|
}
|
|
if (prefs.sound) playNotificationSound();
|
|
}
|
|
|
|
function calendarReminderStorageKey(event, minutes) {
|
|
return `rustbox.calendarReminder.${state.accountId || "account"}.${event.id}.${event.start_time}.${minutes}`;
|
|
}
|
|
|
|
// All-day events are reminded relative to the start of the working day.
|
|
function calendarReminderTrigger(event, minutes) {
|
|
const offset = Math.max(0, Number(minutes) || 0) * 60000;
|
|
if (event.all_day) {
|
|
const day = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(event.start_time || ""));
|
|
if (!day) return NaN;
|
|
const [hours, mins] = String(state.calendarSettings?.working_hours_start || "09:00").split(":").map(Number);
|
|
const base = new Date(Number(day[1]), Number(day[2]) - 1, Number(day[3]), Number.isFinite(hours) ? hours : 9, Number.isFinite(mins) ? mins : 0);
|
|
return base.getTime() - offset;
|
|
}
|
|
const startMs = new Date(event.start_time).getTime();
|
|
return Number.isFinite(startMs) ? startMs - offset : NaN;
|
|
}
|
|
|
|
function fireCalendarReminder(event) {
|
|
const title = event.title || "Calendar reminder";
|
|
const location = event.location ? ` · ${event.location}` : "";
|
|
const when = formatDate(event.start_time, true);
|
|
toast(`${title} — ${when}${location}`, "info", 12000);
|
|
if (!dndActive() && state.notificationPrefs?.enabled && "Notification" in window && Notification.permission === "granted") {
|
|
const notification = new Notification(title, { body: `${when}${location}`, tag: `rustbox-calendar-${event.id}-${event.start_time}`, renotify: true });
|
|
notification.onclick = () => { window.focus(); notification.close(); openCalendar(); };
|
|
}
|
|
if (!dndActive() && state.notificationPrefs?.sound) playNotificationSound();
|
|
}
|
|
|
|
async function checkCalendarReminders() {
|
|
if (!state.accountId) return;
|
|
const now = Date.now();
|
|
const start = new Date(now - 2 * 24 * 60 * 60 * 1000);
|
|
// Look far enough ahead for the longest reminder choice (1 week before).
|
|
const end = new Date(now + 8 * 24 * 60 * 60 * 1000);
|
|
try {
|
|
const data = await request(`/calendar/events?start=${encodeURIComponent(start.toISOString())}&end=${encodeURIComponent(end.toISOString())}`, { method: "GET" });
|
|
for (const event of data.events || []) {
|
|
if (String(event.status || "").toLowerCase() === "cancelled") continue;
|
|
for (const minutes of [event.reminder_minutes, event.reminder_extra_minutes]) {
|
|
if (minutes == null) continue;
|
|
const trigger = calendarReminderTrigger(event, minutes);
|
|
if (!Number.isFinite(trigger) || trigger > now || now - trigger > 10 * 60 * 1000) continue;
|
|
const key = calendarReminderStorageKey(event, minutes);
|
|
if (localStorage.getItem(key)) continue;
|
|
localStorage.setItem(key, String(now));
|
|
fireCalendarReminder(event);
|
|
}
|
|
}
|
|
} catch (_) { }
|
|
}
|
|
|
|
function startCalendarReminderWatcher() {
|
|
if (state.calendarReminderTimer) clearInterval(state.calendarReminderTimer);
|
|
void checkCalendarReminders();
|
|
state.calendarReminderTimer = setInterval(() => { void checkCalendarReminders(); }, 30000);
|
|
}
|
|
|
|
function realtimeEventKey(payload) {
|
|
if (payload.eventId) return String(payload.eventId);
|
|
const event = payload.event || {};
|
|
return `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
|
|
}
|
|
|
|
function isDuplicateRealtimeEvent(payload) {
|
|
const now = Date.now();
|
|
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
|
|
|
|
// Deduplicate by eventId when present: the same state can legitimately repeat.
|
|
if (payload.eventId) {
|
|
const idKey = `id:${payload.eventId}`;
|
|
const duplicate = state.realtimeSeenEvents.has(idKey);
|
|
state.realtimeSeenEvents.set(idKey, now);
|
|
return duplicate;
|
|
}
|
|
|
|
// Fallback for legacy/nonstandard envelopes without eventId.
|
|
const event = payload.event || {};
|
|
const fingerprint = `${payload.accountId || ""}|${event.type || ""}|${JSON.stringify(event.data || {})}`;
|
|
const fingerprintKey = `event:${fingerprint}`;
|
|
const seenAt = state.realtimeSeenEvents.get(fingerprintKey);
|
|
const duplicate = Boolean(seenAt && now - seenAt < 1500);
|
|
state.realtimeSeenEvents.set(fingerprintKey, now);
|
|
return duplicate;
|
|
}
|
|
|
|
function applyRealtimeFolderStatus(data) {
|
|
if (!data?.folder) return;
|
|
setFolderCounts(data.folder, Number(data.total_count), Number(data.unread_count));
|
|
}
|
|
|
|
function applyRealtimeFlagChanges(data) {
|
|
if (!data?.folder) return false;
|
|
const changes = Array.isArray(data.changes) ? data.changes : [];
|
|
for (const change of changes) {
|
|
if (!Number.isFinite(Number(change?.uid))) continue;
|
|
applyLocalMessageFlags(data.folder, Number(change.uid), change.flags || []);
|
|
}
|
|
return changes.length > 0;
|
|
}
|
|
|
|
function applyRealtimeFolderAllRead(data) {
|
|
const folder = data?.folder;
|
|
if (!folder || folder !== state.folder) return;
|
|
const throughUid = Number(data?.through_uid);
|
|
for (const message of state.messages) {
|
|
if (Number.isFinite(throughUid) && Number(message.uid) > throughUid) continue;
|
|
const flags = normalizedMessageFlags(message.flags);
|
|
if (!flags.includes("\\Seen")) flags.push("\\Seen");
|
|
applyLocalMessageFlags(folder, message.uid, flags);
|
|
}
|
|
}
|
|
|
|
async function resyncRealtimeState() {
|
|
if (!state.accountId) return;
|
|
if (state.realtimeSyncPromise) return state.realtimeSyncPromise;
|
|
state.realtimeSyncPromise = (async () => {
|
|
await refreshFolderListOnly({ forceServer: true });
|
|
if (state.searchContext) await runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop });
|
|
else await loadMessages(true, { background: true });
|
|
})().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; });
|
|
return state.realtimeSyncPromise;
|
|
}
|
|
|
|
function scheduleRealtimeRefresh(event, accountId) {
|
|
if (accountId !== state.accountId) return;
|
|
state.realtimeRefreshEvents.push(event);
|
|
clearTimeout(state.realtimeRefreshTimer);
|
|
state.realtimeRefreshTimer = setTimeout(async () => {
|
|
const events = state.realtimeRefreshEvents.splice(0);
|
|
const folderTreeChanged = events.some((item) => item.type === "FolderUpdated");
|
|
const newInCurrent = events.some((item) => item.type === "NewMessages" && item.data?.folder === state.folder);
|
|
const structuralFlagsInCurrent = events.some((item) => item.type === "FlagsChanged" && item.data?.folder === state.folder && !(item.data?.changes || []).length);
|
|
const tasks = [];
|
|
if (folderTreeChanged) tasks.push(refreshFolderListOnly());
|
|
if (folderTreeChanged || newInCurrent || structuralFlagsInCurrent) {
|
|
if (state.searchContext) tasks.push(runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop }));
|
|
else tasks.push(loadMessages(true, { background: true }));
|
|
}
|
|
if (tasks.length) await Promise.all(tasks);
|
|
}, 80);
|
|
}
|
|
|
|
function startRealtimeFallbackPolling() {
|
|
if (state.realtimePollTimer) return;
|
|
state.realtimePollTimer = setInterval(() => {
|
|
if (!state.accounts.length || document.visibilityState === "hidden") return;
|
|
const open = state.ws?.readyState === WebSocket.OPEN;
|
|
const stale = !state.wsLastHeartbeatAt || Date.now() - state.wsLastHeartbeatAt > 55000;
|
|
if (!open || stale) void resyncRealtimeState();
|
|
}, 30000);
|
|
}
|
|
|
|
function startRealtimeWatchdog(socket) {
|
|
clearInterval(state.wsWatchdogTimer);
|
|
state.wsWatchdogTimer = setInterval(() => {
|
|
if (state.ws !== socket || socket.readyState !== WebSocket.OPEN) return;
|
|
if (Date.now() - state.wsLastHeartbeatAt > 60000) socket.close(4000, "Heartbeat timeout");
|
|
}, 10000);
|
|
}
|
|
|
|
function scheduleRealtimeReconnect() {
|
|
if (!state.wsShouldConnect || !state.accounts.length || state.wsRetryTimer) return;
|
|
const attempt = Math.min(state.wsRetryAttempt, 6);
|
|
const base = Math.min(30000, 1000 * (2 ** attempt));
|
|
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
|
|
state.wsRetryAttempt += 1;
|
|
state.wsRetryTimer = setTimeout(() => {
|
|
state.wsRetryTimer = null;
|
|
connectRealtime();
|
|
}, delay);
|
|
}
|
|
|
|
function connectRealtime() {
|
|
state.wsShouldConnect = true;
|
|
startRealtimeFallbackPolling();
|
|
if (!state.accounts.length) return;
|
|
if (state.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(state.ws.readyState)) return;
|
|
clearTimeout(state.wsRetryTimer);
|
|
state.wsRetryTimer = null;
|
|
const protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
const accountQuery = state.accountId ? `?account_id=${encodeURIComponent(state.accountId)}` : "";
|
|
const socket = new WebSocket(`${protocol}//${location.host}${BASE}/api/ws${accountQuery}`);
|
|
state.ws = socket;
|
|
socket.onopen = () => {
|
|
if (state.ws !== socket) return;
|
|
const reconnect = state.wsConnectedOnce;
|
|
state.wsConnectedOnce = true;
|
|
state.wsRetryAttempt = 0;
|
|
state.wsLastHeartbeatAt = Date.now();
|
|
state.realtimeWatchedFolder = null;
|
|
startRealtimeWatchdog(socket);
|
|
syncRealtimeWatchedFolder(true);
|
|
scheduleRealtimeNotificationsReady();
|
|
if (reconnect) void resyncRealtimeState();
|
|
};
|
|
socket.onmessage = (message) => {
|
|
if (state.ws !== socket) return;
|
|
state.wsLastHeartbeatAt = Date.now();
|
|
try {
|
|
const payload = JSON.parse(message.data);
|
|
if (payload.type === "heartbeat") {
|
|
if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "heartbeat_ack", timestamp: payload.timestamp || Date.now() }));
|
|
return;
|
|
}
|
|
if (isDuplicateRealtimeEvent(payload)) return;
|
|
const event = payload.event || {};
|
|
if (event.type === "NewMessages") {
|
|
const data = event.data || {};
|
|
const duplicate = isDuplicateNewMailNotification(data, payload.accountId);
|
|
if (!duplicate && notificationAccountEnabled(payload.accountId) && !realtimeNewMailNotificationsSuppressed(data, payload.accountId)) {
|
|
toastNewMail(data, payload.accountId);
|
|
notifyNewMail(data, payload.accountId);
|
|
}
|
|
}
|
|
if (payload.accountId === state.accountId) {
|
|
if (event.type === "FolderStatus") applyRealtimeFolderStatus(event.data || {});
|
|
if (event.type === "FlagsChanged") {
|
|
const patched = applyRealtimeFlagChanges(event.data || {});
|
|
if (!patched) scheduleRealtimeRefresh(event, payload.accountId);
|
|
}
|
|
if (event.type === "FolderAllRead") applyRealtimeFolderAllRead(event.data || {});
|
|
if (event.type === "IndexingStatus") setIndexingIndicator(Boolean(event.data?.active), event.data?.pending);
|
|
if (event.type === "SyncStatus") {
|
|
const wasActive = state.imapSyncActive;
|
|
const syncFolder = event.data?.folder || null;
|
|
const active = Boolean(event.data?.active);
|
|
setImapSyncIndicator(active, syncFolder, event.data?.pending);
|
|
const folderState = state.folders.find((folder) => folder.name === syncFolder);
|
|
const serverTotal = Number(folderState?.total_count);
|
|
const syncCompletedForCurrentFolder = wasActive && !active && syncFolder === state.folder;
|
|
if (syncCompletedForCurrentFolder && !state.searchContext && Number.isFinite(serverTotal) && state.total < serverTotal) {
|
|
void loadMessages(true, { background: true });
|
|
}
|
|
}
|
|
if (event.type === "SyncInventory") setImapSyncInventoryPending(event.data?.pending);
|
|
}
|
|
if (["NewMessages", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
|
|
} catch (error) { console.warn("Invalid realtime event", error); }
|
|
};
|
|
socket.onclose = () => {
|
|
if (state.ws !== socket) return;
|
|
state.ws = null;
|
|
state.realtimeWatchedFolder = null;
|
|
setIndexingIndicator(false);
|
|
setImapSyncIndicator(false, null, 0);
|
|
clearInterval(state.wsWatchdogTimer);
|
|
state.wsWatchdogTimer = null;
|
|
scheduleRealtimeReconnect();
|
|
};
|
|
socket.onerror = () => { if (socket.readyState < WebSocket.CLOSING) socket.close(); };
|
|
}
|
|
|
|
function disconnectRealtime() {
|
|
state.wsShouldConnect = false;
|
|
clearTimeout(state.wsRetryTimer);
|
|
clearTimeout(state.realtimeRefreshTimer);
|
|
clearInterval(state.wsWatchdogTimer);
|
|
clearInterval(state.realtimePollTimer);
|
|
state.wsRetryTimer = null;
|
|
state.realtimeRefreshTimer = null;
|
|
state.wsWatchdogTimer = null;
|
|
state.realtimePollTimer = null;
|
|
state.realtimeRefreshEvents = [];
|
|
state.wsRetryAttempt = 0;
|
|
state.wsLastHeartbeatAt = 0;
|
|
state.realtimeWatchedFolder = null;
|
|
setIndexingIndicator(false);
|
|
setImapSyncIndicator(false, null, 0);
|
|
const socket = state.ws;
|
|
state.ws = null;
|
|
if (socket && socket.readyState < WebSocket.CLOSING) socket.close(1000, "Client disconnect");
|
|
}
|
|
|
|
async function resetAllSettings() {
|
|
if (!window.confirm("Reset all RustBox settings to defaults? Messages, identities, keys and templates will be kept.")) return;
|
|
const button = $("#reset-all-settings");
|
|
if (button) button.disabled = true;
|
|
try {
|
|
await request("/settings/reset", { method: "POST", body: "{}" });
|
|
["rustbox.calendarView", "rustbox.threadExpanded", "rustbox.composeFullscreen"].forEach((key) => localStorage.removeItem(key));
|
|
window.location.reload();
|
|
} catch (error) {
|
|
toast(error.message, "error");
|
|
if (button) button.disabled = false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
if (state.authPolicy.sieveEnabled !== false) {
|
|
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";
|
|
const bodyCacheState = info.deepIndexEnabled ? "body cache enabled" : "body cache disabled";
|
|
$("#diagnostic-search-summary").textContent = `${info.indexedDocuments} indexed · ${info.cachedMessages} cached messages · ${info.cachedBodies} cached bodies · ${bodyCacheState}${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;
|
|
setIndexingIndicator(true, 0);
|
|
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 {
|
|
setIndexingIndicator(false, 0);
|
|
button.disabled = false;
|
|
}
|
|
}
|