1531 lines
56 KiB
Rust
1531 lines
56 KiB
Rust
//! 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 `&&`. 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 `<mark>` 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<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 < 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("<mark>invoice</mark> 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("<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"
|
||
);
|
||
}
|
||
}
|