From be6ed326b826e00bf3caeb4f0486fcf4c74878b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:16:49 +0000 Subject: [PATCH 1/2] implement IMAP IDLE: SSE endpoint, admin observability, Dovecot config Agent-Logs-Url: https://github.com/tayyebi/mailserver/sessions/61e37ed3-3507-4cc9-8269-e4823140f274 Co-authored-by: tayyebi <14053493+tayyebi@users.noreply.github.com> --- Cargo.lock | 13 +++ Cargo.toml | 1 + src/main.rs | 3 + src/web/mod.rs | 22 ++++- src/web/routes/dashboard.rs | 10 +- src/web/routes/imap_idle.rs | 37 ++++++++ src/web/routes/mod.rs | 3 + src/web/routes/webmail.rs | 152 +++++++++++++++++++++++++++++- templates/config/dovecot.conf.txt | 4 + templates/dashboard.html | 1 + templates/imap_idle/list.html | 43 +++++++++ templates/layout.html | 1 + templates/webmail/inbox.html | 45 +++++++++ 13 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 src/web/routes/imap_idle.rs create mode 100644 templates/imap_idle/list.html diff --git a/Cargo.lock b/Cargo.lock index 3c8d520..7b1703c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1088,6 +1088,7 @@ dependencies = [ "serde_json", "sha1", "tokio", + "tokio-stream", "tower 0.4.13", "tower-http 0.5.2", "uuid", @@ -2014,6 +2015,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-util" version = "0.7.18" diff --git a/Cargo.toml b/Cargo.toml index 6128913..8c89314 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,3 +34,4 @@ reqwest = { version = "0.12", default-features = false, features = ["blocking", quick-xml = "0.39" flate2 = "1" zip = { version = "8", default-features = false, features = ["deflate"] } +tokio-stream = { version = "0.1", features = ["sync"] } diff --git a/src/main.rs b/src/main.rs index 365ea55..1d76576 100644 --- a/src/main.rs +++ b/src/main.rs @@ -53,6 +53,9 @@ fn main() { hostname, admin_port: port, mcp_guard: std::sync::Arc::new(std::sync::Mutex::new(web::McpGuard::new())), + idle_registry: std::sync::Arc::new(std::sync::Mutex::new( + std::collections::HashMap::new(), + )), }; // Start fail2ban log watcher in a background thread diff --git a/src/web/mod.rs b/src/web/mod.rs index 8c8a2eb..7b40058 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -8,13 +8,31 @@ use axum::response::Response; use axum::routing::get_service; use axum::Router; use log::{debug, info, warn}; -use std::collections::VecDeque; +use serde::Serialize; +use std::collections::{HashMap, VecDeque}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use tower_http::services::ServeDir; use crate::web::errors::status_response; +// ── IMAP IDLE session tracking ──────────────────────────────────────────────── + +/// Represents one active IMAP-IDLE (SSE) connection from the webmail client. +#[derive(Clone, Serialize)] +pub struct ImapIdleSession { + pub id: String, + pub account_id: i64, + pub username: String, + pub domain: String, + pub folder: String, + pub connected_at: String, + pub last_ping_at: String, +} + +/// Shared in-memory registry of active IMAP IDLE sessions. +pub type ImapIdleRegistry = Arc>>; + // ── MCP rate-limit and anomaly-detection constants ──────────────────────────── /// Maximum number of MCP calls allowed per 60-second sliding window. @@ -116,6 +134,8 @@ pub struct AppState { pub admin_port: u16, /// Shared rate-limiter and anomaly detector for the MCP endpoint. pub mcp_guard: Arc>, + /// Registry of active webmail IMAP-IDLE (SSE) sessions. + pub idle_registry: ImapIdleRegistry, } impl AppState { diff --git a/src/web/routes/dashboard.rs b/src/web/routes/dashboard.rs index f1400cd..6c5a885 100644 --- a/src/web/routes/dashboard.rs +++ b/src/web/routes/dashboard.rs @@ -14,14 +14,20 @@ struct DashboardTemplate<'a> { flash: Option<&'a str>, hostname: &'a str, stats: crate::db::Stats, + idle_session_count: usize, } pub async fn page(_auth: AuthAdmin, State(state): State) -> Html { info!("[web] GET / — dashboard requested"); let stats = state.blocking_db(|db| db.get_stats()).await; + let idle_session_count = { + let reg = state.idle_registry.lock().unwrap(); + reg.len() + }; + debug!( - "[web] dashboard stats: domains={}, accounts={}, aliases={}, forwarding={}, tracked={}, opens={}, banned={}, webhooks={}, unsubs={}, dkim_ready={}", + "[web] dashboard stats: domains={}, accounts={}, aliases={}, forwarding={}, tracked={}, opens={}, banned={}, webhooks={}, unsubs={}, dkim_ready={}, idle_sessions={}", stats.domain_count, stats.account_count, stats.alias_count, @@ -32,6 +38,7 @@ pub async fn page(_auth: AuthAdmin, State(state): State) -> Html) -> Html { + nav_active: &'a str, + flash: Option<&'a str>, + sessions: Vec, +} + +// ── Handlers ── + +pub async fn list(_auth: AuthAdmin, State(state): State) -> Html { + info!("[web] GET /imap-idle — listing active IDLE sessions"); + + let sessions: Vec = { + let reg = state.idle_registry.lock().unwrap(); + let mut list: Vec = reg.values().cloned().collect(); + list.sort_by(|a, b| a.connected_at.cmp(&b.connected_at)); + list + }; + + let tmpl = ImapIdleTemplate { + nav_active: "IMAP IDLE", + flash: None, + sessions, + }; + Html(tmpl.render().unwrap()) +} diff --git a/src/web/routes/mod.rs b/src/web/routes/mod.rs index 063eb6a..130efe7 100644 --- a/src/web/routes/mod.rs +++ b/src/web/routes/mod.rs @@ -13,6 +13,7 @@ pub mod domains; pub mod fail2ban; pub mod footer; pub mod forwarding; +pub mod imap_idle; pub mod mcp; pub mod pixel; pub mod queue; @@ -94,6 +95,8 @@ pub fn auth_routes() -> Router { .route("/webmail/delete/:filename", post(webmail::delete_email)) .route("/webmail/compose", get(webmail::compose)) .route("/webmail/send", post(webmail::send_email)) + .route("/webmail/idle", get(webmail::idle_stream)) + .route("/imap-idle", get(imap_idle::list)) .route("/settings", get(settings::page)) .route("/settings/password", post(settings::change_password)) .route("/settings/2fa", get(settings::setup_2fa)) diff --git a/src/web/routes/webmail.rs b/src/web/routes/webmail.rs index d685fa5..2ab861e 100644 --- a/src/web/routes/webmail.rs +++ b/src/web/routes/webmail.rs @@ -2,12 +2,18 @@ use askama::Template; use axum::{ extract::{Path, Query, State}, http::header, - response::{Html, IntoResponse, Redirect, Response}, + response::{ + sse::{Event, KeepAlive, Sse}, + Html, IntoResponse, Redirect, Response, + }, Form, }; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; use log::{debug, error, info, warn}; use serde::Deserialize; +use std::convert::Infallible; +use std::time::Duration; +use tokio_stream::wrappers::ReceiverStream; use crate::db::Account; use crate::web::auth::AuthAdmin; @@ -1496,6 +1502,150 @@ pub async fn send_email( } } +// ── IMAP IDLE (SSE) ────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +pub struct ImapIdleQuery { + pub account_id: i64, + #[serde(default)] + pub folder: String, +} + +/// Count the number of messages in the `new/` subdirectory of a Maildir folder. +fn count_new_messages(maildir_base: &str, folder: &str) -> usize { + let root = folder_root(maildir_base, folder); + let new_dir = format!("{}/new", root); + std::fs::read_dir(&new_dir) + .map(|entries| entries.flatten().filter(|e| e.path().is_file()).count()) + .unwrap_or(0) +} + +/// Server-Sent Events endpoint that emits `mailbox` events whenever the number +/// of new messages in a Maildir folder changes. This is the webmail equivalent +/// of the IMAP IDLE command (RFC 2177). +/// +/// Query parameters: +/// - `account_id` – ID of the account to watch. +/// - `folder` – Maildir subfolder name (empty = INBOX). +/// +/// The connection is registered in `AppState::idle_registry` for admin visibility +/// and is automatically removed when the client disconnects. +pub async fn idle_stream( + _auth: AuthAdmin, + State(state): State, + Query(query): Query, +) -> impl IntoResponse { + let account_id = query.account_id; + let folder = if is_safe_folder(&query.folder) { + query.folder.clone() + } else { + String::new() + }; + + // Resolve account details + let acct = state + .blocking_db(move |db| db.get_account_with_domain(account_id)) + .await; + + let (username, domain) = match acct { + Some(ref a) => ( + a.username.clone(), + a.domain_name.clone().unwrap_or_default(), + ), + None => { + warn!("[idle] account id={} not found", account_id); + let (_, rx) = tokio::sync::mpsc::channel::>(1); + let stream = ReceiverStream::new(rx); + return Sse::new(stream).keep_alive(KeepAlive::default()); + } + }; + + if !is_safe_path_component(&domain) || !is_safe_path_component(&username) { + warn!( + "[idle] unsafe path component: domain={}, username={}", + domain, username + ); + let (_, rx) = tokio::sync::mpsc::channel::>(1); + let stream = ReceiverStream::new(rx); + return Sse::new(stream).keep_alive(KeepAlive::default()); + } + + let maildir_base = maildir_path(&domain, &username); + let session_id = uuid::Uuid::new_v4().to_string(); + let now_ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); + + // Register the session + { + let mut reg = state.idle_registry.lock().unwrap(); + reg.insert( + session_id.clone(), + crate::web::ImapIdleSession { + id: session_id.clone(), + account_id, + username: username.clone(), + domain: domain.clone(), + folder: if folder.is_empty() { + "INBOX".to_string() + } else { + folder.trim_start_matches('.').to_string() + }, + connected_at: now_ts.clone(), + last_ping_at: now_ts, + }, + ); + } + + info!( + "[idle] session {} opened for {}@{} folder={}", + session_id, username, domain, folder + ); + + let (tx, rx) = tokio::sync::mpsc::channel::>(16); + let registry = state.idle_registry.clone(); + let sid = session_id.clone(); + + tokio::spawn(async move { + let mut last_count: Option = None; + let mut interval = tokio::time::interval(Duration::from_secs(5)); + + loop { + interval.tick().await; + + let count = count_new_messages(&maildir_base, &folder); + + // Update last_ping_at + { + let mut reg = registry.lock().unwrap(); + if let Some(session) = reg.get_mut(&sid) { + session.last_ping_at = + chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); + } + } + + let changed = last_count.map(|c| c != count).unwrap_or(true); + if changed { + last_count = Some(count); + let data = serde_json::json!({ "new_count": count }).to_string(); + let event = Event::default().event("mailbox").data(data); + if tx.send(Ok(event)).await.is_err() { + // Receiver dropped – client disconnected + break; + } + } + } + + // Deregister session + { + let mut reg = registry.lock().unwrap(); + reg.remove(&sid); + } + info!("[idle] session {} closed", sid); + }); + + let stream = ReceiverStream::new(rx); + Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(30))) +} + #[cfg(test)] mod tests { use super::{ diff --git a/templates/config/dovecot.conf.txt b/templates/config/dovecot.conf.txt index 3c41bc2..483a6f8 100644 --- a/templates/config/dovecot.conf.txt +++ b/templates/config/dovecot.conf.txt @@ -79,6 +79,10 @@ service imap-login { } } +# IMAP IDLE (RFC 2177) — notify clients at most every 2 minutes that new mail +# has arrived so they can update without polling. +imap_idle_notify_interval = 2 mins + service pop3-login { inet_listener pop3 { address = 0.0.0.0 diff --git a/templates/dashboard.html b/templates/dashboard.html index 44db717..34f6f61 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -41,6 +41,7 @@

Live counters

{{ stats.webhook_count }}WebhooksEvents dispatched
{{ stats.unsubscribe_count }}UnsubscribesOpt-out records
{{ stats.banned_count }}Banned IPsActive fail2ban bans
+
{{ idle_session_count }}IMAP IDLELive watchers
{% endblock %} diff --git a/templates/imap_idle/list.html b/templates/imap_idle/list.html new file mode 100644 index 0000000..ef584d8 --- /dev/null +++ b/templates/imap_idle/list.html @@ -0,0 +1,43 @@ +{% extends "layout.html" %} +{% block title %}IMAP IDLE Sessions{% endblock %} +{% block content %} +
+
+ Real-time inbox watchers +

IMAP IDLE Sessions

+
+

Each row represents a webmail browser tab currently subscribed to live new-mail notifications via Server-Sent Events — the web equivalent of IMAP IDLE (RFC 2177).

+
+ +
+ {% if sessions.is_empty() %} +

No active IMAP IDLE sessions.

+ {% else %} +
+ + + + + + + + + + + + {% for s in sessions %} + + + + + + + + {% endfor %} + +
AccountFolderConnected AtLast PingSession ID
{{ s.username }}@{{ s.domain }}{{ s.folder }}{{ s.connected_at }}{{ s.last_ping_at }}{{ s.id }}
+
+

{{ sessions.len() }} active session(s). The list refreshes on every page load.

+ {% endif %} +
+{% endblock %} diff --git a/templates/layout.html b/templates/layout.html index 1611004..2b465cf 100644 --- a/templates/layout.html +++ b/templates/layout.html @@ -49,6 +49,7 @@ diff --git a/templates/webmail/inbox.html b/templates/webmail/inbox.html index 01a6acf..8ad914f 100644 --- a/templates/webmail/inbox.html +++ b/templates/webmail/inbox.html @@ -15,6 +15,12 @@

Webmail

{% if let Some(sel) = selected_account %} + +
+ + {% endif %} {% if !logs.is_empty() %} From 3388b480238cecb9c203885e3d6309c043fb66fd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 21 Apr 2026 13:22:51 +0000 Subject: [PATCH 2/2] fix: use data attributes for JS values, add MissedTickBehavior::Delay, pre-format timestamp before lock Agent-Logs-Url: https://github.com/tayyebi/mailserver/sessions/61e37ed3-3507-4cc9-8269-e4823140f274 Co-authored-by: tayyebi <14053493+tayyebi@users.noreply.github.com> --- src/web/routes/webmail.rs | 7 +++++-- templates/webmail/inbox.html | 14 ++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/web/routes/webmail.rs b/src/web/routes/webmail.rs index 2ab861e..2086684 100644 --- a/src/web/routes/webmail.rs +++ b/src/web/routes/webmail.rs @@ -1607,18 +1607,21 @@ pub async fn idle_stream( tokio::spawn(async move { let mut last_count: Option = None; let mut interval = tokio::time::interval(Duration::from_secs(5)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { interval.tick().await; let count = count_new_messages(&maildir_base, &folder); + // Format timestamp before acquiring lock to minimise contention + let ping_ts = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); + // Update last_ping_at { let mut reg = registry.lock().unwrap(); if let Some(session) = reg.get_mut(&sid) { - session.last_ping_at = - chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); + session.last_ping_at = ping_ts; } } diff --git a/templates/webmail/inbox.html b/templates/webmail/inbox.html index 8ad914f..5102674 100644 --- a/templates/webmail/inbox.html +++ b/templates/webmail/inbox.html @@ -21,7 +21,9 @@

Webmail

-
+