1 parent
dacdf9c27d
commit
a116890e20
34 files changed
+186
-83
No files matched your search
@@ -57,9 +57,9 @@ pub fn update_preferences(
|
||||
.map_err(|error| format!("Failed to initialize mail preferences: {error}"))?;
|
||||
|
||||
if let Some(value) = data.messages_per_page
|
||||
&& !(20..=200).contains(&value)
|
||||
&& !(20..=300).contains(&value)
|
||||
{
|
||||
return Err("messages_per_page must be between 20 and 200".to_string());
|
||||
return Err("messages_per_page must be between 20 and 300".to_string());
|
||||
}
|
||||
if let Some(value) = data.mark_read_delay_ms
|
||||
&& value > 30_000
|
||||
@@ -151,7 +151,7 @@ mod tests {
|
||||
fn defaults_and_update_work() {
|
||||
let conn = open_test_db();
|
||||
let defaults = get_preferences(&conn).unwrap();
|
||||
assert_eq!(defaults.messages_per_page, 50);
|
||||
assert_eq!(defaults.messages_per_page, 300);
|
||||
assert_eq!(defaults.remote_images, "ask");
|
||||
|
||||
let updated = update_preferences(
|
||||
|
||||
+3
-3
@@ -1923,19 +1923,19 @@ fn tenant_seed_sql(backend: DatabaseBackend, prefix: &str) -> String {
|
||||
DatabaseBackend::Sqlite => format!(
|
||||
"INSERT OR IGNORE INTO {prefix}user_meta (key, value) VALUES ('schema_version', '1');\n\
|
||||
INSERT OR IGNORE INTO {prefix}notification_preferences (id) VALUES (1);\n\
|
||||
INSERT OR IGNORE INTO {prefix}mail_preferences (id) VALUES (1);\n\
|
||||
INSERT OR IGNORE INTO {prefix}mail_preferences (id, messages_per_page) VALUES (1, 300);\n\
|
||||
INSERT OR IGNORE INTO {prefix}pgp_preferences (id) VALUES (1);"
|
||||
),
|
||||
DatabaseBackend::Mysql => format!(
|
||||
"INSERT IGNORE INTO {prefix}user_meta (`key`, value) VALUES ('schema_version', '1');\n\
|
||||
INSERT IGNORE INTO {prefix}notification_preferences (id) VALUES (1);\n\
|
||||
INSERT IGNORE INTO {prefix}mail_preferences (id) VALUES (1);\n\
|
||||
INSERT IGNORE INTO {prefix}mail_preferences (id, messages_per_page) VALUES (1, 300);\n\
|
||||
INSERT IGNORE INTO {prefix}pgp_preferences (id) VALUES (1);"
|
||||
),
|
||||
DatabaseBackend::Pgsql => format!(
|
||||
"INSERT INTO {prefix}user_meta (key, value) VALUES ('schema_version', '1') ON CONFLICT (key) DO NOTHING;\n\
|
||||
INSERT INTO {prefix}notification_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n\
|
||||
INSERT INTO {prefix}mail_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;\n\
|
||||
INSERT INTO {prefix}mail_preferences (id, messages_per_page) VALUES (1, 300) ON CONFLICT (id) DO NOTHING;\n\
|
||||
INSERT INTO {prefix}pgp_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING;"
|
||||
),
|
||||
}
|
||||
|
||||
+2
-2
@@ -14,7 +14,7 @@ pub fn reset_all(conn: &mut Connection) -> Result<(), String> {
|
||||
"DELETE FROM calendar_settings",
|
||||
"DELETE FROM pgp_preferences",
|
||||
"INSERT INTO display_preferences (id) VALUES (1)",
|
||||
"INSERT INTO mail_preferences (id) VALUES (1)",
|
||||
"INSERT INTO mail_preferences (id, messages_per_page) VALUES (1, 300)",
|
||||
"INSERT INTO notification_preferences (id) VALUES (1)",
|
||||
"INSERT INTO calendar_settings (id) VALUES (1)",
|
||||
"INSERT INTO pgp_preferences (id) VALUES (1)",
|
||||
@@ -85,7 +85,7 @@ mod tests {
|
||||
mail_preferences::get_preferences(&conn)
|
||||
.unwrap()
|
||||
.messages_per_page,
|
||||
50
|
||||
300
|
||||
);
|
||||
assert_eq!(
|
||||
folder_preferences::get_preferences(&conn, "INBOX")
|
||||
|
||||
@@ -270,7 +270,8 @@ pub mod db_identities {
|
||||
|
||||
pub mod db_mail_preferences {
|
||||
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
|
||||
pub const Q_002: &str = "INSERT IGNORE INTO mail_preferences (id) VALUES (1)";
|
||||
pub const Q_002: &str =
|
||||
"INSERT IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
|
||||
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?, mark_read_delay_ms = ?, remote_images = ?, preferred_body = ?, thread_mode = ?, collapse_quotes = ?, show_snippets = ?, default_sort = ?, flagged_on_top = ?, sync_imap_keywords = ?, updated_at = CURRENT_TIMESTAMP WHERE id = 1";
|
||||
}
|
||||
|
||||
|
||||
@@ -270,8 +270,7 @@ pub mod db_identities {
|
||||
|
||||
pub mod db_mail_preferences {
|
||||
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
|
||||
pub const Q_002: &str =
|
||||
"INSERT INTO mail_preferences (id) VALUES (1) ON CONFLICT (id) DO NOTHING";
|
||||
pub const Q_002: &str = "INSERT INTO mail_preferences (id, messages_per_page) VALUES (1, 300) ON CONFLICT (id) DO NOTHING";
|
||||
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = $1, mark_read_delay_ms = $2, remote_images = $3, preferred_body = $4, thread_mode = $5, collapse_quotes = $6, show_snippets = $7, default_sort = $8, flagged_on_top = $9, sync_imap_keywords = $10, updated_at = CURRENT_TIMESTAMP::text WHERE id = 1";
|
||||
}
|
||||
|
||||
|
||||
@@ -269,7 +269,8 @@ pub mod db_identities {
|
||||
|
||||
pub mod db_mail_preferences {
|
||||
pub const Q_001: &str = "SELECT messages_per_page, mark_read_delay_ms, remote_images, preferred_body, thread_mode, collapse_quotes, show_snippets, default_sort, flagged_on_top, sync_imap_keywords, updated_at FROM mail_preferences WHERE id = 1";
|
||||
pub const Q_002: &str = "INSERT OR IGNORE INTO mail_preferences (id) VALUES (1)";
|
||||
pub const Q_002: &str =
|
||||
"INSERT OR IGNORE INTO mail_preferences (id, messages_per_page) VALUES (1, 300)";
|
||||
pub const Q_003: &str = "UPDATE mail_preferences SET messages_per_page = ?1, mark_read_delay_ms = ?2, remote_images = ?3, preferred_body = ?4, thread_mode = ?5, collapse_quotes = ?6, show_snippets = ?7, default_sort = ?8, flagged_on_top = ?9, sync_imap_keywords = ?10, updated_at = datetime('now') WHERE id = 1";
|
||||
}
|
||||
|
||||
|
||||
@@ -63,9 +63,9 @@ pub async fn list_messages(
|
||||
Path(folder): Path<String>,
|
||||
Query(query): Query<ListMessagesQuery>,
|
||||
) -> Result<Response, AppError> {
|
||||
if !(1..=200).contains(&query.per_page) {
|
||||
if !(1..=300).contains(&query.per_page) {
|
||||
return Err(AppError::BadRequest(
|
||||
"per_page must be between 1 and 200".to_string(),
|
||||
"per_page must be between 1 and 300".to_string(),
|
||||
));
|
||||
}
|
||||
if !matches!(
|
||||
|
||||
@@ -3,7 +3,7 @@ use serde::{Deserialize, Serialize};
|
||||
pub use crate::email_theme::EmailTheme;
|
||||
|
||||
pub fn default_per_page() -> u32 {
|
||||
50
|
||||
300
|
||||
}
|
||||
|
||||
pub fn default_sort() -> String {
|
||||
|
||||
@@ -55,5 +55,6 @@
|
||||
"message_details": "Message details",
|
||||
"move_copy": "Move / copy",
|
||||
"move_to": "Move to…",
|
||||
"copy_to": "Copy to…"
|
||||
"copy_to": "Copy to…",
|
||||
"login_success": "Successfully logged in"
|
||||
}
|
||||
@@ -640,5 +640,6 @@
|
||||
"message_details": "Szczegóły",
|
||||
"move_copy": "Przenieś / kopiuj",
|
||||
"move_to": "Przenieś do…",
|
||||
"copy_to": "Kopiuj do…"
|
||||
"copy_to": "Kopiuj do…",
|
||||
"login_success": "Pomyślnie zalogowano"
|
||||
}
|
||||
+2
-1
@@ -784,6 +784,7 @@
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="200">200</option>
|
||||
<option value="300">300</option>
|
||||
</select></label>
|
||||
<label><span>Mark as read</span><select id="mail-pref-read-delay">
|
||||
<option value="0">Immediately</option>
|
||||
@@ -1460,7 +1461,7 @@
|
||||
<dl>
|
||||
<dt><kbd>/</kbd></dt>
|
||||
<dd>Focus search</dd>
|
||||
<dt><kbd>C</kbd> / <kbd>Ctrl</kbd> + <kbd>N</kbd></dt>
|
||||
<dt><kbd>C</kbd></dt>
|
||||
<dd>New message</dd>
|
||||
<dt><kbd>V</kbd></dt>
|
||||
<dd>Toggle selection mode</dd>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
folder: "INBOX",
|
||||
collapsedFolders: new Set(),
|
||||
page: 0,
|
||||
perPage: 50,
|
||||
perPage: 300,
|
||||
total: 0,
|
||||
messages: [],
|
||||
selected: null,
|
||||
@@ -45,7 +45,7 @@
|
||||
savedSearches: [],
|
||||
responseTemplates: [],
|
||||
notificationPrefs: { enabled: true, sound: false, folders: ["INBOX"], dnd_enabled: false, dnd_start: "22:00", dnd_end: "07:00" },
|
||||
mailPrefs: { messages_per_page: 50, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false },
|
||||
mailPrefs: { messages_per_page: 300, mark_read_delay_ms: 0, remote_images: "ask", preferred_body: "auto", thread_mode: "threaded", collapse_quotes: true, show_snippets: true, default_sort: "date_desc", flagged_on_top: false, sync_imap_keywords: false },
|
||||
currentFolderFlaggedOnTop: false,
|
||||
markReadTimer: null,
|
||||
ws: null,
|
||||
@@ -61,7 +61,12 @@
|
||||
realtimeSyncPromise: null,
|
||||
realtimeSeenEvents: new Map(),
|
||||
realtimeNotifiedMessages: new Map(),
|
||||
realtimeNotificationSuppressions: new Map(),
|
||||
realtimeNotificationsReady: false,
|
||||
realtimeNotificationsArmRequested: false,
|
||||
realtimeNotificationsReadyTimer: null,
|
||||
realtimeWatchedFolder: null,
|
||||
startupToastsMuted: true,
|
||||
indexingActive: false,
|
||||
indexingPending: 0,
|
||||
imapSyncActive: false,
|
||||
|
||||
@@ -171,6 +171,7 @@ function uploadRequest(path, formData, onProgress = null, signal = null) {
|
||||
}
|
||||
|
||||
function toast(message, kind = "info", duration = null, action = null) {
|
||||
if (state.startupToastsMuted) return null;
|
||||
const normalized = ["success", "error", "warning", "info"].includes(kind) ? kind : "info";
|
||||
const lifetime = Number(duration) > 0 ? Number(duration) : normalized === "error" ? 6500 : normalized === "warning" ? 5200 : 3800;
|
||||
const node = el("div", `toast toast-${normalized}`);
|
||||
|
||||
@@ -315,7 +315,7 @@ async function loadMailPreferences() {
|
||||
try {
|
||||
const prefs = await request("/settings/mailbox", { method: "GET" });
|
||||
state.mailPrefs = { ...state.mailPrefs, ...prefs };
|
||||
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
|
||||
state.perPage = Number(state.mailPrefs.messages_per_page || 300);
|
||||
const values = {
|
||||
"#mail-pref-per-page": String(state.perPage),
|
||||
"#mail-pref-read-delay": String(state.mailPrefs.mark_read_delay_ms || 0),
|
||||
@@ -353,7 +353,7 @@ async function saveMailPreferences() {
|
||||
};
|
||||
try {
|
||||
state.mailPrefs = await request("/settings/mailbox", { method: "PUT", body: JSON.stringify(payload) });
|
||||
state.perPage = Number(state.mailPrefs.messages_per_page || 50);
|
||||
state.perPage = Number(state.mailPrefs.messages_per_page || 300);
|
||||
toast("Mailbox settings saved");
|
||||
if (state.currentDetail) renderMessageDetail(state.currentDetail);
|
||||
await loadMessages(true);
|
||||
@@ -458,6 +458,17 @@ function showApp() {
|
||||
$("#app-view").classList.remove("hidden");
|
||||
}
|
||||
|
||||
function finishAuthenticatedStartup() {
|
||||
state.startupToastsMuted = false;
|
||||
armRealtimeNotifications();
|
||||
toast(tr("login_success", "Successfully logged in"), "success");
|
||||
}
|
||||
|
||||
function prepareAuthenticatedStartup() {
|
||||
state.startupToastsMuted = true;
|
||||
resetRealtimeNotificationGate();
|
||||
}
|
||||
|
||||
function settingsTabAvailable(name) {
|
||||
if (name === "openpgp") return state.authPolicy.pgpEnabled !== false;
|
||||
if (name === "sieve") return state.authPolicy.sieveEnabled !== false;
|
||||
@@ -697,6 +708,7 @@ async function login(event) {
|
||||
const errorBox = $("#login-error");
|
||||
errorBox.classList.add("hidden");
|
||||
setBusy(submit, true);
|
||||
prepareAuthenticatedStartup();
|
||||
try {
|
||||
const data = await request("/auth/login", {
|
||||
method: "POST",
|
||||
@@ -723,6 +735,7 @@ async function login(event) {
|
||||
await loadTrustedSenders();
|
||||
startCalendarReminderWatcher();
|
||||
connectRealtime();
|
||||
finishAuthenticatedStartup();
|
||||
} catch (error) {
|
||||
errorBox.textContent = error.message;
|
||||
errorBox.classList.remove("hidden");
|
||||
@@ -733,6 +746,7 @@ async function login(event) {
|
||||
|
||||
async function logout() {
|
||||
disconnectRealtime();
|
||||
prepareAuthenticatedStartup();
|
||||
if (state.calendarReminderTimer) { clearInterval(state.calendarReminderTimer); state.calendarReminderTimer = null; }
|
||||
try { await request("/auth/logout", { method: "POST", body: "{}" }); } catch (_) { }
|
||||
state.accountId = null;
|
||||
|
||||
@@ -235,6 +235,14 @@ async function performBulk(action, targetFolder = null) {
|
||||
}
|
||||
const permanentDelete = action === "delete" && items.every((message) => (message.folder || state.folder) === detectSpecialFolder("trash"));
|
||||
if (permanentDelete && !await appConfirm(`Permanently delete ${items.length} selected messages?`, { title: "Delete selected messages", confirmLabel: "Delete permanently", danger: true })) return;
|
||||
let notificationDestination = null;
|
||||
if (action === "move" || action === "copy") notificationDestination = targetFolder;
|
||||
else if (action === "archive") notificationDestination = detectSpecialFolder("archive");
|
||||
else if (action === "junk") notificationDestination = bulkJunkToInbox ? inboxFolder() : detectSpecialFolder("junk");
|
||||
else if (action === "delete" && !permanentDelete) notificationDestination = detectSpecialFolder("trash");
|
||||
const releaseRealtimeNotificationSuppression = notificationDestination
|
||||
? suppressRealtimeNotificationsForFolder(notificationDestination)
|
||||
: null;
|
||||
let completed = 0;
|
||||
const undoMoves = [];
|
||||
let movedOperations = 0;
|
||||
@@ -299,6 +307,8 @@ async function performBulk(action, targetFolder = null) {
|
||||
toast(`${completed}/${items.length} completed: ${error.message}`, "error");
|
||||
state.searchAllResults = null;
|
||||
await refreshCurrentMessageView();
|
||||
} finally {
|
||||
releaseRealtimeNotificationSuppression?.();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -219,6 +219,54 @@ function newMailNotificationKey(data, accountId) {
|
||||
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 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 realtimeNewMailNotificationsSuppressed(data, accountId) {
|
||||
if (!state.realtimeNotificationsReady) 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.realtimeNotifiedMessages.clear();
|
||||
}
|
||||
|
||||
function isDuplicateNewMailNotification(data, accountId) {
|
||||
const now = Date.now();
|
||||
for (const [key, seenAt] of state.realtimeNotifiedMessages) {
|
||||
@@ -455,6 +503,7 @@ function connectRealtime() {
|
||||
state.realtimeWatchedFolder = null;
|
||||
startRealtimeWatchdog(socket);
|
||||
syncRealtimeWatchedFolder(true);
|
||||
scheduleRealtimeNotificationsReady();
|
||||
if (reconnect) void resyncRealtimeState();
|
||||
};
|
||||
socket.onmessage = (message) => {
|
||||
@@ -470,7 +519,8 @@ function connectRealtime() {
|
||||
const event = payload.event || {};
|
||||
if (event.type === "NewMessages") {
|
||||
const data = event.data || {};
|
||||
if (!isDuplicateNewMailNotification(data, payload.accountId)) {
|
||||
const duplicate = isDuplicateNewMailNotification(data, payload.accountId);
|
||||
if (!duplicate && !realtimeNewMailNotificationsSuppressed(data, payload.accountId)) {
|
||||
toastNewMail(data, payload.accountId);
|
||||
notifyNewMail(data, payload.accountId);
|
||||
}
|
||||
|
||||
@@ -497,19 +497,19 @@ function bindEvents() {
|
||||
if (event.key === "/" && !["INPUT", "TEXTAREA"].includes(tag) && !document.activeElement?.isContentEditable) {
|
||||
event.preventDefault(); $("#search-input").focus();
|
||||
}
|
||||
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "n") { event.preventDefault(); openCompose("new"); }
|
||||
if (event.key === "?" && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable) {
|
||||
event.preventDefault();
|
||||
if (!document.querySelector("dialog[open]")) $("#shortcuts-dialog").showModal();
|
||||
}
|
||||
const dialogOpen = Boolean(document.querySelector("dialog[open]"));
|
||||
const keyboardIdle = !dialogOpen && !["INPUT", "TEXTAREA", "SELECT"].includes(tag) && !document.activeElement?.isContentEditable;
|
||||
if (keyboardIdle) {
|
||||
const appShortcut = keyboardIdle && !event.ctrlKey && !event.metaKey && !event.altKey;
|
||||
if (appShortcut) {
|
||||
const globalKey = event.key.toLowerCase();
|
||||
if (globalKey === "c") { event.preventDefault(); openCompose("new"); return; }
|
||||
if (globalKey === "v") { event.preventDefault(); setSelectionMode(!state.selectionMode); return; }
|
||||
}
|
||||
if (keyboardIdle && state.currentDetail) {
|
||||
if (appShortcut && state.currentDetail) {
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "x") {
|
||||
const current = state.messages.find((message) => message.uid === state.currentDetail.uid && (message.folder || state.folder) === state.currentDetail.folder);
|
||||
@@ -559,11 +559,13 @@ async function boot() {
|
||||
await loadAuthPolicy();
|
||||
const authenticated = await loadAccounts();
|
||||
if (authenticated) {
|
||||
prepareAuthenticatedStartup();
|
||||
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"));
|
||||
if (folder && Number.isFinite(uid) && uid > 0) await openMessageTarget(state.accountId, folder, uid, params.get("mail_fullscreen") === "1");
|
||||
finishAuthenticatedStartup();
|
||||
} else { await loadTranslations(state.prefs.language); }
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user