Skip to main content

mailrs_intelligence/
analyze.rs

1//! Full email analysis — category, summary, entities, intent, action deadline.
2//!
3//! The analysis prompt is fixed (revision: [`PROMPT_VERSION`]) and outputs
4//! Simplified Chinese for all natural-language fields regardless of source
5//! language. Bump [`PROMPT_VERSION`] when changing the prompt so that
6//! consumers can detect stored analyses produced by old prompts and decide
7//! whether to re-analyze.
8
9use serde::{Deserialize, Serialize};
10
11use crate::provider::LlmProvider;
12
13/// Current prompt revision — bump when the system prompt changes so that
14/// consumers can trigger re-analysis of stored results.
15pub const PROMPT_VERSION: &str = "v8";
16
17/// Full analysis result.
18///
19/// `people` / `dates` / `amounts` / `action_items` use `serde_json::Value`
20/// because the LLM occasionally returns strings instead of structured
21/// objects; tolerating either keeps the public API stable across prompt
22/// revisions.
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct EmailAnalysis {
25    /// Category label (free-form, but typically one of `personal` / `work` /
26    /// `transactional` / `promotion` / `notification` / `spam` / `scam`).
27    #[serde(default)]
28    pub category: String,
29    /// Risk score 0-100; 100 = highest risk (phishing, fraud, malware link).
30    #[serde(default)]
31    pub risk_score: u8,
32    /// One-line human-readable reason for the risk score.
33    #[serde(default)]
34    pub risk_reason: String,
35    /// 1-3 sentence summary of the message content.
36    #[serde(default)]
37    pub summary: String,
38    /// Extracted people / entities (JSON shape free-form per LLM impl).
39    #[serde(default)]
40    pub people: serde_json::Value,
41    /// Extracted dates / time references mentioned in the body.
42    #[serde(default)]
43    pub dates: serde_json::Value,
44    /// Extracted amounts / numbers / monetary values.
45    #[serde(default)]
46    pub amounts: serde_json::Value,
47    /// Action items the recipient might want to act on.
48    #[serde(default)]
49    pub action_items: serde_json::Value,
50    /// Cleaned plain-text body the LLM saw (matches mailrs-clean output).
51    #[serde(default)]
52    pub clean_text: String,
53    /// `true` when the LLM judged the message as requiring user action.
54    #[serde(default)]
55    pub requires_action: bool,
56    /// One-word sender intent (`request` / `inform` / `confirm` / `notify` / ...).
57    #[serde(default)]
58    pub sender_intent: String,
59    /// ISO-8601 date if an action deadline was detected.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub action_deadline: Option<String>,
62}
63
64/// Truncate a string at a char boundary, never splitting a multi-byte char.
65fn truncate_str(s: &str, max_bytes: usize) -> &str {
66    if s.len() <= max_bytes {
67        return s;
68    }
69    let mut end = max_bytes;
70    while end > 0 && !s.is_char_boundary(end) {
71        end -= 1;
72    }
73    &s[..end]
74}
75
76/// Analyze an email using the supplied [`LlmProvider`].
77///
78/// The body is truncated to 3000 bytes before sending. Returns `None` if
79/// the provider call fails or the response can't be parsed even after
80/// truncation-repair. Errors are logged via `tracing`.
81pub async fn analyze_email(
82    provider: &dyn LlmProvider,
83    sender: &str,
84    subject: &str,
85    body_text: &str,
86) -> Option<EmailAnalysis> {
87    let body_text = truncate_str(body_text, 3000);
88
89    let system = r#"邮件分析助手。只返回JSON,不要代码块。
90重要:所有文本字段必须用简体中文输出,即使原文是日语、英语或其他语言也必须翻译成中文。
91
92category 分类规则(严格按以下优先级判断):
93- spam: 未经请求的广告、群发营销、钓鱼、欺诈、虚假中奖、不认识的推销
94- scam: 诈骗、钓鱼链接、冒充身份、勒索、虚假紧急通知
95- promotion: 商家促销、优惠券、打折活动、产品推广、平台活动推送(已订阅的商家)
96- newsletter: 定期订阅的资讯、周报、行业动态、博客更新
97- notification: 系统通知、账户变更、安全提醒、服务状态更新、CI/CD通知
98- receipt: 订单确认、付款收据、发票、电子票据
99- shipping: 物流跟踪、发货通知、配送状态更新
100- travel: 机票、酒店、行程确认、签证相关
101- finance: 银行对账单、投资报告、税务通知、转账确认
102- work: 同事/客户/合作方的工作邮件、会议邀请、项目讨论
103- personal: 亲友私信、个人事务
104- general: 以上都不符合时使用
105
106判断要点:
1071. 群发的商业邮件,如果收件人没有明确订阅关系 → spam
1082. "お知らせ"类日语营销邮件、产品推广 → spam 或 promotion(看是否有订阅关系)
1093. GitHub/GitLab/Jira 等开发工具通知 → notification
1104. 含 unsubscribe 链接的批量邮件,优先考虑 promotion/newsletter/spam
111
112risk_score: 0-100 (0可信,25正常,50可疑,75危险,100诈骗)
113sender_intent: request|inform|confirm|social|alert|marketing
114
115{"category":"","risk_score":0,"risk_reason":"中文","summary":"中文","clean_text":"中文","requires_action":false,"sender_intent":"inform","action_deadline":null,"people":[],"dates":[],"amounts":[],"action_items":["中文"]}"#;
116
117    let user_message =
118        format!("Analyze this email:\n\nFrom: {sender}\nSubject: {subject}\nBody:\n{body_text}");
119
120    let text = provider.complete(system, &user_message, 0.1).await?;
121    parse_analysis_response(&text)
122}
123
124/// Parse the JSON analysis response, tolerating markdown fences, extra
125/// surrounding text, and truncated trailing output.
126fn parse_analysis_response(text: &str) -> Option<EmailAnalysis> {
127    let text = text.trim();
128    let text = if let Some(stripped) = text.strip_prefix("```json") {
129        stripped.strip_suffix("```").unwrap_or(stripped).trim()
130    } else if let Some(stripped) = text.strip_prefix("```") {
131        stripped.strip_suffix("```").unwrap_or(stripped).trim()
132    } else {
133        text
134    };
135
136    let start = text.find('{')?;
137    let json_str = if let Some(end) = text.rfind('}') {
138        &text[start..end + 1]
139    } else {
140        // truncated response — no closing brace
141        &text[start..]
142    };
143
144    let mut analysis: EmailAnalysis = match serde_json::from_str(json_str) {
145        Ok(a) => a,
146        Err(_) => {
147            // attempt repair: close any open string + add closing brace
148            let mut repaired = json_str.to_string();
149            let quote_count = repaired.chars().filter(|c| *c == '"').count();
150            if quote_count % 2 != 0 {
151                repaired.push('"');
152            }
153            if !repaired.trim_end().ends_with('}') {
154                repaired.push('}');
155            }
156            match serde_json::from_str(&repaired) {
157                Ok(a) => a,
158                Err(e) => {
159                    tracing::warn!(
160                        event = "analyze_parse_error",
161                        error = %e,
162                        raw = %&json_str[..json_str.len().min(200)]
163                    );
164                    return None;
165                }
166            }
167        }
168    };
169
170    analysis.risk_score = analysis.risk_score.min(100);
171
172    if analysis.clean_text.len() > 2000 {
173        let mut end = 2000;
174        while end > 0 && !analysis.clean_text.is_char_boundary(end) {
175            end -= 1;
176        }
177        analysis.clean_text.truncate(end);
178    }
179
180    Some(analysis)
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn parse_valid_response() {
189        let json = r#"{"category":"personal","risk_score":5,"risk_reason":"from known sender","summary":"A friendly hello","people":[{"name":"John","email":"john@example.com"}],"dates":[],"amounts":[],"action_items":["reply to John"],"clean_text":"Hello there","requires_action":true,"sender_intent":"request","action_deadline":"2026-03-15"}"#;
190        let result = parse_analysis_response(json).unwrap();
191        assert_eq!(result.category, "personal");
192        assert_eq!(result.risk_score, 5);
193        assert!(result.people.is_array());
194        assert_eq!(result.clean_text, "Hello there");
195        assert!(result.requires_action);
196        assert_eq!(result.sender_intent, "request");
197        assert_eq!(result.action_deadline.as_deref(), Some("2026-03-15"));
198    }
199
200    #[test]
201    fn parse_response_without_clean_text() {
202        let json = r#"{"category":"personal","risk_score":5,"risk_reason":"safe","summary":"test","people":[],"dates":[],"amounts":[],"action_items":[]}"#;
203        let result = parse_analysis_response(json).unwrap();
204        assert_eq!(result.clean_text, "");
205        assert!(!result.requires_action);
206        assert_eq!(result.sender_intent, "");
207        assert_eq!(result.action_deadline, None);
208    }
209
210    #[test]
211    fn parse_markdown_fenced_response() {
212        let json = "```json\n{\"category\":\"spam\",\"risk_score\":80,\"risk_reason\":\"suspicious links\",\"summary\":\"spam email\",\"people\":[],\"dates\":[],\"amounts\":[],\"action_items\":[],\"clean_text\":\"\"}\n```";
213        let result = parse_analysis_response(json).unwrap();
214        assert_eq!(result.category, "spam");
215        assert_eq!(result.risk_score, 80);
216    }
217
218    #[test]
219    fn parse_response_with_surrounding_text() {
220        let json = "Here is the analysis:\n{\"category\":\"general\",\"risk_score\":0,\"risk_reason\":\"safe\",\"summary\":\"test\",\"people\":[],\"dates\":[],\"amounts\":[],\"action_items\":[],\"clean_text\":\"\"}\nDone.";
221        let result = parse_analysis_response(json).unwrap();
222        assert_eq!(result.category, "general");
223    }
224
225    #[test]
226    fn parse_invalid_response() {
227        assert!(parse_analysis_response("no json here").is_none());
228        assert!(parse_analysis_response("").is_none());
229        assert!(parse_analysis_response("{invalid}").is_none());
230    }
231
232    #[test]
233    fn parse_truncated_response() {
234        let json = r#"{"category":"promotion","risk_score":25,"risk_reason":"营销邮件","summary":"推广活动","people":[],"dates":[],"amounts":[],"action_items":[],"clean_text":"这是一封营销"#;
235        let result = parse_analysis_response(json).unwrap();
236        assert_eq!(result.category, "promotion");
237        assert_eq!(result.risk_score, 25);
238    }
239
240    #[test]
241    fn risk_score_clamped() {
242        let json = r#"{"category":"scam","risk_score":150,"risk_reason":"phishing","summary":"dangerous","people":[],"dates":[],"amounts":[],"action_items":[],"clean_text":""}"#;
243        let result = parse_analysis_response(json).unwrap();
244        assert_eq!(result.risk_score, 100);
245    }
246
247    #[test]
248    fn parse_with_optional_fields() {
249        let json = r#"{"category":"work","risk_score":10,"risk_reason":"normal","summary":"meeting invite","people":[{"name":"Alice"}],"dates":[{"text":"March 5th","context":"meeting date"}],"amounts":[{"text":"$500","value":500.0,"currency":"USD","context":"budget"}],"action_items":[],"clean_text":"Meeting on March 5th"}"#;
250        let result = parse_analysis_response(json).unwrap();
251        assert_eq!(result.category, "work");
252        assert!(result.people.is_array());
253        assert!(result.amounts.is_array());
254    }
255
256    #[test]
257    fn parse_tolerates_string_people() {
258        // qwen sometimes returns strings instead of objects
259        let json = r#"{"category":"work","risk_score":0,"risk_reason":"","summary":"test","people":"GOLIA K.K.","dates":[],"amounts":[],"action_items":[]}"#;
260        let result = parse_analysis_response(json).unwrap();
261        assert_eq!(result.category, "work");
262        assert!(result.people.is_string());
263    }
264
265    #[test]
266    fn truncate_multibyte_safe() {
267        let s = "あいう";
268        assert_eq!(truncate_str(s, 9), "あいう");
269        assert_eq!(truncate_str(s, 8), "あい");
270        assert_eq!(truncate_str(s, 6), "あい");
271        assert_eq!(truncate_str(s, 5), "あ");
272        assert_eq!(truncate_str(s, 3), "あ");
273        assert_eq!(truncate_str(s, 2), "");
274        assert_eq!(truncate_str(s, 0), "");
275        assert_eq!(truncate_str("hello", 3), "hel");
276    }
277
278    #[test]
279    fn prompt_version_constant() {
280        assert!(!PROMPT_VERSION.is_empty());
281    }
282}
283
284#[cfg(test)]
285mod integration_tests {
286    use super::*;
287    use crate::provider::LlmProvider;
288    use async_trait::async_trait;
289
290    struct CannedProvider(String);
291
292    #[async_trait]
293    impl LlmProvider for CannedProvider {
294        async fn complete(&self, _s: &str, _u: &str, _t: f32) -> Option<String> {
295            Some(self.0.clone())
296        }
297        async fn embed(&self, _t: &str) -> Option<Vec<f32>> {
298            None
299        }
300        fn model_id(&self) -> &str {
301            "test/1"
302        }
303    }
304
305    struct DeadProvider;
306
307    #[async_trait]
308    impl LlmProvider for DeadProvider {
309        async fn complete(&self, _s: &str, _u: &str, _t: f32) -> Option<String> {
310            None
311        }
312        async fn embed(&self, _t: &str) -> Option<Vec<f32>> {
313            None
314        }
315        fn model_id(&self) -> &str {
316            "dead/0"
317        }
318    }
319
320    #[tokio::test]
321    async fn analyze_email_returns_parsed_result() {
322        let provider = CannedProvider(
323            r#"{"category":"work","risk_score":10,"risk_reason":"safe","summary":"meeting","people":[],"dates":[],"amounts":[],"action_items":[],"clean_text":"meeting tomorrow","requires_action":true,"sender_intent":"request","action_deadline":"2026-01-01"}"#
324                .into(),
325        );
326        let result = analyze_email(&provider, "boss@x", "Q3", "review please")
327            .await
328            .expect("analyze must succeed");
329        assert_eq!(result.category, "work");
330        assert!(result.requires_action);
331        assert_eq!(result.action_deadline.as_deref(), Some("2026-01-01"));
332    }
333
334    #[tokio::test]
335    async fn analyze_email_truncates_long_body() {
336        // body > 3000 bytes should be truncated before sending to LLM; we
337        // assert via the fact that we get a valid response back (the canned
338        // provider doesn't care about input, the test asserts the call flows)
339        let body = "x".repeat(10_000);
340        let provider = CannedProvider(
341            r#"{"category":"general","risk_score":0,"risk_reason":"","summary":"x","people":[],"dates":[],"amounts":[],"action_items":[],"clean_text":""}"#
342                .into(),
343        );
344        let result = analyze_email(&provider, "a@x", "subj", &body)
345            .await
346            .expect("must succeed despite huge body");
347        assert_eq!(result.category, "general");
348    }
349
350    #[tokio::test]
351    async fn analyze_email_returns_none_on_provider_failure() {
352        assert!(
353            analyze_email(&DeadProvider, "a@x", "s", "b")
354                .await
355                .is_none()
356        );
357    }
358
359    #[tokio::test]
360    async fn analyze_email_returns_none_on_garbage_response() {
361        let provider = CannedProvider("I am a tea kettle, short and stout.".into());
362        assert!(analyze_email(&provider, "a@x", "s", "b").await.is_none());
363    }
364}