feat: Telegram /search 邮件搜索(email/query Filter + SearchSnippet/get 高亮)

⑥-c 落地代码缺口:/search <关键词>

实现
- JmapBackend::search_emails 加 trait 默认实现(mock 后端返回 InvalidRequest,
  真机后端用 Email/query + text Filter 取 ID,再 SearchSnippet/get 取高亮)
- JmapService::search_emails 暴露搜索入口,与 list_emails_page 共享超时/错误映射
- worker:Intent::Search + /search 分发分支 + /help 说明行

降级路径(不 panic、不回栈给用户)
- SearchSnippet/get 不支持(unknownMethod/timeout)→ 仍返回匹配 ID 列表,
  渲染降级为高亮片段暂不可用
- 无匹配 → 没有找到匹配「…」的邮件。
- JMAP 侧失败 → SearchReply::Retry,交由协调器 503 + Retry-After 重试
- 搜索关键词为空 → 引导提示,不发请求

安全边界
- subject/preview 只做展示:strip_mark_tags 剥 <mark> 高亮(大小写不敏感、
  未闭合标记丢弃,不泄漏原始标记),unescape_html_entities 单趟解码,
  先剥标签再解码实体以保留邮箱正文里字面 <mark>
- 无匹配 ID / 主题 / preview 分别有截断上限(120/120/160 字符)
- 每条命中带 email_id,可直接接 /summary <email_id>
- 中文关键词仅前缀匹配,避免帮我搜一下发票被劫持成搜索、或覆盖授权意图

测试 +7(55 → 62 passed, 0 failed, 1 ignored)
This commit is contained in:
anonsaber 2026-09-28 21:09:06 +08:00
parent 6c99ce51ab
commit bfe0fd88aa
3 changed files with 465 additions and 6 deletions

View File

@ -66,6 +66,25 @@ pub struct EmailChanges {
pub has_more: bool,
}
/// Highlighted RFC 8621 `SearchSnippet/get` fragment for one email. Values
/// carry the server's highlight markup (`<mark>`); the boundary passes them
/// through untouched and the presentation layer must sanitize before display.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchSnippet {
pub email_id: String,
pub subject: Option<String>,
pub preview: Option<String>,
}
/// Bounded search outcome: matched ids in server order plus any snippets the
/// server produced. `snippets` is empty when `Email/query` succeeded but
/// `SearchSnippet/get` was unavailable; ids remain the authoritative result.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SearchResult {
pub ids: Vec<String>,
pub snippets: Vec<SearchSnippet>,
}
#[async_trait]
pub trait JmapBackend: Send + Sync {
async fn create_push_subscription(&self, callback_url: &str) -> Result<String, JmapError>;
@ -109,6 +128,17 @@ pub trait JmapBackend: Send + Sync {
account_id: &str,
email_id: &str,
) -> Result<Option<EmailContent>, JmapError>;
/// Bounded RFC 8621 `Email/query` text search across the mailbox. The
/// result set must be capped at `limit`; implementations must not fall
/// back to a full-mailbox scan. Snippet retrieval is best-effort —
/// `SearchSnippet/get` failure degrades to ids-only, `Email/query` failure
/// propagates as an error.
async fn search_emails(
&self,
account_id: &str,
query: &str,
limit: usize,
) -> Result<SearchResult, JmapError>;
}
/// Single-account service. `long_email_limit` only annotates output; it never
@ -234,6 +264,19 @@ impl<B: JmapBackend> JmapService<B> {
content.is_long = content.character_count > self.long_email_limit;
Ok(Some(content))
}
pub async fn search_emails(
&self,
query: &str,
limit: usize,
) -> Result<SearchResult, JmapError> {
if query.trim().is_empty() || limit == 0 {
return Err(JmapError::InvalidRequest("search query"));
}
self.backend
.search_emails(&self.account_id, query, limit)
.await
}
}
#[cfg(test)]
@ -332,6 +375,22 @@ mod tests {
is_long: false,
}))
}
async fn search_emails(
&self,
account_id: &str,
query: &str,
limit: usize,
) -> Result<SearchResult, JmapError> {
assert_eq!((account_id, query, limit), ("account-1", "hello", 10));
Ok(SearchResult {
ids: vec!["email-1".into()],
snippets: vec![SearchSnippet {
email_id: "email-1".into(),
subject: Some("<mark>hello</mark>".into()),
preview: Some("say <mark>hello</mark> now".into()),
}],
})
}
}
#[tokio::test]
@ -348,6 +407,27 @@ mod tests {
assert!(email.is_long);
}
#[tokio::test]
async fn service_search_passes_bounded_query_through() {
let service = JmapService::new(MockBackend, "account-1").unwrap();
let result = service.search_emails("hello", 10).await.unwrap();
assert_eq!(result.ids, vec!["email-1"]);
assert_eq!(result.snippets.len(), 1);
assert_eq!(
result.snippets[0].subject.as_deref(),
Some("<mark>hello</mark>")
);
}
#[tokio::test]
async fn service_rejects_invalid_search_input() {
let service = JmapService::new(MockBackend, "account-1").unwrap();
let err = service.search_emails(" ", 10).await.unwrap_err();
assert!(matches!(err, JmapError::InvalidRequest("search query")));
let err = service.search_emails("hello", 0).await.unwrap_err();
assert!(matches!(err, JmapError::InvalidRequest("search query")));
}
#[test]
fn service_rejects_invalid_limits_and_account() {
assert!(JmapService::new(MockBackend, " ").is_err());

View File

@ -1,6 +1,9 @@
//! Concrete read-only adapter (MOD-JMAP-CLIENT, GATE-G1-JMAP-READONLY).
use super::{EmailChanges, EmailContent, EmailMetadata, Folder, JmapBackend, JmapError};
use super::{
EmailChanges, EmailContent, EmailMetadata, Folder, JmapBackend, JmapError, SearchResult,
SearchSnippet,
};
use crate::state::{runtime_provider, OutboundConfig, RuntimeConfigProvider};
use async_trait::async_trait;
use jmap_client::{email, mailbox, DataType, Get};
@ -310,6 +313,51 @@ impl JmapBackend for JmapClientBackend {
text,
}))
}
async fn search_emails(
&self,
account_id: &str,
query: &str,
limit: usize,
) -> Result<SearchResult, JmapError> {
if account_id != self.account_id || limit == 0 || query.trim().is_empty() {
return Err(JmapError::InvalidRequest("email search"));
}
// JMAP caps a single Email/query page; a bounded search never pages,
// so clamp instead of issuing a follow-up position request.
let limit = limit.min(100);
let filter = email::query::Filter::text(query);
let mut request = self.client.build();
let email_query = request.query_email();
email_query.filter(filter.clone()).position(0).limit(limit);
let mut response = tokio::time::timeout(self.timeout(), request.send_query_email())
.await
.map_err(|_| JmapError::Timeout)??;
let mut ids = response.take_ids();
ids.truncate(limit);
// Snippets are display-only: when the server cannot answer
// SearchSnippet/get (e.g. `unknownMethod`) we still return the matched
// ids so the caller can degrade to a result list without highlights.
let snippets = match tokio::time::timeout(
self.timeout(),
self.client
.search_snippet_get(Some(filter), ids.iter().cloned()),
)
.await
{
Ok(Ok(response)) => response
.list()
.iter()
.map(|snippet| SearchSnippet {
email_id: snippet.email_id().to_owned(),
subject: snippet.subject().map(str::to_owned),
preview: snippet.preview().map(str::to_owned),
})
.collect(),
Ok(Err(_)) | Err(_) => Vec::new(),
};
Ok(SearchResult { ids, snippets })
}
}
impl JmapClientBackend {

View File

@ -2,7 +2,7 @@
use crate::ai::LlmClient;
use crate::channel::telegram::TelegramClient;
use crate::config::BusinessConfig;
use crate::domain::jmap::{JmapBackend, JmapService};
use crate::domain::jmap::{JmapBackend, JmapService, SearchResult};
use crate::domain::Notification;
use crate::state::ReliableState;
use async_trait::async_trait;
@ -392,6 +392,9 @@ impl<B: JmapBackend> MetadataWorker<B> {
.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
@ -462,6 +465,29 @@ impl<B: JmapBackend> MetadataWorker<B> {
.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 {
@ -480,6 +506,7 @@ enum Intent {
Consent { ttl: u64, label: &'static str },
Summary(String),
Query,
Search(String),
Unknown,
}
@ -493,10 +520,28 @@ impl Intent {
}
fn parse_intent(input: &str) -> Intent {
let text = input.trim().to_ascii_lowercase();
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: 3600,
@ -566,7 +611,159 @@ fn parse_intent(input: &str) -> Intent {
}
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/help(或 help):显示本说明。\n\n隐私:AI 默认关闭,只有你明确开启后才会发送正文;授权有期限且不会自动续期;正文和 AI 结果不会持久化,也不会写入日志。\n如果未配置 AI 或 AI 调用失败,将回退为本地截取摘要。"
"使用说明:\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)]
@ -587,12 +784,24 @@ struct TelegramChat {
#[cfg(test)]
mod tests {
use super::{help_message, parse_intent, Intent};
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>", "/help"] {
for command in [
"/ai on",
"/ai off",
"/summary <email_id>",
"/search <关键词>",
"/help",
] {
assert!(text.contains(command));
}
assert!(text.contains("默认关闭"));
@ -631,6 +840,128 @@ mod tests {
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)));
}
}