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 `${escapeHtml(label)}${supported ? "yes" : "no"}`; } 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; } }