MessageWeave/src/worker.rs

1531 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 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<String, ()> {
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<String, &'static str> {
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<Arc<dyn WorkerHandler>>,
}
impl WorkerHandle {
pub fn new(worker: Arc<dyn WorkerHandler>) -> Self {
Self {
current: RwLock::new(worker),
}
}
pub fn replace(&self, worker: Arc<dyn WorkerHandler>) -> 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<WorkerHandle>,
config: RwLock<Option<BusinessConfig>>,
}
impl ReloadCoordinator {
pub fn new(worker: Arc<WorkerHandle>, config: Option<BusinessConfig>) -> 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<F>(&self, config: BusinessConfig, factory: F) -> Result<(), ()>
where
F: FnOnce(
&BusinessConfig,
)
-> Pin<Box<dyn Future<Output = Result<Arc<dyn WorkerHandler>, ()>> + 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<dyn WorkerHandler>) -> 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<String, ()> {
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<String, &'static str> {
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<B> {
jmap: JmapService<B>,
telegram: Arc<TelegramClient>,
chat_id: i64,
state: Arc<dyn ReliableState>,
llm: Option<Arc<LlmClient>>,
/// Fixed offset, seconds east of UTC, for the configured display zone.
timezone_offset: i32,
}
impl<B: JmapBackend> MetadataWorker<B> {
pub fn new(
jmap: JmapService<B>,
telegram: TelegramClient,
chat_id: i64,
state: Arc<dyn ReliableState>,
llm: Option<Arc<LlmClient>>,
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<String> {
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<B: JmapBackend> WorkerHandler for MetadataWorker<B> {
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<String, ()> {
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<String, &'static str> {
// `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<B: JmapBackend> MetadataWorker<B> {
/// 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<WalkOutcome, &'static str> {
// 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<String, &'static str> {
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::<String>();
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::<Option<Vec<_>>>()?;
Some((String::from_utf8(bytes).ok()?, position.parse().ok()?))
}
impl<B: JmapBackend> MetadataWorker<B> {
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 <email_id> 请求",
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<String> {
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 <query>`: 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 <email_id>:请求指定邮件摘要;未授权或授权到期时只返回元数据并提示重新授权。\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<String> = 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 <email_id>`.
if preview.is_empty() {
lines.push(format!("- {subject}({id})"));
} else {
lines.push(format!("- {subject}({id})\n {preview}"));
}
}
lines.join("\n")
}
/// Strip RFC 8621 `<mark>`/`</mark>` 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 = "<mark>";
const CLOSE: &str = "</mark>";
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 `&amp;&amp;`. The pass is
/// strictly left-to-right, so `&amp;lt;` stays the literal text `&lt;` instead
/// of being decoded twice.
fn unescape_html_entities(input: &str) -> String {
const ENTITIES: [(&str, &str); 6] = [
("&lt;", "<"),
("&gt;", ">"),
("&quot;", "\""),
("&#39;", "'"),
("&apos;", "'"),
("&amp;", "&"),
];
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 `<mark>` in the email arrives as `&lt;mark&gt;`, 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<TelegramMessage>,
}
#[derive(serde::Deserialize)]
struct TelegramMessage {
chat: TelegramChat,
#[serde(default)]
text: Option<String>,
}
#[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 <email_id>",
"/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("<mark>invoice</mark> by friday"),
"invoice by friday"
);
assert_eq!(strip_mark_tags("<MARK>a</MARK> <Mark>b</Mark>"), "a b");
// An unmatched opener is dropped rather than emitted, so no raw markup
// can reach Telegram.
assert_eq!(strip_mark_tags("<mark>"), "");
assert_eq!(strip_mark_tags("<mark>hello"), "hello");
assert_eq!(strip_mark_tags("a & <b> not a mark"), "a & <b> not a mark");
assert_eq!(strip_mark_tags(""), "");
}
#[test]
fn unescape_html_entities_decodes_a_single_pass() {
assert_eq!(
unescape_html_entities("A &lt; B &gt; C &quot;pay&quot; &#39;now&#39;"),
"A < B > C \"pay\" 'now'"
);
assert_eq!(unescape_html_entities("Tom &amp; 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("&amp;lt;"), "&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("<mark>invoice</mark> Q3".into()),
preview: Some("pay &lt;100&gt; &amp; 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("<mark>"));
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<String>,
#[serde(rename = "emailId")]
email_id: Option<String>,
}
#[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<dyn WorkerHandler> = 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<AtomicUsize>,
}
#[async_trait]
impl JmapBackend for StaleChangesBackend {
async fn create_push_subscription(&self, _callback_url: &str) -> Result<String, JmapError> {
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<String, JmapError> {
Ok("state-fresh".to_string())
}
async fn email_changes(
&self,
_account_id: &str,
_since_state: &str,
_max_changes: usize,
) -> Result<EmailChanges, JmapError> {
self.changes_calls.fetch_add(1, Ordering::SeqCst);
Err(JmapError::InvalidRequest("email changes"))
}
async fn list_folders(&self, _account_id: &str) -> Result<Vec<Folder>, JmapError> {
unreachable!("not exercised")
}
async fn list_emails(
&self,
_account_id: &str,
_folder_id: Option<&str>,
_limit: usize,
) -> Result<Vec<EmailMetadata>, JmapError> {
unreachable!("not exercised")
}
async fn list_emails_page(
&self,
_account_id: &str,
_folder_id: Option<&str>,
_position: usize,
_limit: usize,
) -> Result<Vec<EmailMetadata>, JmapError> {
Ok(vec![])
}
async fn read_email(
&self,
_account_id: &str,
_email_id: &str,
) -> Result<Option<EmailContent>, JmapError> {
unreachable!("not exercised")
}
async fn search_emails(
&self,
_account_id: &str,
_query: &str,
_limit: usize,
) -> Result<SearchResult, JmapError> {
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<dyn ReliableState> = 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<AtomicUsize>,
}
#[async_trait]
impl JmapBackend for UnboundedListBackend {
async fn create_push_subscription(&self, _callback_url: &str) -> Result<String, JmapError> {
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<String, JmapError> {
Ok("state-fresh".to_string())
}
async fn email_changes(
&self,
_account_id: &str,
_since_state: &str,
_max_changes: usize,
) -> Result<EmailChanges, JmapError> {
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<Vec<Folder>, JmapError> {
unreachable!("not exercised")
}
async fn list_emails(
&self,
_account_id: &str,
_folder_id: Option<&str>,
_limit: usize,
) -> Result<Vec<EmailMetadata>, JmapError> {
unreachable!("not exercised")
}
async fn list_emails_page(
&self,
_account_id: &str,
_folder_id: Option<&str>,
_position: usize,
_limit: usize,
) -> Result<Vec<EmailMetadata>, 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<Option<EmailContent>, JmapError> {
unreachable!("not exercised")
}
async fn search_emails(
&self,
_account_id: &str,
_query: &str,
_limit: usize,
) -> Result<SearchResult, JmapError> {
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<dyn ReliableState> = 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"
);
}
}