1use serde::{Deserialize, Serialize};
10
11use crate::provider::LlmProvider;
12
13pub const PROMPT_VERSION: &str = "v8";
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct EmailAnalysis {
25 #[serde(default)]
28 pub category: String,
29 #[serde(default)]
31 pub risk_score: u8,
32 #[serde(default)]
34 pub risk_reason: String,
35 #[serde(default)]
37 pub summary: String,
38 #[serde(default)]
40 pub people: serde_json::Value,
41 #[serde(default)]
43 pub dates: serde_json::Value,
44 #[serde(default)]
46 pub amounts: serde_json::Value,
47 #[serde(default)]
49 pub action_items: serde_json::Value,
50 #[serde(default)]
52 pub clean_text: String,
53 #[serde(default)]
55 pub requires_action: bool,
56 #[serde(default)]
58 pub sender_intent: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub action_deadline: Option<String>,
62}
63
64fn 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
76pub 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
124fn 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 &text[start..]
142 };
143
144 let mut analysis: EmailAnalysis = match serde_json::from_str(json_str) {
145 Ok(a) => a,
146 Err(_) => {
147 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 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 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}