//! SPLIT-EVAL: 已评估暂缓拆分——搜索降级链、通知编排与出站重试共用同一份配置读取、错误映射表与日志字段集合,拆分会引入重复的错误翻译与日志规约。 //! Bounded metadata notification worker (MOD-TELEGRAM-NOTIFY). use crate::ai::LlmClient; use crate::channel::telegram::TelegramClient; use crate::config::BusinessConfig; use crate::domain::jmap::{JmapBackend, JmapService, SearchResult}; use crate::domain::Notification; use crate::state::ttl; use crate::state::ReliableState; use async_trait::async_trait; use std::{ future::Future, pin::Pin, sync::{Arc, RwLock}, time::Duration, }; #[async_trait] pub trait WorkerHandler: Send + Sync { async fn process(&self, stream: &str, payload: &str) -> Result<(), ()>; async fn create_push_subscription(&self, _callback_url: &str) -> Result { Err(()) } async fn destroy_push_subscription(&self, _subscription_id: &str) -> Result<(), ()> { Err(()) } async fn verify_push_subscription( &self, _subscription_id: &str, _verification_code: &str, ) -> Result<(), ()> { Err(()) } /// Performs one bounded JMAP reconciliation pass. Implementations must not /// persist the cursor; the HTTP coordinator commits it after enqueueing. /// /// `initial_changes` sizes the first `/changes` call only. The pass may /// widen the window afterwards, which is why it is not called `max_changes`. /// /// On failure, returns a `&'static str` diagnostic code identifying which /// step broke (e.g. `"jmap_session_failed"`, `"jmap_list_failed"`). The HTTP /// layer surfaces this as a `reason` field in the 503 body so operators can /// see the cause without server-side logs. async fn reconcile( &self, _since_state: Option<&str>, _initial_changes: usize, ) -> Result { Err("reconcile_unsupported") } } /// Atomically replaceable worker handle. In-flight requests keep their old worker; /// subsequent bounded requests observe the newest successfully-built instance. pub struct WorkerHandle { current: RwLock>, } impl WorkerHandle { pub fn new(worker: Arc) -> Self { Self { current: RwLock::new(worker), } } pub fn replace(&self, worker: Arc) -> Result<(), ()> { self.current .write() .map(|mut current| *current = worker) .map_err(|_| ()) } } /// Coordinates an all-or-nothing worker/config swap. The caller builds every client before /// invoking `reload`; a failed factory therefore leaves both the old worker and snapshot intact. pub struct ReloadCoordinator { worker: Arc, config: RwLock>, } impl ReloadCoordinator { pub fn new(worker: Arc, config: Option) -> Self { Self { worker, config: RwLock::new(config), } } #[cfg_attr( not(test), expect(dead_code, reason = "stable reload API retained for coordinator tests") )] pub async fn reload_async(&self, config: BusinessConfig, factory: F) -> Result<(), ()> where F: FnOnce( &BusinessConfig, ) -> Pin, ()>> + Send>>, { let next = factory(&config).await?; self.worker.replace(next)?; *self.config.write().map_err(|_| ())? = Some(config); Ok(()) } /// Commit an already-built worker and its matching snapshot. Construction must happen /// before this method so failed client creation cannot disturb the active instance. pub fn commit(&self, config: BusinessConfig, worker: Arc) -> Result<(), ()> { self.worker.replace(worker)?; *self.config.write().map_err(|_| ())? = Some(config); Ok(()) } } #[async_trait] impl WorkerHandler for WorkerHandle { async fn destroy_push_subscription(&self, subscription_id: &str) -> Result<(), ()> { let worker = self.current.read().map_err(|_| ())?.clone(); worker.destroy_push_subscription(subscription_id).await } async fn create_push_subscription(&self, callback_url: &str) -> Result { let worker = self.current.read().map_err(|_| ())?.clone(); worker.create_push_subscription(callback_url).await } async fn verify_push_subscription( &self, subscription_id: &str, verification_code: &str, ) -> Result<(), ()> { let worker = self.current.read().map_err(|_| ())?.clone(); worker .verify_push_subscription(subscription_id, verification_code) .await } async fn process(&self, stream: &str, payload: &str) -> Result<(), ()> { let worker = self.current.read().map_err(|_| ())?.clone(); worker.process(stream, payload).await } async fn reconcile( &self, since_state: Option<&str>, initial_changes: usize, ) -> Result { let worker = self .current .read() .map_err(|_| "worker_handle_unavailable")? .clone(); worker.reconcile(since_state, initial_changes).await } } pub struct NoopWorker; #[async_trait] impl WorkerHandler for NoopWorker { async fn process(&self, _stream: &str, _payload: &str) -> Result<(), ()> { Err(()) } } /// One page of the position walk. const BASELINE_PAGE_SIZE: usize = 100; /// Hard stop on walk pages so a large mailbox cannot stretch a single pass. const BASELINE_MAX_PAGES: usize = 100; /// Hard stop on walk emails. Redundant with `BASELINE_MAX_PAGES` at the current /// page size, but independent of it, so shrinking the page cannot widen it. const BASELINE_MAX_EMAILS: usize = 10_000; /// Upper bound the `/changes` window may widen to before we accept advancing /// the cursor instead (see `MetadataWorker::replay_changes`). const CHANGE_WINDOW_CAP: usize = 4_096; /// Page ceiling for either phase, so one invocation is always one bounded pass. const RECONCILE_MAX_PAGES: usize = 100; /// Wall-clock ceiling for the whole invocation, walk phase included. const RECONCILE_BUDGET: Duration = Duration::from_secs(20); /// `reconcile:state` is bimodal, and the two modes need different handling. /// /// Encoding this as an enum instead of a `(String, usize)` pair plus a magic /// `usize::MAX` sentinel means no position value is silently reserved with a /// second meaning. #[derive(Debug, Clone, PartialEq, Eq)] enum ReconcileCursor { /// Resume the position walk from `position`. It was captured at `state`, /// which must stay fixed for the whole walk or the ids would drift. The /// walk is the only path that can see email the changelog no longer retains. Walk { state: String, position: usize }, /// Resume `Email/changes` from this collection state token. Replay(String), } impl ReconcileCursor { /// A walk that starts at position 0 from a freshly captured state. Used /// for the very first reconcile and after re-baselining a dead cursor. fn walk_from(state: String) -> Self { Self::Walk { state, position: 0 } } /// Decode a persisted cursor: `baseline:{hex-state}:{position}` is a walk /// in flight, a bare state string is a replay point. fn from_persisted(value: &str) -> Self { match decode_baseline_cursor(value) { Some((state, position)) => Self::Walk { state, position }, None => Self::Replay(value.to_owned()), } } fn to_persisted(&self) -> String { match self { Self::Walk { state, position } => encode_baseline_cursor(state, *position), Self::Replay(state) => state.clone(), } } } /// How the position walk ended, which decides what the pass may return. enum WalkOutcome { /// The listing is exhausted: every email is enqueued, so `/changes` may be /// replayed from the state captured before the walk started. ReplayFrom(String), /// The budget cut the walk short after at least one complete page. Persist /// the walk cursor so the next invocation resumes instead of restarting. Persist(ReconcileCursor), /// The budget cut the walk before a single page completed. The pass made no /// progress and must be reported as failed. NoProgress, } /// JMAP bodies are read only to obtain metadata; body text never reaches Telegram. pub struct MetadataWorker { jmap: JmapService, telegram: Arc, chat_id: i64, state: Arc, llm: Option>, /// Fixed offset, seconds east of UTC, for the configured display zone. timezone_offset: i32, } impl MetadataWorker { pub fn new( jmap: JmapService, telegram: TelegramClient, chat_id: i64, state: Arc, llm: Option>, timezone_offset: i32, ) -> Self { Self { jmap, telegram: Arc::new(telegram), chat_id, state, llm, timezone_offset, } } } /// Render a unix-seconds timestamp as wall clock time in a fixed-offset zone. /// /// `offset_seconds` must be within +/-24h, which holds for every entry of /// `SUPPORTED_TIMEZONES`; out-of-range values return `None` instead of panicking. fn format_received_at(unix_seconds: i64, offset_seconds: i32) -> Option { use chrono::{DateTime, FixedOffset}; let offset = FixedOffset::east_opt(offset_seconds)?; let utc = DateTime::from_timestamp(unix_seconds, 0)?; Some( utc.with_timezone(&offset) .format("%Y-%m-%d %H:%M") .to_string(), ) } #[async_trait] impl WorkerHandler for MetadataWorker { async fn destroy_push_subscription(&self, subscription_id: &str) -> Result<(), ()> { self.jmap .destroy_push_subscription(subscription_id) .await .map_err(|_| ()) } async fn create_push_subscription(&self, callback_url: &str) -> Result { self.jmap .create_push_subscription(callback_url) .await .map_err(|_| ()) } async fn verify_push_subscription( &self, subscription_id: &str, verification_code: &str, ) -> Result<(), ()> { self.jmap .verify_push_subscription(subscription_id, verification_code) .await .map_err(|_| ()) } async fn process(&self, stream: &str, payload: &str) -> Result<(), ()> { if stream == "stalwart:telegram" { return self.process_telegram(payload).await; } if stream != "stalwart:jmap" { // Unknown stream: fail closed. Returning Ok would let the coordinator // write `delivery:committed` + XACK and silently drop an event we do // not understand. Falling through to Err routes it to retry/DLQ so it // stays observable instead of vanishing. return Err(()); } let event: WorkerEvent = serde_json::from_str(payload).map_err(|_| ())?; let (Some(account), Some(email)) = (event.account_id.as_deref(), event.email_id.as_deref()) else { return Err(()); }; if account != self.jmap.account_id() { return Err(()); } let content = self .jmap .read_email(email) .await .map_err(|_| ())? .ok_or(())?; let metadata = content.metadata; self.telegram .send_notification( self.chat_id, &Notification { sender: metadata.sender.unwrap_or_else(|| "unknown".into()), subject: metadata.subject.unwrap_or_else(|| "(no subject)".into()), received_at: metadata .received_at .and_then(|unix| format_received_at(unix, self.timezone_offset)) .unwrap_or_else(|| "unknown".into()), }, ) .await .map_err(|_| ()) } async fn reconcile( &self, since_state: Option<&str>, initial_changes: usize, ) -> Result { // `maxChanges` must be at least 1: a zero window is not a valid // `/changes` request, and rejecting it would waste the pass. let window = initial_changes.max(1); let started = tokio::time::Instant::now(); let cursor = if let Some(state) = since_state { ReconcileCursor::from_persisted(state) } else { // Capture a stable baseline before paging. Changes occurring while // the listing is in flight are replayed from it before committing. ReconcileCursor::walk_from( self.jmap .session_state() .await .map_err(|_| "jmap_session_failed")?, ) }; // Phase 1: drain the position walk, the only path that can see email // the server-side changelog no longer retains. It must finish before // `/changes` is replayed. match self.walk_baseline(cursor, started).await? { WalkOutcome::ReplayFrom(state) => self.replay_changes(state, window, started).await, WalkOutcome::Persist(cursor) => Ok(cursor.to_persisted()), WalkOutcome::NoProgress => Err("reconcile_no_progress"), } } } impl MetadataWorker { /// Drains the position walk `cursor` points at. The outcome decides what /// the pass may do next: replay `/changes` from a captured state, persist /// a walk cursor to resume later, or report that nothing was done. async fn walk_baseline( &self, cursor: ReconcileCursor, started: tokio::time::Instant, ) -> Result { // A cursor in replay mode has nothing to walk: nothing was appended to // the collection since it was committed. let (state, mut position) = match cursor { ReconcileCursor::Walk { state, position } => (state, position), ReconcileCursor::Replay(state) => return Ok(WalkOutcome::ReplayFrom(state)), }; let mut pages = 0_usize; let mut emails = 0_usize; loop { if pages >= BASELINE_MAX_PAGES || emails >= BASELINE_MAX_EMAILS || started.elapsed() >= RECONCILE_BUDGET { return if pages == 0 { Ok(WalkOutcome::NoProgress) } else { Ok(WalkOutcome::Persist(ReconcileCursor::Walk { state, position, })) }; } let page = self .jmap .list_emails_page(None, position, BASELINE_PAGE_SIZE) .await .map_err(|_| "jmap_list_failed")?; if page.is_empty() { return Ok(WalkOutcome::ReplayFrom(state)); } pages += 1; emails = emails.saturating_add(page.len()); position = position.saturating_add(page.len()); for email in page { self.enqueue_reconcile_event(&email.id).await?; } } } /// Replays `Email/changes` from `state` and returns the cursor to persist. /// /// RFC 8620 lets a server answer `/changes` with `hasMoreChanges=true` /// when more events remain than `maxChanges` allowed. The spec-blessed /// continuation is `sinceState = newState`; but a server that resolves /// `newState` to "after *all* pending changes" would then hand us a /// cursor that silently skips the unreturned batch. We cannot pass /// `upToId` here (jmap-client 0.4.2 does not expose it), so while more /// changes remain we keep the *same* sinceState and widen the window /// instead. The 24h dedup key makes re-reading the superset idempotent, /// so this is at-most-duplicate, never-loss. Only when the window stops /// growing (server-side cap) do we fall back to advancing to `new_state` /// to guarantee forward progress. async fn replay_changes( &self, mut state: String, initial_window: usize, started: tokio::time::Instant, ) -> Result { let mut window = initial_window; let mut pages = 0_usize; loop { if pages >= RECONCILE_MAX_PAGES || started.elapsed() >= RECONCILE_BUDGET { // Every event from the last completed page is already enqueued. // Returning that cursor lets the next invocation continue from // it instead of retrying the same bounded window forever. return if pages == 0 { Err("reconcile_budget_exhausted") } else { Ok(state) }; } pages += 1; let changes = match self.jmap.email_changes(&state, window).await { Ok(changes) => changes, Err(_) => { // Re-baseline instead of failing the invocation. jmap-client // 0.4.2 exposes no reset signal: `ChangesResponse` only // carries `oldState`/`newState`/`hasMoreChanges`/`created`/ // `updated`, so a `/changes` failure is // indistinguishable from "the server no longer replays from // this `sinceState`". Returning an error leaves the cursor // untouched, so every later invocation retries the same dead // state and `reconcile:state` freezes forever (a persistent // 503 `reconcile_retry` with no forward progress). The // position walk does not depend on server-side // changelog retention at all, so re-baselining to a fresh // state at position 0 recovers on the next run. The 24h // dedup key makes the resulting re-read at-most-duplicate, // never-loss. let rebaselined = ReconcileCursor::walk_from( self.jmap .session_state() .await .map_err(|_| "jmap_session_failed")?, ); return Ok(rebaselined.to_persisted()); } }; for email_id in changes.created.iter().chain(changes.updated.iter()) { self.enqueue_reconcile_event(email_id).await?; } if !changes.has_more_changes { return Ok(changes.new_state); } if window < CHANGE_WINDOW_CAP { window = window.saturating_mul(2).min(CHANGE_WINDOW_CAP); continue; } // The window is at its cap and the server still reports more: // advancing is the only way to make progress. state = changes.new_state; } } } fn encode_baseline_cursor(state: &str, position: usize) -> String { let encoded = state .as_bytes() .iter() .map(|byte| format!("{byte:02x}")) .collect::(); format!("baseline:{encoded}:{position}") } fn decode_baseline_cursor(value: &str) -> Option<(String, usize)> { let rest = value.strip_prefix("baseline:")?; let (encoded, position) = rest.rsplit_once(':')?; let bytes = (0..encoded.len()) .step_by(2) .map(|index| u8::from_str_radix(encoded.get(index..index + 2)?, 16).ok()) .collect::>>()?; Some((String::from_utf8(bytes).ok()?, position.parse().ok()?)) } impl MetadataWorker { async fn enqueue_reconcile_event(&self, email_id: &str) -> Result<(), &'static str> { let key = format!("dedup:jmap:{}:{}", self.jmap.account_id(), email_id); let payload = serde_json::json!({ "accountId": self.jmap.account_id(), "emailId": email_id, }) .to_string(); // Claim the dedup key and XADD in a single atomic step: a Redis hiccup // mid-way must not leave the dedup key claimed while the payload is // absent from the stream (which would drop the event for the whole // 24h dedup window). self.state .claim_dedup_and_enqueue(&key, ttl::DEDUP_JMAP_SECONDS, "stalwart:jmap", &payload) .await .map_err(|_| "enqueue_failed")?; Ok(()) } async fn process_telegram(&self, payload: &str) -> Result<(), ()> { let update: TelegramUpdate = serde_json::from_str(payload).map_err(|_| ())?; let Some(message) = update.message else { return Ok(()); }; let Some(text) = message.text.as_deref() else { return Ok(()); }; let intent = parse_intent(text); if matches!(&intent, Intent::Help) { return self .telegram .send_text(message.chat.id, help_message()) .await .map_err(|_| ()); } if let Intent::Search(query) = &intent { return self.handle_search(message.chat.id, query).await; } if let Intent::Consent { ttl, label } = &intent { if *ttl == 0 { self.state .clear_ai_consent(message.chat.id) .await .map_err(|_| ())?; return self .telegram .send_text(message.chat.id, "AI摘要授权已撤销") .await .map_err(|_| ()); } self.state .set_ai_consent(message.chat.id, *ttl) .await .map_err(|_| ())?; return self .telegram .send_text( message.chat.id, &format!( "AI摘要授权已开启:{},到期时间 Unix {};仍需使用 /summary 请求", label, unix_now().saturating_add(*ttl as i64) ), ) .await .map_err(|_| ()); } let Some(email_id) = intent.email_id() else { if matches!(&intent, Intent::Query) { self.telegram .send_text(message.chat.id, "请提供邮件 ID,例如:摘要 e-123") .await .map_err(|_| ())?; } return Ok(()); }; let content = self .jmap .read_email(&email_id) .await .map_err(|_| ())? .ok_or(())?; let allowed = self .state .ai_consent_until(message.chat.id) .await .map_err(|_| ())? .is_some(); let summary = if allowed { match &self.llm { Some(llm) => llm .summarize(&content.text) .await .unwrap_or_else(|_| fallback(&content.text)), None => fallback(&content.text), } } else { format!( "{} — {}\n授权已到期或尚未授权;如需 AI 摘要,请重新选择授权期限。", content.metadata.sender.unwrap_or_default(), content.metadata.subject.unwrap_or_default() ) }; self.telegram .send_text(message.chat.id, &summary) .await .map_err(|_| ()) } async fn handle_search(&self, chat_id: i64, query: &str) -> Result<(), ()> { let query = query.trim(); if query.is_empty() { return self .telegram .send_text(chat_id, "请提供搜索关键词,例如:/search 发票") .await .map_err(|_| ()); } let result = self.jmap.search_emails(query, SEARCH_LIMIT).await; match search_reply(result.as_ref().map_err(|_| ()), query, SEARCH_LIMIT) { SearchReply::Text(reply) => self .telegram .send_text(chat_id, &reply) .await .map_err(|_| ()), // Hard JMAP failure: propagate so the coordinator answers // 503 + Retry-After and the event is retried; never send an // error stack to the user. SearchReply::Retry => Err(()), } } } fn fallback(body: &str) -> String { body.chars().take(300).collect() } fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |duration| duration.as_secs() as i64) } #[derive(Debug, PartialEq, Eq)] enum Intent { Help, Consent { ttl: u64, label: &'static str }, Summary(String), Query, Search(String), Unknown, } impl Intent { fn email_id(&self) -> Option { match self { Self::Summary(id) => Some(id.clone()), _ => None, } } } fn parse_intent(input: &str) -> Intent { let input = input.trim(); let text = input.to_ascii_lowercase(); if text == "/help" || text == "help" || text.contains("帮助") || text.contains("怎么用") { return Intent::Help; } // `/search `: the remainder is the whole query, read back from the // sender's original casing so the JMAP filter and the echoed reply keep // their capitalisation. A bare `/search` maps to Search("") so the handler // can ask for keywords; `/searchx` is not a command and falls through. if let Some(rest) = input.strip_prefix("/search") { if rest.is_empty() || rest.starts_with(char::is_whitespace) { return Intent::Search(rest.trim().to_owned()); } } // The Chinese equivalents are also prefix-matched: matching a keyword // anywhere in the sentence would turn "帮我搜一下发票" into a search for // "一下发票" and hijack consent and summary requests. for keyword in ["搜索", "查找", "检索"] { if let Some(rest) = input.strip_prefix(keyword) { return Intent::Search(rest.trim().to_owned()); } } if text.contains("临时") || text.contains("一次") { return Intent::Consent { ttl: ttl::CONSENT_TEMPORARY_SECONDS, label: "临时1小时", }; } if text.contains("今天") { return Intent::Consent { ttl: ttl::CONSENT_TODAY_SECONDS, label: "今天", }; } if text.contains("7天") { return Intent::Consent { ttl: ttl::CONSENT_WEEK_SECONDS, label: "7天", }; } if text.contains("直到我撤销") || text.contains("长期") { return Intent::Consent { ttl: ttl::CONSENT_MAXIMUM_SECONDS, label: "直到撤销(最长365天)", }; } if text == "/ai on" || text == "/ai yes" || text.contains("开启 ai") || text.contains("同意摘要") || text.contains("允许 ai") { return Intent::Consent { ttl: ttl::CONSENT_TEMPORARY_SECONDS, label: "1小时", }; } if text == "/ai off" || text.contains("关闭 ai") || text.contains("撤销授权") || text.contains("停止摘要") { return Intent::Consent { ttl: ttl::CONSENT_REVOKED_SECONDS, label: "已撤销", }; } let asks_summary = text.starts_with("/summary") || text.contains("摘要") || text.contains("总结"); let asks_query = text.contains("查询邮件") || text.contains("查看邮件") || text.contains("查邮件"); let id = text.split_whitespace().last().filter(|id| { id.len() > 1 && id .chars() .all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_') }); if asks_summary { return id .map(|v| Intent::Summary(v.to_owned())) .unwrap_or(Intent::Query); } if asks_query { return id .map(|v| Intent::Summary(v.to_owned())) .unwrap_or(Intent::Query); } Intent::Unknown } fn help_message() -> &'static str { "使用说明:\n邮件到达后会先发送发件人、主题和时间等元数据通知。\n\n授权示例(请按原文中文输入,不支持英文别名):\n1小时:/ai on、临时一次、临时、一次\n今天:今天\n7天:7天\n直到我撤销(最长365天):直到我撤销、长期\n撤销授权:/ai off、关闭 ai、撤销授权、停止摘要\n/summary :请求指定邮件摘要;未授权或授权到期时只返回元数据并提示重新授权。\n/search <关键词>:搜索邮件主题与正文,返回带高亮片段的匹配列表(最多 10 条)。\n/help(或 help):显示本说明。\n\n隐私:AI 默认关闭,只有你明确开启后才会发送正文;授权有期限且不会自动续期;正文和 AI 结果不会持久化,也不会写入日志。\n如果未配置 AI 或 AI 调用失败,将回退为本地截取摘要。" } /// Upper bound on how many matching emails one `/search` reply lists. const SEARCH_LIMIT: usize = 10; /// Upper bound on characters of a snippet subject shown per result. const SEARCH_SUBJECT_MAX: usize = 120; /// Upper bound on characters of a snippet preview shown per result. const SEARCH_PREVIEW_MAX: usize = 160; enum SearchReply { Text(String), Retry, } /// Decide what to do with a search outcome without touching the network, so /// the mapping is unit-testable: any hard failure becomes `Retry` (surfaced /// as 503 + Retry-After by the coordinator), everything else renders to text. fn search_reply(result: Result<&SearchResult, ()>, query: &str, limit: usize) -> SearchReply { match result { Ok(result) => SearchReply::Text(render_search_results(result, query, limit)), Err(()) => SearchReply::Retry, } } /// Render a search outcome as a plain-text Telegram reply. Highlight tags are /// stripped (see [`strip_mark_tags`]) and subject/preview are truncated. fn render_search_results(result: &SearchResult, query: &str, limit: usize) -> String { if result.ids.is_empty() { return format!("没有找到匹配「{query}」的邮件。"); } let mut lines: Vec = Vec::new(); lines.push(format!( "搜索「{query}」:共 {} 封匹配邮件", result.ids.len() )); if result.snippets.is_empty() { // SearchSnippet/get was unavailable: degrade to ids, never invent // fragments. lines.push("(高亮片段暂不可用,以下为匹配的邮件 ID)".to_owned()); for id in result.ids.iter().take(limit) { lines.push(format!("- {id}")); } return lines.join("\n"); } for snippet in result.snippets.iter().take(limit) { let subject = truncate_chars( &snippet .subject .as_deref() .map(clean_snippet) .unwrap_or_else(|| "(无主题)".to_owned()), SEARCH_SUBJECT_MAX, ); let preview = truncate_chars( &snippet .preview .as_deref() .map(clean_snippet) .unwrap_or_default(), SEARCH_PREVIEW_MAX, ); let id = snippet.email_id.as_str(); // The id is what makes a hit actionable: without it the user cannot // follow up with `/summary `. if preview.is_empty() { lines.push(format!("- {subject}({id})")); } else { lines.push(format!("- {subject}({id})\n {preview}")); } } lines.join("\n") } /// Strip RFC 8621 ``/`` highlight tags so a snippet value can be /// sent to Telegram as plain text. Byte offsets are safe because /// `to_ascii_lowercase` preserves string length. fn strip_mark_tags(input: &str) -> String { const OPEN: &str = ""; const CLOSE: &str = ""; let mut output = String::with_capacity(input.len()); let mut remainder = input; while let Some(start) = remainder.to_ascii_lowercase().find(OPEN) { output.push_str(&remainder[..start]); remainder = &remainder[start + OPEN.len()..]; match remainder.to_ascii_lowercase().find(CLOSE) { Some(end) => { output.push_str(&remainder[..end]); remainder = &remainder[end + CLOSE.len()..]; } None => { output.push_str(remainder); return output; } } } output.push_str(remainder); output } /// Decode the HTML entities RFC 8621 §5 puts around snippet text, so an email /// containing `&&` or `a < b` is not echoed back as `&&`. The pass is /// strictly left-to-right, so `&lt;` stays the literal text `<` instead /// of being decoded twice. fn unescape_html_entities(input: &str) -> String { const ENTITIES: [(&str, &str); 6] = [ ("<", "<"), (">", ">"), (""", "\""), ("'", "'"), ("'", "'"), ("&", "&"), ]; let mut output = String::with_capacity(input.len()); let mut remainder = input; while let Some(entity) = remainder.find('&') { let (head, tail) = remainder.split_at(entity); output.push_str(head); let mut decoded = false; for (name, replacement) in ENTITIES { if let Some(next) = tail.strip_prefix(name) { output.push_str(replacement); remainder = next; decoded = true; break; } } if !decoded { output.push('&'); remainder = &tail[1..]; } } output.push_str(remainder); output } /// Clean one RFC 8621 §5 snippet value for Telegram plain text. Tags are /// stripped before entities are decoded: the server escapes angle brackets too, /// so a literal `` in the email arrives as `<mark>`, and decoding /// first would turn that into a real tag and erase the user's text. fn clean_snippet(input: &str) -> String { unescape_html_entities(&strip_mark_tags(input)) } /// Truncate to at most `max_chars` characters (by char, not byte), adding an /// ellipsis when anything was cut. fn truncate_chars(text: &str, max_chars: usize) -> String { let count = text.chars().count(); if count <= max_chars { return text.to_owned(); } let cut: String = text.chars().take(max_chars).collect(); format!("{cut}…") } #[derive(serde::Deserialize)] struct TelegramUpdate { #[serde(default)] message: Option, } #[derive(serde::Deserialize)] struct TelegramMessage { chat: TelegramChat, #[serde(default)] text: Option, } #[derive(serde::Deserialize)] struct TelegramChat { id: i64, } #[cfg(test)] mod tests { use crate::domain::jmap::SearchSnippet; use super::{ help_message, parse_intent, render_search_results, search_reply, strip_mark_tags, truncate_chars, unescape_html_entities, Intent, SearchReply, SearchResult, SEARCH_PREVIEW_MAX, SEARCH_SUBJECT_MAX, }; #[test] fn help_is_safe_and_actionable() { let text = help_message(); for command in [ "/ai on", "/ai off", "/summary ", "/search <关键词>", "/help", ] { assert!(text.contains(command)); } assert!(text.contains("默认关闭")); assert!(text.contains("不会持久化")); assert!(!text.contains("BOT_TOKEN")); } #[test] fn natural_language_intents_keep_commands_compatible() { assert_eq!(parse_intent("请帮我看看怎么用"), Intent::Help); assert!(matches!( parse_intent("我同意摘要"), Intent::Consent { ttl: 3600, .. } )); assert!(matches!( parse_intent("关闭 AI"), Intent::Consent { ttl: 0, .. } )); assert!(matches!( parse_intent("今天允许 AI"), Intent::Consent { ttl: 86_400, .. } )); assert!(matches!( parse_intent("授权 7天"), Intent::Consent { ttl: 604_800, .. } )); assert!(matches!( parse_intent("直到我撤销"), Intent::Consent { ttl: 31_536_000, .. } )); assert_eq!( parse_intent("请总结 e-123"), Intent::Summary("e-123".into()) ); assert_eq!(parse_intent("查询邮件"), Intent::Query); // /search keeps the sender's casing and consumes the whole remainder. assert_eq!( parse_intent("/search Invoice Q3"), Intent::Search("Invoice Q3".into()) ); assert_eq!(parse_intent("/search"), Intent::Search(String::new())); // Bare-word queries: the keyword must lead the sentence, so that // "帮我搜一下发票" is not searched as "一下发票" and consent phrases // containing a later keyword stay consent intents. assert_eq!( parse_intent("搜索发票 报销"), Intent::Search("发票 报销".into()) ); assert_eq!( parse_intent("查找 e-1 的摘要"), Intent::Search("e-1 的摘要".into()) ); assert_eq!(parse_intent("帮我搜一下发票"), Intent::Unknown); assert!(matches!( parse_intent("同意一次搜索摘要"), Intent::Consent { ttl: 3600, .. } )); assert_eq!(parse_intent("/searchx abc"), Intent::Unknown); } #[test] fn strip_mark_tags_handles_case_mixed_and_unmatched_pairs() { assert_eq!( strip_mark_tags("invoice by friday"), "invoice by friday" ); assert_eq!(strip_mark_tags("a b"), "a b"); // An unmatched opener is dropped rather than emitted, so no raw markup // can reach Telegram. assert_eq!(strip_mark_tags(""), ""); assert_eq!(strip_mark_tags("hello"), "hello"); assert_eq!(strip_mark_tags("a & not a mark"), "a & not a mark"); assert_eq!(strip_mark_tags(""), ""); } #[test] fn unescape_html_entities_decodes_a_single_pass() { assert_eq!( unescape_html_entities("A < B > C "pay" 'now'"), "A < B > C \"pay\" 'now'" ); assert_eq!(unescape_html_entities("Tom & Jerry"), "Tom & Jerry"); // A lone ampersand and a partially escaped entity must not mangle the text. assert_eq!(unescape_html_entities("100% & ok"), "100% & ok"); assert_eq!(unescape_html_entities("&lt;"), "<"); assert_eq!(unescape_html_entities(""), ""); } #[test] fn search_reply_maps_failures_to_retry_and_renders_outcomes() { assert!(matches!( search_reply(Err(()), "发票", 10), SearchReply::Retry )); assert!(matches!( search_reply(Ok(&SearchResult { ids: vec![], snippets: vec![] }), "发票", 10), SearchReply::Text(text) if text.contains("没有找到匹配") )); assert!(matches!( search_reply( Ok(&SearchResult { ids: vec!["e-1".into()], snippets: vec![] }), "发票", 10 ), SearchReply::Text(text) if text.contains("e-1") )); } #[test] fn render_search_results_strips_highlights_and_degrades_to_ids() { let result = SearchResult { ids: vec!["e-1".into(), "e-2".into()], snippets: vec![ SearchSnippet { subject: Some("invoice Q3".into()), preview: Some("pay <100> & send".into()), email_id: "e-1".into(), }, SearchSnippet { subject: None, preview: None, email_id: "e-2".into(), }, ], }; let text = render_search_results(&result, "invoice", 2); assert!(!text.contains("")); assert!(text.contains("invoice Q3")); assert!(text.contains("pay <100> & send")); assert!(text.contains("(e-2)")); assert!(text.contains("(无主题)")); let ids_only = SearchResult { ids: vec!["e-9".into()], snippets: vec![], }; let ids_text = render_search_results(&ids_only, "发票", 2); assert!(ids_text.contains("e-9")); assert!(ids_text.contains("高亮片段暂不可用")); } #[test] fn render_search_results_truncates_oversized_fields() { let result = SearchResult { ids: vec!["e-1".into()], snippets: vec![SearchSnippet { subject: Some("s".repeat(300)), preview: Some("p".repeat(300)), email_id: "e-1".into(), }], }; let text = render_search_results(&result, "q", 10); assert!(text.contains("…")); // Neither field may survive the cap: the raw 300-char runs are gone. assert!(!text.contains(&"s".repeat(SEARCH_SUBJECT_MAX + 1))); assert!(!text.contains(&"p".repeat(SEARCH_PREVIEW_MAX + 1))); assert!(text.contains(&truncate_chars(&"s".repeat(300), SEARCH_SUBJECT_MAX))); } } #[derive(serde::Deserialize)] struct WorkerEvent { #[serde(rename = "accountId")] account_id: Option, #[serde(rename = "emailId")] email_id: Option, } #[cfg(test)] mod reload_tests { use super::*; use secrecy::SecretString; fn config() -> BusinessConfig { BusinessConfig { bot_token: SecretString::new("bot".into()), telegram_chat_id: 1, chat_allowlist: vec![], telegram_webhook_secret: SecretString::new("hook".into()), jmap_session_url: "https://mail.example.test".into(), jmap_username: "user".into(), jmap_password: SecretString::new("pass".into()), account_id: None, llm_enabled: false, llm_allow_net: false, llm_api_key: None, llm_base_url: None, llm_model: None, reconcile_token: SecretString::new("reconcile".into()), worker_token: SecretString::new("worker".into()), timezone: "Asia/Shanghai".into(), } } #[tokio::test] async fn failed_build_keeps_existing_worker() { let handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker))); let coordinator = ReloadCoordinator::new(handle.clone(), Some(config())); let result = coordinator .reload_async(config(), |_| Box::pin(async { Err(()) })) .await; assert!(result.is_err()); assert!(handle.process("x", "y").await.is_err()); } #[tokio::test] async fn successful_build_replaces_worker_atomically() { let handle = Arc::new(WorkerHandle::new(Arc::new(NoopWorker))); let coordinator = ReloadCoordinator::new(handle.clone(), None); let replacement: Arc = Arc::new(OkWorker); coordinator .reload_async(config(), move |_| { let replacement = replacement.clone(); Box::pin(async move { Ok(replacement) }) }) .await .unwrap(); assert!(handle.process("x", "y").await.is_ok()); } #[test] fn baseline_cursor_roundtrips_state_and_position() { let encoded = encode_baseline_cursor("state:with:punctuation", 1234); assert_eq!( decode_baseline_cursor(&encoded), Some(("state:with:punctuation".to_owned(), 1234)) ); assert!(decode_baseline_cursor("baseline:odd:hex").is_none()); } #[test] fn a_walk_cursor_roundtrips_as_a_walk() { let cursor = ReconcileCursor::Walk { state: "state:with:punctuation".to_string(), position: 1234, }; assert_eq!( ReconcileCursor::from_persisted(&cursor.to_persisted()), cursor ); } #[test] fn a_replay_cursor_roundtrips_as_a_bare_state() { let cursor = ReconcileCursor::Replay("state:with:punctuation".to_string()); let persisted = cursor.to_persisted(); assert_eq!(persisted, "state:with:punctuation"); assert_eq!(ReconcileCursor::from_persisted(&persisted), cursor); } /// The walk used to be told apart from replay by a position of /// `usize::MAX`, so a legitimately stored max position would have decoded /// as a bare state token and its walk would have been skipped silently. #[test] fn a_max_position_is_not_misread_as_replay_mode() { let cursor = ReconcileCursor::Walk { state: "state".to_string(), position: usize::MAX, }; let persisted = cursor.to_persisted(); assert!(persisted.starts_with("baseline:"), "{persisted}"); assert_eq!(ReconcileCursor::from_persisted(&persisted), cursor); } struct OkWorker; #[async_trait] impl WorkerHandler for OkWorker { async fn process(&self, _stream: &str, _payload: &str) -> Result<(), ()> { Ok(()) } } /// `1700000000` is `2023-11-14T22:13:20Z`. These are the literal expected /// wall clock strings, so an offset regression shows up as a value mismatch /// rather than a silently shifted time. #[test] fn received_at_renders_in_the_configured_zone() { use crate::config::{default_timezone, resolve_timezone}; let unix = 1_700_000_000_i64; let shanghai = resolve_timezone("Asia/Shanghai").unwrap(); let tokyo = resolve_timezone("Asia/Tokyo").unwrap(); let utc = resolve_timezone("Etc/UTC").unwrap(); let kolkata = resolve_timezone("Asia/Kolkata").unwrap(); assert_eq!( format_received_at(unix, shanghai), Some("2023-11-15 06:13".into()) ); assert_eq!( format_received_at(unix, tokyo), Some("2023-11-15 07:13".into()) ); assert_eq!( format_received_at(unix, utc), Some("2023-11-14 22:13".into()) ); // Half-hour zone: the :30 offset must survive the round trip. assert_eq!( format_received_at(unix, kolkata), Some("2023-11-15 03:43".into()) ); // The configured default is the zone the SPA ships with. assert_eq!(default_timezone(), "Asia/Shanghai"); assert_eq!(resolve_timezone(&default_timezone()), Some(8 * 3600)); // Out-of-range values fall back to "unknown" upstream instead of panicking. assert_eq!(format_received_at(i64::MIN, shanghai), None); assert_eq!(format_received_at(unix, 25 * 3600), None); } } #[cfg(test)] mod reconcile_rebaseline_tests { use super::*; use crate::domain::jmap::{EmailChanges, EmailContent, EmailMetadata, Folder, JmapError}; use crate::state::{runtime_provider, MemoryState, OutboundConfig}; use secrecy::SecretString; use std::sync::atomic::{AtomicUsize, Ordering}; /// A backend whose `Email/changes` replay is already invalid while position /// paging still works: `session_state()` succeeds, `list_emails_page` /// succeeds, `/changes` fails. struct StaleChangesBackend { changes_calls: Arc, } #[async_trait] impl JmapBackend for StaleChangesBackend { async fn create_push_subscription(&self, _callback_url: &str) -> Result { unreachable!("not exercised") } async fn destroy_push_subscription(&self, _subscription_id: &str) -> Result<(), JmapError> { unreachable!("not exercised") } async fn verify_push_subscription( &self, _subscription_id: &str, _verification_code: &str, ) -> Result<(), JmapError> { unreachable!("not exercised") } async fn session_state(&self) -> Result { Ok("state-fresh".to_string()) } async fn email_changes( &self, _account_id: &str, _since_state: &str, _max_changes: usize, ) -> Result { self.changes_calls.fetch_add(1, Ordering::SeqCst); Err(JmapError::InvalidRequest("email changes")) } async fn list_folders(&self, _account_id: &str) -> Result, JmapError> { unreachable!("not exercised") } async fn list_emails( &self, _account_id: &str, _folder_id: Option<&str>, _limit: usize, ) -> Result, JmapError> { unreachable!("not exercised") } async fn list_emails_page( &self, _account_id: &str, _folder_id: Option<&str>, _position: usize, _limit: usize, ) -> Result, JmapError> { Ok(vec![]) } async fn read_email( &self, _account_id: &str, _email_id: &str, ) -> Result, JmapError> { unreachable!("not exercised") } async fn search_emails( &self, _account_id: &str, _query: &str, _limit: usize, ) -> Result { unreachable!("not exercised") } } /// A frozen `baseline:` cursor whose server-side replay is stale must /// re-baseline rather than wedge. `Err(())` used to leave `reconcile:state` /// untouched, so every later run retried the same dead `sinceState` and /// reconcile answered 503 forever. Asserting `Ok` pins the whole loop: the /// state key gets a fresh cursor, so the next run restarts the walk. #[tokio::test] async fn reconcile_rebaselines_when_changes_replay_is_stale() { let changes_calls = Arc::new(AtomicUsize::new(0)); let backend = StaleChangesBackend { changes_calls: changes_calls.clone(), }; let jmap = JmapService::new(backend, "account-x").expect("account id"); let telegram = TelegramClient::with_endpoint( SecretString::from("token".to_string()), runtime_provider(OutboundConfig::default()), "http://127.0.0.1:1/".to_string(), ); let state: Arc = Arc::new(MemoryState::enabled_for_tests()); let worker = MetadataWorker::new(jmap, telegram, 42, state, None, 0); let stale = "baseline:7374616c65:98"; let result = worker.reconcile(Some(stale), 100).await; let cursor = result.expect("a stale replay must re-baseline instead of failing the run"); assert_eq!( ReconcileCursor::from_persisted(&cursor), ReconcileCursor::Walk { state: "state-fresh".to_string(), position: 0, }, "the cursor must hold a freshly fetched state at position 0" ); assert_ne!(&cursor, stale); assert_eq!( changes_calls.load(Ordering::SeqCst), 1, "the stale state is attempted exactly once before re-baselining" ); } /// A listing that never exhausts: every page returns one email, so the walk /// can only stop at the page cap. struct UnboundedListBackend { changes_calls: Arc, } #[async_trait] impl JmapBackend for UnboundedListBackend { async fn create_push_subscription(&self, _callback_url: &str) -> Result { unreachable!("not exercised") } async fn destroy_push_subscription(&self, _subscription_id: &str) -> Result<(), JmapError> { unreachable!("not exercised") } async fn verify_push_subscription( &self, _subscription_id: &str, _verification_code: &str, ) -> Result<(), JmapError> { unreachable!("not exercised") } async fn session_state(&self) -> Result { Ok("state-fresh".to_string()) } async fn email_changes( &self, _account_id: &str, _since_state: &str, _max_changes: usize, ) -> Result { self.changes_calls.fetch_add(1, Ordering::SeqCst); Ok(EmailChanges { new_state: "state-listed".to_string(), created: vec![], updated: vec![], has_more_changes: false, }) } async fn list_folders(&self, _account_id: &str) -> Result, JmapError> { unreachable!("not exercised") } async fn list_emails( &self, _account_id: &str, _folder_id: Option<&str>, _limit: usize, ) -> Result, JmapError> { unreachable!("not exercised") } async fn list_emails_page( &self, _account_id: &str, _folder_id: Option<&str>, _position: usize, _limit: usize, ) -> Result, JmapError> { Ok(vec![EmailMetadata { id: "m1".to_string(), subject: None, sender: None, received_at: None, preview: None, size: None, has_attachment: false, }]) } async fn read_email( &self, _account_id: &str, _email_id: &str, ) -> Result, JmapError> { unreachable!("not exercised") } async fn search_emails( &self, _account_id: &str, _query: &str, _limit: usize, ) -> Result { unreachable!("not exercised") } } /// A walk cut off at the page cap must hand back a walk cursor, not a /// replay one: replay mode asserts the listing is exhausted, and replaying /// `/changes` from a half-listed state would skip whatever still remains. #[tokio::test] async fn a_walk_cut_by_the_page_cap_persists_its_position() { let changes_calls = Arc::new(AtomicUsize::new(0)); let backend = UnboundedListBackend { changes_calls: changes_calls.clone(), }; let jmap = JmapService::new(backend, "account-x").expect("account id"); let telegram = TelegramClient::with_endpoint( SecretString::from("token".to_string()), runtime_provider(OutboundConfig::default()), "http://127.0.0.1:1/".to_string(), ); let state: Arc = Arc::new(MemoryState::enabled_for_tests()); let worker = MetadataWorker::new(jmap, telegram, 42, state, None, 0); let cursor = worker .reconcile(None, 100) .await .expect("a capped walk reports progress instead of failing the run"); assert_eq!( ReconcileCursor::from_persisted(&cursor), ReconcileCursor::Walk { state: "state-fresh".to_string(), position: BASELINE_MAX_PAGES, }, "a truncated walk must stay a walk at the position it stopped at" ); assert_eq!( changes_calls.load(Ordering::SeqCst), 0, "a truncated walk must not replay /changes from a half-listed state" ); } }