1 parent
cfc74d9db6
commit
dacdf9c27d
76 files changed
+5209
-741
No files matched your search
@@ -158,15 +158,22 @@
|
||||
.indexing-indicator svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.indexing-indicator.active svg {
|
||||
animation: rustbox-indexing-spin .9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rustbox-indexing-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indexing-indicator svg { animation: none; }
|
||||
.indexing-indicator svg {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.account-summary {
|
||||
@@ -216,6 +223,8 @@
|
||||
}
|
||||
|
||||
.folder-pane {
|
||||
container-name: folder-pane;
|
||||
container-type: inline-size;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
|
||||
@@ -49,10 +49,47 @@
|
||||
}
|
||||
|
||||
.folder-count.unread {
|
||||
color: var(--subtle);
|
||||
}
|
||||
|
||||
.folder-count .folder-unread,
|
||||
.folder-count.unread .folder-count-compact {
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.folder-count-wide {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.folder-count-compact {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-count .folder-total,
|
||||
.folder-count .folder-unread,
|
||||
.folder-count-compact {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.folder-count .folder-separator {
|
||||
color: var(--subtle);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
@container folder-pane (max-width: 205px) {
|
||||
.folder-count-wide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder-count-compact {
|
||||
display: inline;
|
||||
}
|
||||
}
|
||||
|
||||
.message-pane {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
|
||||
+5
-14
@@ -12,16 +12,7 @@
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<meta name="description" content="RustBox webmail">
|
||||
<title>RustBox</title>
|
||||
<script>
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const configured = root.dataset.defaultTheme || "system";
|
||||
root.dataset.theme = configured === "system"
|
||||
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||
: configured;
|
||||
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
|
||||
})();
|
||||
</script>
|
||||
<script src="__RUSTBOX_ASSET_PREFIX__/assets/theme-bootstrap.__RUSTBOX_ASSET_ID__.js"></script>
|
||||
<link rel="icon" href="__RUSTBOX_ASSET_PREFIX__/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="__RUSTBOX_ASSET_PREFIX__/assets/app.__RUSTBOX_ASSET_ID__.css">
|
||||
</head>
|
||||
@@ -77,13 +68,13 @@
|
||||
<kbd>/</kbd>
|
||||
</form>
|
||||
<div class="appbar-actions">
|
||||
<span id="indexing-indicator" class="indexing-indicator hidden" role="status" aria-live="polite"
|
||||
title="Mail synchronization" aria-label="Mail synchronization"><svg aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-refresh-cw"></use>
|
||||
</svg></span>
|
||||
<button id="contacts-btn" class="icon-button" type="button" title="Contacts" aria-label="Contacts"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-user-round"></use>
|
||||
</svg></button>
|
||||
<span id="indexing-indicator" class="indexing-indicator hidden" role="status" aria-live="polite"
|
||||
title="Indexing messages" aria-label="Indexing messages"><svg aria-hidden="true">
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-refresh-cw"></use>
|
||||
</svg></span>
|
||||
<button id="calendar-btn" class="icon-button" type="button" title="Calendar" aria-label="Calendar"><svg>
|
||||
<use href="__RUSTBOX_ASSET_PREFIX__/assets/icons.svg#icon-calendar-days"></use>
|
||||
</svg></button>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
pgpKeys: [],
|
||||
pgpPrefs: { wkdEnabled: true, hkpEnabled: false, requireTrustedEncryption: false },
|
||||
pgpRecipientStatus: [],
|
||||
authPolicy: { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true },
|
||||
authPolicy: { rememberMeEnabled: true, pgpEnabled: false, sieveEnabled: false, diagnosticsEnabled: false, aboutEnabled: false },
|
||||
contacts: [],
|
||||
trustedSenders: new Set(),
|
||||
contactGroups: [],
|
||||
@@ -63,6 +63,11 @@
|
||||
realtimeNotifiedMessages: new Map(),
|
||||
realtimeWatchedFolder: null,
|
||||
indexingActive: false,
|
||||
indexingPending: 0,
|
||||
imapSyncActive: false,
|
||||
imapSyncFolder: null,
|
||||
imapSyncPending: 0,
|
||||
imapSyncInventoryPending: 0,
|
||||
calendarReminderTimer: null,
|
||||
composeDraftId: null,
|
||||
composeAttachments: [],
|
||||
|
||||
@@ -481,17 +481,40 @@ function applyFeatureAvailability() {
|
||||
|
||||
async function loadAuthPolicy() {
|
||||
try {
|
||||
state.authPolicy = await request("/auth/policy", { method: "GET" });
|
||||
const policy = await request("/auth/policy", { method: "GET" });
|
||||
state.authPolicy = { ...state.authPolicy, ...policy };
|
||||
} catch (_) {
|
||||
state.authPolicy = { rememberMeEnabled: true, rememberMeDays: 30, sessionTimeoutHours: 24, pgpEnabled: true, sieveEnabled: true, diagnosticsEnabled: true, aboutEnabled: true };
|
||||
state.authPolicy = { ...state.authPolicy, rememberMeEnabled: true };
|
||||
}
|
||||
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) return;
|
||||
input.disabled = !enabled;
|
||||
if (!enabled) input.checked = false;
|
||||
});
|
||||
["#login-remember-label", "#add-account-remember-label"].forEach((selector) => {
|
||||
const label = $(selector);
|
||||
if (label) label.textContent = "Keep me signed in";
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSessionPolicy() {
|
||||
try {
|
||||
const policy = await request("/auth/session-policy", { method: "GET" });
|
||||
state.authPolicy = { ...state.authPolicy, ...policy };
|
||||
} catch (_) {
|
||||
state.authPolicy = {
|
||||
...state.authPolicy,
|
||||
pgpEnabled: false,
|
||||
sieveEnabled: false,
|
||||
diagnosticsEnabled: false,
|
||||
aboutEnabled: false,
|
||||
};
|
||||
}
|
||||
setOpenPgpAvailability(state.authPolicy.pgpEnabled !== false);
|
||||
applyFeatureAvailability();
|
||||
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() {
|
||||
@@ -607,9 +630,11 @@ async function reloadActiveAccount() {
|
||||
state.selectedMessages.clear();
|
||||
state.selectionMode = false;
|
||||
closeReader();
|
||||
await loadSessionPolicy();
|
||||
await loadPreferences();
|
||||
await loadMailPreferences();
|
||||
await loadOpenPgp();
|
||||
if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp();
|
||||
else setOpenPgpAvailability(false);
|
||||
await loadIdentities();
|
||||
await loadFolders(true);
|
||||
await loadSpecialFolders();
|
||||
@@ -684,9 +709,11 @@ async function login(event) {
|
||||
state.accountId = data.account.id;
|
||||
localStorage.setItem("rustbox.activeAccount", state.accountId);
|
||||
await loadAccounts();
|
||||
await loadSessionPolicy();
|
||||
await loadPreferences();
|
||||
await loadMailPreferences();
|
||||
await loadOpenPgp();
|
||||
if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp();
|
||||
else setOpenPgpAvailability(false);
|
||||
await loadIdentities();
|
||||
await loadFolders(true);
|
||||
await loadSpecialFolders();
|
||||
|
||||
@@ -86,6 +86,43 @@ function toggleFolderDrawer() {
|
||||
setFolderDrawerOpen(!$("#folder-pane")?.classList.contains("open"));
|
||||
}
|
||||
|
||||
function folderCounterParts(folder) {
|
||||
const total = Math.max(0, Number(folder?.total_count) || 0);
|
||||
const unread = Math.max(0, Number(folder?.unread_count) || 0);
|
||||
const title = unread > 0 ? `${total} messages · ${unread} unread` : `${total} messages · no unread`;
|
||||
const wide = unread > 0
|
||||
? `<span class="folder-count-wide"><span class="folder-total">${total}</span><span class="folder-separator"> / </span><span class="folder-unread">${unread}</span></span>`
|
||||
: `<span class="folder-count-wide"><span class="folder-total">${total}</span></span>`;
|
||||
const compactValue = unread > 0 ? unread : total;
|
||||
const compact = `<span class="folder-count-compact">${compactValue}</span>`;
|
||||
return { total, unread, title, html: `${wide}${compact}` };
|
||||
}
|
||||
|
||||
function updateFolderCounter(folder) {
|
||||
if (!folder) return;
|
||||
const parts = folderCounterParts(folder);
|
||||
const button = $$(".folder-button").find((item) => item.dataset.folder === folder.name);
|
||||
const counter = button?.querySelector(".folder-count");
|
||||
if (!counter) return;
|
||||
counter.classList.toggle("unread", parts.unread > 0);
|
||||
counter.title = parts.title;
|
||||
counter.innerHTML = parts.html;
|
||||
}
|
||||
|
||||
function setFolderCounts(folderName, totalCount, unreadCount) {
|
||||
const folder = state.folders.find((item) => item.name === folderName);
|
||||
if (!folder) return;
|
||||
if (Number.isFinite(Number(totalCount))) folder.total_count = Math.max(0, Number(totalCount));
|
||||
if (Number.isFinite(Number(unreadCount))) folder.unread_count = Math.max(0, Number(unreadCount));
|
||||
updateFolderCounter(folder);
|
||||
if (folderName === state.folder && !state.searchContext) {
|
||||
const total = Math.max(0, Number(folder.total_count) || 0);
|
||||
const count = $("#message-count");
|
||||
if (count) count.textContent = `${total} ${total === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
updateUnreadAppBadge();
|
||||
}
|
||||
|
||||
function renderFolders() {
|
||||
const list = $("#folder-list");
|
||||
list.replaceChildren();
|
||||
@@ -109,7 +146,9 @@ function renderFolders() {
|
||||
|
||||
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.dataset.folder = folder.name;
|
||||
const counter = folderCounterParts(folder);
|
||||
button.innerHTML = `${svgIcon(folderIconName(folder))}<span class="folder-name" title="${escapeHtml(folder.name)}">${escapeHtml(folderLeafName(folder))}</span><span class="folder-count ${counter.unread > 0 ? "unread" : ""}" title="${escapeHtml(counter.title)}">${counter.html}</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;
|
||||
@@ -160,6 +199,8 @@ async function loadFolders(restoreView = false) {
|
||||
try {
|
||||
const data = await request("/folders", { method: "GET" });
|
||||
state.folders = data.folders || [];
|
||||
setImapSyncInventoryPending(data.pending_sync_count);
|
||||
setIndexingIndicator(state.indexingActive, data.pending_index_count);
|
||||
updateUnreadAppBadge();
|
||||
loadCollapsedFolders();
|
||||
const inbox = state.folders.find((folder) => folder.name.toUpperCase() === "INBOX");
|
||||
@@ -172,6 +213,7 @@ async function loadFolders(restoreView = false) {
|
||||
}
|
||||
renderFolders();
|
||||
$("#connection-label").textContent = "Connected";
|
||||
setTimeout(() => { void refreshFolderListOnly({ forceServer: true }); }, 0);
|
||||
if (restoreView) {
|
||||
const restoredSearch = readActiveSearchContext();
|
||||
if (restoredSearch) {
|
||||
|
||||
@@ -6,10 +6,12 @@ async function loadMessages(reset = false, options = {}) {
|
||||
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>`;
|
||||
if (!options.background) {
|
||||
state.messages = [];
|
||||
state.selectedMessages.clear();
|
||||
clearActiveSearchContext();
|
||||
list.innerHTML = `<div class="empty-list">Loading messages…</div>`;
|
||||
}
|
||||
}
|
||||
$("#folder-title").textContent = state.folder;
|
||||
try {
|
||||
@@ -22,6 +24,7 @@ async function loadMessages(reset = false, options = {}) {
|
||||
let flaggedOnTop = state.currentFolderFlaggedOnTop;
|
||||
let folderTotalCount = null;
|
||||
let folderUnreadCount = null;
|
||||
let syncing = false;
|
||||
let lastLoadedPage = state.page;
|
||||
for (const page of pages) {
|
||||
const forceRefresh = options.force ? "&refresh=true" : "";
|
||||
@@ -30,6 +33,7 @@ async function loadMessages(reset = false, options = {}) {
|
||||
flaggedOnTop = Boolean(data.flagged_on_top);
|
||||
folderTotalCount = Number(data.folder_total_count);
|
||||
folderUnreadCount = Number(data.unread_count);
|
||||
syncing = syncing || Boolean(data.syncing);
|
||||
loaded.push(...(data.messages || []));
|
||||
lastLoadedPage = page;
|
||||
if (loaded.length >= totalCount) break;
|
||||
@@ -38,12 +42,14 @@ async function loadMessages(reset = false, options = {}) {
|
||||
state.total = totalCount;
|
||||
state.currentFolderFlaggedOnTop = flaggedOnTop;
|
||||
state.messages = reset ? loaded : [...state.messages, ...loaded];
|
||||
const folderState = state.folders.find((item) => item.name === state.folder);
|
||||
if (folderState) {
|
||||
if (Number.isFinite(folderTotalCount)) folderState.total_count = folderTotalCount;
|
||||
if (Number.isFinite(folderUnreadCount)) folderState.unread_count = folderUnreadCount;
|
||||
updateUnreadAppBadge();
|
||||
renderFolders();
|
||||
if (state.folders.some((item) => item.name === state.folder)) {
|
||||
setFolderCounts(state.folder, folderTotalCount, folderUnreadCount);
|
||||
}
|
||||
if (syncing) {
|
||||
const pending = Number.isFinite(folderTotalCount) ? Math.max(0, folderTotalCount - state.messages.length) : 0;
|
||||
setImapSyncIndicator(true, state.folder, pending);
|
||||
} else if (state.imapSyncFolder === state.folder) {
|
||||
setImapSyncIndicator(false, null, 0);
|
||||
}
|
||||
$("#connection-label").textContent = "Connected";
|
||||
renderMessages();
|
||||
@@ -59,28 +65,122 @@ async function loadMessages(reset = false, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedMessageFlags(value) {
|
||||
const source = Array.isArray(value) ? value : String(value || "").split(/[,\s]+/).filter(Boolean);
|
||||
return [...new Set(source.map((flag) => String(flag).trim()).filter(Boolean))].sort();
|
||||
}
|
||||
|
||||
function findMessageRow(folder, uid) {
|
||||
return $$(".message-row").find((row) => row.dataset.folder === String(folder) && Number(row.dataset.uid) === Number(uid)) || null;
|
||||
}
|
||||
|
||||
function updateMessageRowState(folder, uid, flags) {
|
||||
const row = findMessageRow(folder, uid);
|
||||
if (!row) return;
|
||||
const normalized = normalizedMessageFlags(flags);
|
||||
const unread = !normalized.includes("\\Seen");
|
||||
const flagged = normalized.includes("\\Flagged");
|
||||
row.classList.toggle("unread", unread);
|
||||
row.classList.toggle("flagged", flagged);
|
||||
const readButton = row.querySelector(".message-quick-action:not(.message-flag):not(.message-fullscreen-action)");
|
||||
if (readButton) {
|
||||
readButton.title = unread ? "Mark read" : "Mark unread";
|
||||
readButton.setAttribute("aria-label", readButton.title);
|
||||
}
|
||||
const flagButton = row.querySelector(".message-flag");
|
||||
if (flagButton) {
|
||||
flagButton.classList.toggle("active", flagged);
|
||||
flagButton.title = flagged ? "Unflag" : "Flag";
|
||||
flagButton.setAttribute("aria-label", flagButton.title);
|
||||
}
|
||||
}
|
||||
|
||||
function applyLocalMessageFlags(folder, uid, flags) {
|
||||
const normalized = normalizedMessageFlags(flags);
|
||||
const item = state.messages.find((message) => Number(message.uid) === Number(uid) && (message.folder || state.folder) === folder);
|
||||
if (item) item.flags = normalized.join(" ");
|
||||
if (state.currentDetail?.folder === folder && Number(state.currentDetail?.uid) === Number(uid)) {
|
||||
state.currentDetail.flags = [...normalized];
|
||||
$("#flag-btn")?.classList.toggle("active", normalized.includes("\\Flagged"));
|
||||
}
|
||||
updateMessageRowState(folder, uid, normalized);
|
||||
}
|
||||
|
||||
function applyOptimisticMessageFlag(folder, uid, flag, add) {
|
||||
const message = state.messages.find((item) => Number(item.uid) === Number(uid) && (item.folder || state.folder) === folder);
|
||||
const detailMatches = state.currentDetail?.folder === folder && Number(state.currentDetail?.uid) === Number(uid);
|
||||
const originalFlags = normalizedMessageFlags(message?.flags ?? (detailMatches ? state.currentDetail.flags : []));
|
||||
const next = new Set(originalFlags);
|
||||
add ? next.add(flag) : next.delete(flag);
|
||||
const optimisticFlags = normalizedMessageFlags([...next]);
|
||||
if (originalFlags.join("\u0000") === optimisticFlags.join("\u0000")) return () => {};
|
||||
|
||||
const folderState = state.folders.find((item) => item.name === folder);
|
||||
const originalTotal = Number(folderState?.total_count);
|
||||
const originalUnread = Number(folderState?.unread_count);
|
||||
let optimisticUnread = originalUnread;
|
||||
if (flag === "\\Seen" && Number.isFinite(originalUnread)) {
|
||||
const wasSeen = originalFlags.includes("\\Seen");
|
||||
const isSeen = optimisticFlags.includes("\\Seen");
|
||||
if (wasSeen !== isSeen) optimisticUnread = Math.max(0, originalUnread + (isSeen ? -1 : 1));
|
||||
}
|
||||
|
||||
applyLocalMessageFlags(folder, uid, optimisticFlags);
|
||||
if (Number.isFinite(originalTotal) && Number.isFinite(optimisticUnread) && optimisticUnread !== originalUnread) {
|
||||
setFolderCounts(folder, originalTotal, optimisticUnread);
|
||||
}
|
||||
|
||||
return () => {
|
||||
const current = state.messages.find((item) => Number(item.uid) === Number(uid) && (item.folder || state.folder) === folder);
|
||||
const currentFlags = normalizedMessageFlags(current?.flags ?? (detailMatches ? state.currentDetail?.flags : []));
|
||||
if (currentFlags.join("\u0000") === optimisticFlags.join("\u0000")) applyLocalMessageFlags(folder, uid, originalFlags);
|
||||
const latestFolder = state.folders.find((item) => item.name === folder);
|
||||
if (Number.isFinite(originalTotal) && Number.isFinite(originalUnread) && Number(latestFolder?.unread_count) === optimisticUnread) {
|
||||
setFolderCounts(folder, originalTotal, originalUnread);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function updateActiveMessageRow() {
|
||||
for (const row of $$(".message-row")) {
|
||||
row.classList.toggle(
|
||||
"active",
|
||||
Boolean(state.selected) && row.dataset.folder === state.selected.folder && Number(row.dataset.uid) === Number(state.selected.uid),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function quickUpdateMessageFlag(message, flag, add, successMessage) {
|
||||
const folder = message.folder || state.folder;
|
||||
const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add);
|
||||
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);
|
||||
const result = await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
|
||||
const flags = Array.isArray(result.flags) ? result.flags : normalizedMessageFlags(message.flags);
|
||||
if (!Array.isArray(result.flags)) {
|
||||
const next = new Set(flags);
|
||||
add ? next.add(flag) : next.delete(flag);
|
||||
applyLocalMessageFlags(folder, message.uid, [...next]);
|
||||
} else {
|
||||
applyLocalMessageFlags(folder, message.uid, flags);
|
||||
}
|
||||
if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
|
||||
setFolderCounts(folder, Number(result.total_count), Number(result.unread_count));
|
||||
}
|
||||
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) {
|
||||
await loadMessages(true, { background: true });
|
||||
}
|
||||
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && folder === state.folder) await loadMessages(true);
|
||||
else renderMessages();
|
||||
toast(successMessage, "success");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
} catch (error) {
|
||||
rollback();
|
||||
toast(error.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
function renderMessages() {
|
||||
$("#folder-title").textContent = state.folder;
|
||||
$("#message-count").textContent = `${state.total} ${state.total === 1 ? "message" : "messages"}`;
|
||||
const folderTotal = Number(state.folders.find((folder) => folder.name === state.folder)?.total_count);
|
||||
const displayTotal = !state.searchContext && Number.isFinite(folderTotal) ? folderTotal : state.total;
|
||||
$("#message-count").textContent = `${displayTotal} ${displayTotal === 1 ? "message" : "messages"}`;
|
||||
const list = $("#message-list");
|
||||
const highlightTerms = activeSearchHighlightTerms();
|
||||
list.classList.toggle("selection-mode", state.selectionMode);
|
||||
@@ -94,6 +194,8 @@ function renderMessages() {
|
||||
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.dataset.folder = folder;
|
||||
row.dataset.uid = String(message.uid);
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.setAttribute("aria-label", `${message.from_name || message.from_address || "Unknown"}: ${message.subject || "(no subject)"}`);
|
||||
@@ -122,13 +224,21 @@ function renderMessages() {
|
||||
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"); });
|
||||
readButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const currentUnread = !normalizedMessageFlags(message.flags).includes("\\Seen");
|
||||
quickUpdateMessageFlag(message, "\\Seen", currentUnread, currentUnread ? "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"); });
|
||||
flagButton.addEventListener("click", (event) => {
|
||||
event.stopPropagation();
|
||||
const currentFlagged = normalizedMessageFlags(message.flags).includes("\\Flagged");
|
||||
quickUpdateMessageFlag(message, "\\Flagged", !currentFlagged, currentFlagged ? "Flag removed" : "Message flagged");
|
||||
});
|
||||
const fullscreenButton = el("button", "message-quick-action message-fullscreen-action");
|
||||
fullscreenButton.type = "button";
|
||||
fullscreenButton.title = "Open full screen";
|
||||
@@ -281,7 +391,7 @@ async function openMessage(folder, uid) {
|
||||
clearTimeout(state.markReadTimer);
|
||||
state.markReadTimer = null;
|
||||
state.selected = { folder, uid };
|
||||
renderMessages();
|
||||
updateActiveMessageRow();
|
||||
updateMessageNavigation();
|
||||
$("#reader-empty").classList.add("hidden");
|
||||
$("#reader-content").classList.remove("hidden");
|
||||
@@ -311,16 +421,15 @@ function scheduleMarkRead(detail) {
|
||||
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;
|
||||
const rollback = applyOptimisticMessageFlag(detail.folder, detail.uid, "\\Seen", true);
|
||||
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(" ");
|
||||
const result = await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: ["\\Seen"], add: true }) });
|
||||
const flags = Array.isArray(result.flags) ? result.flags : [...new Set([...(detail.flags || []), "\\Seen"])];
|
||||
applyLocalMessageFlags(detail.folder, detail.uid, flags);
|
||||
if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
|
||||
setFolderCounts(detail.folder, Number(result.total_count), Number(result.unread_count));
|
||||
}
|
||||
renderMessages();
|
||||
} catch (_) { }
|
||||
} catch (_) { rollback(); }
|
||||
};
|
||||
if (delay === 0) run(); else state.markReadTimer = setTimeout(run, delay);
|
||||
}
|
||||
|
||||
@@ -2,17 +2,30 @@
|
||||
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);
|
||||
const rollback = applyOptimisticMessageFlag(detail.folder, detail.uid, flag, add);
|
||||
try {
|
||||
const result = await request(`/messages/${encodeURIComponent(detail.folder)}/${detail.uid}/flags`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ flags: [flag], add }),
|
||||
});
|
||||
const flags = Array.isArray(result.flags) ? result.flags : normalizedMessageFlags(detail.flags);
|
||||
if (!Array.isArray(result.flags)) {
|
||||
const next = new Set(flags);
|
||||
add ? next.add(flag) : next.delete(flag);
|
||||
applyLocalMessageFlags(detail.folder, detail.uid, [...next]);
|
||||
} else {
|
||||
applyLocalMessageFlags(detail.folder, detail.uid, flags);
|
||||
}
|
||||
if (Number.isFinite(Number(result.total_count)) && Number.isFinite(Number(result.unread_count))) {
|
||||
setFolderCounts(detail.folder, Number(result.total_count), Number(result.unread_count));
|
||||
}
|
||||
if (flag === "\\Flagged" && state.currentFolderFlaggedOnTop && !state.searchContext && detail.folder === state.folder) {
|
||||
await loadMessages(true, { background: true });
|
||||
}
|
||||
} catch (error) {
|
||||
rollback();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleUnread() {
|
||||
@@ -231,7 +244,23 @@ async function performBulk(action, targetFolder = null) {
|
||||
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 }) });
|
||||
const rollback = applyOptimisticMessageFlag(folder, message.uid, flag, add);
|
||||
let result;
|
||||
try {
|
||||
result = await request(`/messages/${encodeURIComponent(folder)}/${message.uid}/flags`, { method: "PATCH", body: JSON.stringify({ flags: [flag], add }) });
|
||||
} catch (error) {
|
||||
rollback();
|
||||
throw error;
|
||||
}
|
||||
const nextFlags = Array.isArray(result?.flags) ? result.flags : (() => {
|
||||
const flags = new Set(normalizedMessageFlags(message.flags));
|
||||
add ? flags.add(flag) : flags.delete(flag);
|
||||
return [...flags];
|
||||
})();
|
||||
applyLocalMessageFlags(folder, message.uid, nextFlags);
|
||||
if (Number.isFinite(Number(result?.total_count)) && Number.isFinite(Number(result?.unread_count))) {
|
||||
setFolderCounts(folder, Number(result.total_count), Number(result.unread_count));
|
||||
}
|
||||
} else if (action === "move" || action === "archive" || action === "junk" || action === "delete") {
|
||||
let destination = targetFolder;
|
||||
if (action === "archive") destination = detectSpecialFolder("archive");
|
||||
@@ -252,8 +281,17 @@ async function performBulk(action, targetFolder = null) {
|
||||
}
|
||||
state.selectedMessages.clear();
|
||||
state.searchAllResults = null;
|
||||
await refreshFolderListOnly();
|
||||
await refreshCurrentMessageView();
|
||||
const flagsOnly = ["read", "unread", "flag", "unflag"].includes(action);
|
||||
const orderingChanged = ["flag", "unflag"].includes(action) && state.currentFolderFlaggedOnTop && !state.searchContext;
|
||||
if (!flagsOnly) {
|
||||
await refreshFolderListOnly({ forceServer: true });
|
||||
await refreshCurrentMessageView();
|
||||
} else if (state.searchContext || orderingChanged) {
|
||||
await refreshCurrentMessageView();
|
||||
} else {
|
||||
renderBulkToolbar();
|
||||
updateUnreadAppBadge();
|
||||
}
|
||||
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);
|
||||
@@ -264,12 +302,28 @@ async function performBulk(action, targetFolder = null) {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshFolderListOnly() {
|
||||
async function refreshFolderListOnly(options = {}) {
|
||||
try {
|
||||
const data = await request("/folders", { method: "GET" });
|
||||
state.folders = data.folders || [];
|
||||
const suffix = options.forceServer ? "?refresh=true" : "";
|
||||
const data = await request(`/folders${suffix}`, { method: "GET" });
|
||||
const nextFolders = data.folders || [];
|
||||
setImapSyncInventoryPending(data.pending_sync_count);
|
||||
setIndexingIndicator(state.indexingActive, data.pending_index_count);
|
||||
const previousNames = state.folders.map((folder) => folder.name).join("\u0000");
|
||||
const nextNames = nextFolders.map((folder) => folder.name).join("\u0000");
|
||||
if (previousNames !== nextNames || state.folders.length !== nextFolders.length) {
|
||||
state.folders = nextFolders;
|
||||
renderFolders();
|
||||
} else {
|
||||
const byName = new Map(nextFolders.map((folder) => [folder.name, folder]));
|
||||
for (const folder of state.folders) {
|
||||
const fresh = byName.get(folder.name);
|
||||
if (!fresh) continue;
|
||||
Object.assign(folder, fresh);
|
||||
updateFolderCounter(folder);
|
||||
}
|
||||
}
|
||||
updateUnreadAppBadge();
|
||||
renderFolders();
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
@@ -621,9 +675,22 @@ function showFolderProperties(folder) {
|
||||
|
||||
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();
|
||||
const result = await request(`/folders/${encodeURIComponent(name)}/mark-all-read`, { method: "POST", body: "{}" });
|
||||
if (state.folder === name) {
|
||||
const throughUid = Number(result?.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(name, message.uid, flags);
|
||||
}
|
||||
}
|
||||
const folder = state.folders.find((item) => item.name === name);
|
||||
setFolderCounts(
|
||||
name,
|
||||
Number.isFinite(Number(result?.total_count)) ? Number(result.total_count) : Number(folder?.total_count || 0),
|
||||
Number.isFinite(Number(result?.unread_count)) ? Number(result.unread_count) : 0,
|
||||
);
|
||||
toast("All messages marked read");
|
||||
} catch (error) { toast(error.message, "error"); }
|
||||
}
|
||||
|
||||
@@ -160,17 +160,48 @@ function playNotificationSound() {
|
||||
} catch (_) { }
|
||||
}
|
||||
|
||||
function setIndexingIndicator(active, pending = 0) {
|
||||
state.indexingActive = Boolean(active);
|
||||
function renderSyncActivityIndicator() {
|
||||
const indicator = $("#indexing-indicator");
|
||||
if (!indicator) return;
|
||||
indicator.classList.toggle("hidden", !state.indexingActive);
|
||||
const count = Math.max(0, Number(pending) || 0);
|
||||
const label = count > 0 ? `Indexing messages (${count} pending)` : "Indexing messages";
|
||||
const busy = Boolean(state.indexingActive || state.imapSyncActive);
|
||||
indicator.classList.toggle("hidden", !busy);
|
||||
indicator.classList.toggle("active", busy);
|
||||
const parts = [];
|
||||
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 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;
|
||||
@@ -296,23 +327,62 @@ function realtimeEventKey(payload) {
|
||||
function isDuplicateRealtimeEvent(payload) {
|
||||
const now = Date.now();
|
||||
for (const [key, seenAt] of state.realtimeSeenEvents) if (now - seenAt > 10000) state.realtimeSeenEvents.delete(key);
|
||||
|
||||
// Server websocket envelopes carry a unique eventId. When present it is the
|
||||
// only safe deduplication key: state can legitimately cycle A -> B -> A in
|
||||
// under a second (for example unread 1 -> 0 -> 1). Fingerprint-based
|
||||
// deduplication would incorrectly drop the second A and leave stale UI.
|
||||
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 idKey = payload.eventId ? `id:${payload.eventId}` : null;
|
||||
const fingerprintKey = `event:${fingerprint}`;
|
||||
const duplicate = Boolean((idKey && state.realtimeSeenEvents.has(idKey)) || (state.realtimeSeenEvents.get(fingerprintKey) && now - state.realtimeSeenEvents.get(fingerprintKey) < 1500));
|
||||
if (idKey) state.realtimeSeenEvents.set(idKey, now);
|
||||
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();
|
||||
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);
|
||||
else await loadMessages(true, { background: true });
|
||||
})().catch(() => { }).finally(() => { state.realtimeSyncPromise = null; });
|
||||
return state.realtimeSyncPromise;
|
||||
}
|
||||
@@ -323,13 +393,16 @@ function scheduleRealtimeRefresh(event, accountId) {
|
||||
clearTimeout(state.realtimeRefreshTimer);
|
||||
state.realtimeRefreshTimer = setTimeout(async () => {
|
||||
const events = state.realtimeRefreshEvents.splice(0);
|
||||
const refreshCurrent = events.some((item) => item.type === "FolderUpdated" || (["NewMessages", "FlagsChanged"].includes(item.type) && item.data?.folder === state.folder));
|
||||
const tasks = [refreshFolderListOnly()];
|
||||
if (refreshCurrent) {
|
||||
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));
|
||||
else tasks.push(loadMessages(true, { background: true }));
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
if (tasks.length) await Promise.all(tasks);
|
||||
}, 80);
|
||||
}
|
||||
|
||||
@@ -402,10 +475,29 @@ function connectRealtime() {
|
||||
notifyNewMail(data, payload.accountId);
|
||||
}
|
||||
}
|
||||
if (event.type === "IndexingStatus" && payload.accountId === state.accountId) {
|
||||
setIndexingIndicator(Boolean(event.data?.active), event.data?.pending);
|
||||
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", "FlagsChanged", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
if (["NewMessages", "FolderUpdated"].includes(event.type)) scheduleRealtimeRefresh(event, payload.accountId);
|
||||
} catch (error) { console.warn("Invalid realtime event", error); }
|
||||
};
|
||||
socket.onclose = () => {
|
||||
@@ -413,6 +505,7 @@ function connectRealtime() {
|
||||
state.ws = null;
|
||||
state.realtimeWatchedFolder = null;
|
||||
setIndexingIndicator(false);
|
||||
setImapSyncIndicator(false, null, 0);
|
||||
clearInterval(state.wsWatchdogTimer);
|
||||
state.wsWatchdogTimer = null;
|
||||
scheduleRealtimeReconnect();
|
||||
@@ -435,6 +528,7 @@ function disconnectRealtime() {
|
||||
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");
|
||||
@@ -516,7 +610,8 @@ async function loadDiagnostics() {
|
||||
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` : ""}`;
|
||||
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;
|
||||
@@ -542,6 +637,7 @@ 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`);
|
||||
@@ -549,6 +645,7 @@ async function rebuildSearchIndex() {
|
||||
} catch (error) {
|
||||
toast(error.message, "error");
|
||||
} finally {
|
||||
setIndexingIndicator(false, 0);
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -237,7 +237,7 @@ function bindEvents() {
|
||||
const button = $("#refresh-btn");
|
||||
setBusy(button, true);
|
||||
try {
|
||||
const folderRefresh = refreshFolderListOnly();
|
||||
const folderRefresh = refreshFolderListOnly({ forceServer: true });
|
||||
if (state.searchContext) {
|
||||
await Promise.all([folderRefresh, runSearch(new URLSearchParams(state.searchContext.params), state.searchContext.label, { restoreScrollTop: state.searchContext.scrollTop })]);
|
||||
} else {
|
||||
@@ -266,7 +266,7 @@ function bindEvents() {
|
||||
disconnectRealtime();
|
||||
connectRealtime();
|
||||
if (state.accounts.length) {
|
||||
await refreshFolderListOnly();
|
||||
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);
|
||||
}
|
||||
@@ -559,7 +559,7 @@ async function boot() {
|
||||
await loadAuthPolicy();
|
||||
const authenticated = await loadAccounts();
|
||||
if (authenticated) {
|
||||
await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime();
|
||||
await loadSessionPolicy(); await loadPreferences(); await loadMailPreferences(); if (state.authPolicy.pgpEnabled !== false) await loadOpenPgp(); else setOpenPgpAvailability(false); await loadIdentities(); await loadResponseTemplates(); await loadFolders(true); await loadSpecialFolders(); await loadQuota(); await loadTags(); await loadNotificationPreferences(); await loadTrustedSenders(); startCalendarReminderWatcher(); connectRealtime();
|
||||
const params = new URLSearchParams(location.search);
|
||||
const folder = params.get("mail_folder");
|
||||
const uid = Number(params.get("mail_uid"));
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
(() => {
|
||||
const root = document.documentElement;
|
||||
const configured = root.dataset.defaultTheme || "system";
|
||||
root.dataset.theme = configured === "system"
|
||||
? (matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark")
|
||||
: configured;
|
||||
root.dataset.interfaceSkin = root.dataset.defaultInterfaceSkin || "rustbox";
|
||||
})();
|
||||
Reference in new issue
Block a user