Skip to main content

agent_first_mail/store/
render.rs

1use super::*;
2
3pub(super) fn message_time(message: &MessageFile) -> Option<String> {
4    message_time_raw(message).map(ToString::to_string)
5}
6
7pub(super) fn message_time_raw(message: &MessageFile) -> Option<&str> {
8    message
9        .received_rfc3339
10        .as_deref()
11        .or(message.sent_rfc3339.as_deref())
12}
13
14pub(super) fn message_time_utc(message: &MessageFile) -> Option<DateTime<Utc>> {
15    message_time_raw(message)
16        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
17        .map(|value| value.with_timezone(&Utc))
18}
19
20pub(super) fn compare_message_time_asc(a: &MessageFile, b: &MessageFile) -> std::cmp::Ordering {
21    match (message_time_utc(a), message_time_utc(b)) {
22        (Some(a_time), Some(b_time)) => a_time.cmp(&b_time),
23        (Some(_), None) => std::cmp::Ordering::Less,
24        (None, Some(_)) => std::cmp::Ordering::Greater,
25        (None, None) => message_time(a)
26            .unwrap_or_default()
27            .cmp(&message_time(b).unwrap_or_default()),
28    }
29    .then_with(|| a.message_id.cmp(&b.message_id))
30}
31
32pub(super) fn compare_rfc3339_asc(a: &str, b: &str) -> std::cmp::Ordering {
33    let a_time = DateTime::parse_from_rfc3339(a)
34        .ok()
35        .map(|value| value.with_timezone(&Utc));
36    let b_time = DateTime::parse_from_rfc3339(b)
37        .ok()
38        .map(|value| value.with_timezone(&Utc));
39    match (a_time, b_time) {
40        (Some(a_time), Some(b_time)) => a_time.cmp(&b_time),
41        (Some(_), None) => std::cmp::Ordering::Less,
42        (None, Some(_)) => std::cmp::Ordering::Greater,
43        (None, None) => a.cmp(b),
44    }
45}
46
47pub(super) fn message_time_datetime(message: &MessageFile, offset: &FixedOffset) -> Option<String> {
48    message_time_raw(message)
49        .and_then(|value| DateTime::parse_from_rfc3339(value).ok())
50        .map(|value| {
51            value
52                .with_timezone(offset)
53                .format("%Y-%m-%d %H:%M")
54                .to_string()
55        })
56}
57
58pub(super) fn message_time_context(message: &MessageFile, offset: &FixedOffset) -> Value {
59    time_context(message_time_raw(message).unwrap_or_default(), offset)
60}
61
62pub(super) fn time_context(original: &str, offset: &FixedOffset) -> Value {
63    if let Ok(parsed) = DateTime::parse_from_rfc3339(original) {
64        let local = parsed.with_timezone(offset);
65        json!({
66            "original_rfc3339": original,
67            "local_rfc3339": local.to_rfc3339_opts(SecondsFormat::Secs, true),
68            "date": local.format("%Y-%m-%d").to_string(),
69            "time": local.format("%H:%M").to_string(),
70            "datetime": local.format("%Y-%m-%d %H:%M").to_string(),
71            "year": local.year(),
72            "month": local.month(),
73            "day": local.day(),
74            "hour": local.hour(),
75            "minute": local.minute(),
76        })
77    } else {
78        json!({
79            "original_rfc3339": original,
80            "local_rfc3339": "",
81            "date": "",
82            "time": "",
83            "datetime": "",
84            "year": null,
85            "month": null,
86            "day": null,
87            "hour": null,
88            "minute": null,
89        })
90    }
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq)]
94pub(super) enum ThreadDirection {
95    Received,
96    Sent,
97}
98
99#[derive(Clone, Copy, Debug, PartialEq, Eq)]
100pub(super) enum ThreadAction {
101    Message,
102    Reply,
103    Forward,
104}
105
106pub(super) fn message_thread_direction(message: &MessageFile) -> ThreadDirection {
107    match message.direction.as_deref().map(str::trim) {
108        Some(direction)
109            if MailDirection::parse(direction)
110                .ok()
111                .is_some_and(|direction| direction == MailDirection::Outbound) =>
112        {
113            ThreadDirection::Sent
114        }
115        _ if message.received_rfc3339.is_none() && message.sent_rfc3339.is_some() => {
116            ThreadDirection::Sent
117        }
118        _ => ThreadDirection::Received,
119    }
120}
121
122pub(super) fn message_thread_action(message: &MessageFile) -> ThreadAction {
123    let subject = message.subject.as_deref().unwrap_or_default();
124    if subject_has_prefix(subject, &["fwd:", "fw:", "转发:", "轉發:", "fwd:", "fw:"]) {
125        return ThreadAction::Forward;
126    }
127    if message
128        .in_reply_to
129        .as_deref()
130        .is_some_and(|value| !value.trim().is_empty())
131        || !message.references.is_empty()
132        || subject_has_prefix(
133            subject,
134            &["re:", "回复:", "回覆:", "答复:", "答覆:", "re:"],
135        )
136    {
137        return ThreadAction::Reply;
138    }
139    ThreadAction::Message
140}
141
142pub(super) fn subject_has_prefix(subject: &str, prefixes: &[&str]) -> bool {
143    let lower = subject.trim_start().to_ascii_lowercase();
144    prefixes
145        .iter()
146        .any(|prefix| lower.starts_with(&prefix.to_ascii_lowercase()))
147}
148
149pub(super) fn thread_label(
150    direction: ThreadDirection,
151    action: ThreadAction,
152    language: TemplateLanguage,
153) -> &'static str {
154    match language {
155        TemplateLanguage::EnUs => match (direction, action) {
156            (ThreadDirection::Received, ThreadAction::Message) => "\u{2190} Received",
157            (ThreadDirection::Received, ThreadAction::Reply) => "\u{2190} Received reply",
158            (ThreadDirection::Received, ThreadAction::Forward) => "\u{2190} Received forward",
159            (ThreadDirection::Sent, ThreadAction::Message) => "\u{2192} Sent",
160            (ThreadDirection::Sent, ThreadAction::Reply) => "\u{2192} Sent reply",
161            (ThreadDirection::Sent, ThreadAction::Forward) => "\u{2192} Sent forward",
162        },
163        TemplateLanguage::ZhCn => match (direction, action) {
164            (ThreadDirection::Received, ThreadAction::Message) => "\u{2190} 收到",
165            (ThreadDirection::Received, ThreadAction::Reply) => "\u{2190} 收到回复",
166            (ThreadDirection::Received, ThreadAction::Forward) => "\u{2190} 收到转发",
167            (ThreadDirection::Sent, ThreadAction::Message) => "\u{2192} 发送",
168            (ThreadDirection::Sent, ThreadAction::Reply) => "\u{2192} 发送回复",
169            (ThreadDirection::Sent, ThreadAction::Forward) => "\u{2192} 发送转发",
170        },
171    }
172}
173
174pub(super) fn thread_action_kind(direction: ThreadDirection, action: ThreadAction) -> &'static str {
175    match (direction, action) {
176        (ThreadDirection::Received, ThreadAction::Message) => "received",
177        (ThreadDirection::Received, ThreadAction::Reply) => "received_reply",
178        (ThreadDirection::Received, ThreadAction::Forward) => "received_forward",
179        (ThreadDirection::Sent, ThreadAction::Message) => "sent",
180        (ThreadDirection::Sent, ThreadAction::Reply) => "sent_reply",
181        (ThreadDirection::Sent, ThreadAction::Forward) => "sent_forward",
182    }
183}
184
185pub(super) fn thread_contact(
186    message: &MessageFile,
187    direction: ThreadDirection,
188    language: TemplateLanguage,
189) -> (&'static str, &'static str, String) {
190    match (direction, language) {
191        (ThreadDirection::Received, TemplateLanguage::EnUs) => {
192            ("from", "From", message.from.clone().unwrap_or_default())
193        }
194        (ThreadDirection::Received, TemplateLanguage::ZhCn) => {
195            ("from", "发件人", message.from.clone().unwrap_or_default())
196        }
197        (ThreadDirection::Sent, TemplateLanguage::EnUs) => ("to", "To", message.to.join(", ")),
198        (ThreadDirection::Sent, TemplateLanguage::ZhCn) => ("to", "收件人", message.to.join(", ")),
199    }
200}
201
202pub(super) fn thread_item_common(
203    message: &MessageFile,
204    offset: &FixedOffset,
205    language: TemplateLanguage,
206    link: String,
207    title: String,
208) -> Result<Value> {
209    let direction = message_thread_direction(message);
210    let action = message_thread_action(message);
211    let (contact_kind, contact_label, contact) = thread_contact(message, direction, language);
212    let time = message_time_context(message, offset);
213    let display_time = time
214        .get("datetime")
215        .and_then(Value::as_str)
216        .unwrap_or_default()
217        .to_string();
218    let direction_kind = match direction {
219        ThreadDirection::Received => "received",
220        ThreadDirection::Sent => "sent",
221    };
222    let action_kind = match action {
223        ThreadAction::Message => "message",
224        ThreadAction::Reply => "reply",
225        ThreadAction::Forward => "forward",
226    };
227    Ok(json!({
228        "message": message_template_value(message)?,
229        "view": {
230            "time": time,
231            "time_rfc3339": message_time(message).unwrap_or_default(),
232            "display_time": display_time,
233            "direction": match direction {
234                ThreadDirection::Received => "inbound",
235                ThreadDirection::Sent => "outbound",
236            },
237            "direction_kind": direction_kind,
238            "direction_symbol": match direction {
239                ThreadDirection::Received => "\u{2190}",
240                ThreadDirection::Sent => "\u{2192}",
241            },
242            "action": action_kind,
243            "action_kind": thread_action_kind(direction, action),
244            "action_label": thread_label(direction, action, language),
245            "is_reply": action == ThreadAction::Reply,
246            "is_forward": action == ThreadAction::Forward,
247            "contact_kind": contact_kind,
248            "contact_label": contact_label,
249            "contact": contact.as_str(),
250            "display_contact": markdown_inline(&contact),
251            "display_subject": message
252                .subject
253                .as_deref()
254                .map(markdown_inline)
255                .unwrap_or_default(),
256            "title": title.as_str(),
257            "display_title": markdown_inline(&title),
258            "display_status": markdown_inline(&message.workspace.status),
259            "link": link,
260        },
261    }))
262}
263
264pub fn clean_body_text(input: &str) -> String {
265    input
266        .replace("\r\n", "\n")
267        .replace('\r', "\n")
268        .chars()
269        .filter(|ch| *ch == '\n' || *ch == '\t' || !ch.is_control())
270        .collect()
271}
272
273pub fn render_message_section(message: &MessageFile, body_text: &str) -> Result<String> {
274    render_message_section_with_options(message, body_text, TemplateLanguage::default(), None)
275}
276
277pub fn render_message_section_with_config(
278    root: &Path,
279    message: &MessageFile,
280    body_text: &str,
281    config: &MailConfig,
282) -> Result<String> {
283    render_message_section_with_root(
284        Some(root),
285        message,
286        body_text,
287        config.template_language(),
288        config
289            .smtp
290            .from
291            .as_deref()
292            .or(config.imap.username.as_deref()),
293        None,
294    )
295}
296
297pub fn render_message_section_with_options(
298    message: &MessageFile,
299    body_text: &str,
300    language: TemplateLanguage,
301    account_email: Option<&str>,
302) -> Result<String> {
303    render_message_section_with_root(None, message, body_text, language, account_email, None)
304}
305
306pub(super) fn render_message_section_with_root(
307    root: Option<&Path>,
308    message: &MessageFile,
309    body_text: &str,
310    language: TemplateLanguage,
311    account_email: Option<&str>,
312    output_dir: Option<&Path>,
313) -> Result<String> {
314    let mut renderer = root.map_or_else(
315        || MarkdownTemplateRenderer::builtin(language),
316        |root| MarkdownTemplateRenderer::new(root, language),
317    );
318    renderer.render(
319        TemplateKey::MessageSection,
320        &message_section_context(
321            root,
322            message,
323            body_text,
324            language,
325            account_email,
326            output_dir,
327        )?,
328    )
329}
330
331pub(super) fn message_section_context(
332    root: Option<&Path>,
333    message: &MessageFile,
334    body_text: &str,
335    language: TemplateLanguage,
336    account_email: Option<&str>,
337    output_dir: Option<&Path>,
338) -> Result<Value> {
339    let timestamp_rfc3339 = message
340        .received_rfc3339
341        .as_deref()
342        .or(message.sent_rfc3339.as_deref())
343        .unwrap_or("");
344    let direction = message_thread_direction(message);
345    let display_time = message
346        .received_rfc3339
347        .as_deref()
348        .or(message.sent_rfc3339.as_deref())
349        .unwrap_or("unknown-time");
350    let counterparty = match direction {
351        ThreadDirection::Received => message.from.clone().unwrap_or_default(),
352        ThreadDirection::Sent => message.to.join(", "),
353    };
354    let display_counterparty = markdown_inline(if counterparty.trim().is_empty() {
355        match language {
356            TemplateLanguage::EnUs => "unknown",
357            TemplateLanguage::ZhCn => "未知",
358        }
359    } else {
360        &counterparty
361    });
362    let (message_action, display_heading) = match (direction, language) {
363        (ThreadDirection::Received, TemplateLanguage::EnUs) => (
364            "received",
365            format!("Received from {display_counterparty} - {display_time}"),
366        ),
367        (ThreadDirection::Sent, TemplateLanguage::EnUs) => (
368            "sent",
369            format!("Sent to {display_counterparty} - {display_time}"),
370        ),
371        (ThreadDirection::Received, TemplateLanguage::ZhCn) => (
372            "received",
373            format!("收到自 {display_counterparty} - {display_time}"),
374        ),
375        (ThreadDirection::Sent, TemplateLanguage::ZhCn) => (
376            "sent",
377            format!("发送给 {display_counterparty} - {display_time}"),
378        ),
379    };
380    let from = message.from.as_deref().unwrap_or("");
381    let mut hints = Vec::new();
382    let mut possible_bcc = false;
383    if let Some(account) = account_email
384        .map(email_address)
385        .filter(|value| !value.is_empty())
386    {
387        let visible_recipients = message
388            .to
389            .iter()
390            .chain(message.cc.iter())
391            .map(|value| email_address(value))
392            .collect::<BTreeSet<_>>();
393        let routed_to_me = message
394            .delivered_to
395            .iter()
396            .chain(message.x_original_to.iter())
397            .chain(message.envelope_to.iter())
398            .any(|value| email_address(value) == account);
399        if routed_to_me && !visible_recipients.contains(&account) {
400            possible_bcc = true;
401            hints.push(json!({"kind": "possible_bcc"}));
402        }
403    }
404    let reply_to_differs =
405        !message.reply_to.is_empty() && reply_to_differs_from_from(&message.reply_to, from);
406    let reply_to_recipients = message.reply_to.join(", ");
407    if reply_to_differs {
408        hints.push(json!({
409            "kind": "reply_to_differs",
410            "recipients": reply_to_recipients.as_str(),
411        }));
412    }
413    let mut sender_differs = false;
414    let sender = message.sender.as_deref().unwrap_or("");
415    if let Some(sender) = &message.sender {
416        if email_address(sender) != email_address(from) {
417            sender_differs = true;
418            hints.push(json!({"kind": "sender_differs", "sender": sender}));
419        }
420    }
421    let mailing_list = message.list_id.as_deref().unwrap_or("");
422    let mailing_list_headers = message.mailing_list_headers.join(" | ");
423    if let Some(list_id) = &message.list_id {
424        hints.push(json!({"kind": "mailing_list", "list_id": list_id}));
425    } else if !message.mailing_list_headers.is_empty() {
426        hints.push(json!({
427            "kind": "mailing_list_headers",
428            "headers": mailing_list_headers.as_str(),
429        }));
430    }
431    let auth = &message.authentication;
432    let authentication_check = matches!(direction, ThreadDirection::Received) || auth.has_results();
433    let security = json!({
434        "authentication": {
435            "check": authentication_check,
436            "has_results": auth.has_results(),
437            "spf": auth.spf.as_str(),
438            "dkim": auth.dkim.as_str(),
439            "dmarc": auth.dmarc.as_str(),
440            "dmarc_policy": auth.dmarc_policy.clone(),
441            "authenticated_domain": auth.authenticated_domain.clone(),
442            "from_domain": auth.from_domain.clone(),
443            "alignment": auth.alignment.as_str(),
444        },
445        "possible_bcc": possible_bcc,
446        "reply_to_differs": reply_to_differs,
447        "reply_to_recipients": reply_to_recipients,
448        "sender_differs": sender_differs,
449        "sender": sender,
450        "mailing_list": mailing_list,
451        "mailing_list_headers": mailing_list_headers,
452    });
453    let mut body_text_block = body_text.to_string();
454    if !body_text_block.ends_with('\n') {
455        body_text_block.push('\n');
456    }
457    let visible_body = body_text_visible(body_text);
458    let mut body_text_visible_block = visible_body.visible.clone();
459    if !body_text_visible_block.ends_with('\n') {
460        body_text_visible_block.push('\n');
461    }
462    let body_text_fence = markdown_fence_for(&body_text_visible_block);
463    let quoted_message_id = if visible_body.has_quoted_reply {
464        quoted_local_message_id(root, message)?.unwrap_or_default()
465    } else {
466        String::new()
467    };
468    let attachments = message
469        .attachments
470        .iter()
471        .map(|attachment| {
472            let file_path = attachment.file_path.as_deref().unwrap_or("");
473            let preview_path = if attachment.fetched
474                && !file_path.is_empty()
475                && is_image_content_type(&attachment.content_type)
476            {
477                attachment_markdown_path(root, output_dir, file_path)
478            } else {
479                String::new()
480            };
481            json!({
482                "part_id": attachment.part_id.as_str(),
483                "filename": attachment.filename.as_str(),
484                "display_filename": markdown_inline(&attachment.filename),
485                "image_alt": markdown_image_alt(&attachment.filename),
486                "content_type": attachment.content_type.as_str(),
487                "size_bytes": attachment.size_bytes,
488                "file_path": file_path,
489                "saved_filename": saved_filename_for_attachment(attachment),
490                "source_path": attachment.source_path.as_deref().unwrap_or(""),
491                "fetched": attachment.fetched,
492                "is_image": is_image_content_type(&attachment.content_type),
493                "preview_path": preview_path,
494            })
495        })
496        .collect::<Vec<_>>();
497    Ok(json!({
498        "message": message_template_value(message)?,
499        "view": {
500            "language": language.as_str(),
501            "timestamp_rfc3339": timestamp_rfc3339,
502            "display_heading": display_heading,
503            "message_action": message_action,
504            "display_counterparty": display_counterparty,
505            "body_text": body_text,
506            "body_text_block": body_text_block,
507            "body_text_visible": visible_body.visible,
508            "body_text_visible_block": body_text_visible_block,
509            "body_text_fence": body_text_fence,
510            "has_quoted_reply": visible_body.has_quoted_reply,
511            "quoted_message_id": quoted_message_id,
512            "quoted_from": visible_body.quoted_from.unwrap_or_default(),
513            "quoted_at": visible_body.quoted_at.unwrap_or_default(),
514            "security": security,
515            "hints": hints,
516            "attachments": attachments,
517        },
518    }))
519}
520
521#[derive(Clone, Debug, Default)]
522struct VisibleBodyText {
523    visible: String,
524    has_quoted_reply: bool,
525    quoted_from: Option<String>,
526    quoted_at: Option<String>,
527}
528
529fn body_text_visible(body_text: &str) -> VisibleBodyText {
530    let lines = body_text.lines().collect::<Vec<_>>();
531    for (idx, line) in lines.iter().enumerate() {
532        if let Some((quoted_at, quoted_from)) = parse_apple_wrote_line(line) {
533            return VisibleBodyText {
534                visible: lines[..idx].join("\n").trim_end().to_string(),
535                has_quoted_reply: true,
536                quoted_from,
537                quoted_at,
538            };
539        }
540    }
541    if let Some(idx) = trailing_quote_block_start(&lines) {
542        return VisibleBodyText {
543            visible: lines[..idx].join("\n").trim_end().to_string(),
544            has_quoted_reply: true,
545            quoted_from: None,
546            quoted_at: None,
547        };
548    }
549    VisibleBodyText {
550        visible: body_text.trim_end().to_string(),
551        has_quoted_reply: false,
552        quoted_from: None,
553        quoted_at: None,
554    }
555}
556
557fn parse_apple_wrote_line(line: &str) -> Option<(Option<String>, Option<String>)> {
558    let trimmed = line.trim();
559    if !trimmed.starts_with("On ") || !trimmed.ends_with(" wrote:") {
560        return None;
561    }
562    let inner = trimmed.strip_prefix("On ")?.strip_suffix(" wrote:")?.trim();
563    let (quoted_at, quoted_from) = inner
564        .rsplit_once(',')
565        .map(|(at, from)| {
566            (
567                Some(at.trim().to_string()).filter(|value| !value.is_empty()),
568                Some(from.trim().to_string()).filter(|value| !value.is_empty()),
569            )
570        })
571        .unwrap_or_else(|| {
572            (
573                Some(inner.to_string()).filter(|value| !value.is_empty()),
574                None,
575            )
576        });
577    Some((quoted_at, quoted_from))
578}
579
580fn trailing_quote_block_start(lines: &[&str]) -> Option<usize> {
581    for idx in 0..lines.len() {
582        let rest = &lines[idx..];
583        let mut nonblank = rest.iter().filter(|line| !line.trim().is_empty());
584        if nonblank
585            .clone()
586            .next()
587            .is_some_and(|line| line.trim_start().starts_with('>'))
588            && nonblank.all(|line| line.trim_start().starts_with('>'))
589        {
590            return Some(idx);
591        }
592    }
593    None
594}
595
596fn quoted_local_message_id(root: Option<&Path>, message: &MessageFile) -> Result<Option<String>> {
597    let Some(root) = root else {
598        return Ok(None);
599    };
600    let candidates = message_reply_header_ids(message);
601    if candidates.is_empty() {
602        return Ok(None);
603    }
604    let index = Workspace::at(root).rfc822_message_id_index()?;
605    for candidate in candidates.into_iter().rev() {
606        if let Some(message_id) = index.get(&candidate) {
607            return Ok(Some(message_id.clone()));
608        }
609    }
610    Ok(None)
611}
612
613pub(super) fn message_template_value(message: &MessageFile) -> Result<Value> {
614    serde_json::to_value(message).map_err(|e| AppError::json("serialize message", &e))
615}
616
617pub(super) fn markdown_inline(value: &str) -> String {
618    value.replace(['\r', '\n'], " ").trim().to_string()
619}
620
621pub(super) fn markdown_image_alt(value: &str) -> String {
622    markdown_inline(value)
623        .replace('\\', "\\\\")
624        .replace('[', "\\[")
625        .replace(']', "\\]")
626}
627
628pub(super) fn markdown_fence_for(value: &str) -> String {
629    let mut max_run = 0usize;
630    let mut current = 0usize;
631    for ch in value.chars() {
632        if ch == '`' {
633            current += 1;
634            max_run = max_run.max(current);
635        } else {
636            current = 0;
637        }
638    }
639    "`".repeat(max_run.max(2) + 1)
640}
641
642pub(super) fn reply_to_differs_from_from(reply_to: &[String], from: &str) -> bool {
643    let from = email_address(from);
644    reply_to.len() != 1
645        || reply_to
646            .first()
647            .is_some_and(|value| email_address(value) != from)
648}
649
650pub(super) fn render_template(
651    root: &Path,
652    language: TemplateLanguage,
653    key: TemplateKey,
654    context: &Value,
655) -> Result<String> {
656    let mut renderer = MarkdownTemplateRenderer::new(root, language);
657    renderer.render(key, context)
658}
659
660pub(super) fn markdown_table_cell(value: &str) -> String {
661    value
662        .replace(['\r', '\n'], " ")
663        .replace('|', "\\|")
664        .trim()
665        .to_string()
666}