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            .default_identity()
290            .ok()
291            .map(|identity| identity.email.as_str()),
292        None,
293    )
294}
295
296pub fn render_message_section_with_options(
297    message: &MessageFile,
298    body_text: &str,
299    language: TemplateLanguage,
300    account_email: Option<&str>,
301) -> Result<String> {
302    render_message_section_with_root(None, message, body_text, language, account_email, None)
303}
304
305pub(super) fn render_message_section_with_root(
306    root: Option<&Path>,
307    message: &MessageFile,
308    body_text: &str,
309    language: TemplateLanguage,
310    account_email: Option<&str>,
311    output_dir: Option<&Path>,
312) -> Result<String> {
313    let mut renderer = root.map_or_else(
314        || MarkdownTemplateRenderer::builtin(language),
315        |root| MarkdownTemplateRenderer::new(root, language),
316    );
317    renderer.render(
318        TemplateKey::MessageSection,
319        &message_section_context(
320            root,
321            message,
322            body_text,
323            language,
324            account_email,
325            output_dir,
326        )?,
327    )
328}
329
330pub(super) fn message_section_context(
331    root: Option<&Path>,
332    message: &MessageFile,
333    body_text: &str,
334    language: TemplateLanguage,
335    account_email: Option<&str>,
336    output_dir: Option<&Path>,
337) -> Result<Value> {
338    let timestamp_rfc3339 = message
339        .received_rfc3339
340        .as_deref()
341        .or(message.sent_rfc3339.as_deref())
342        .unwrap_or("");
343    let direction = message_thread_direction(message);
344    let display_time = message
345        .received_rfc3339
346        .as_deref()
347        .or(message.sent_rfc3339.as_deref())
348        .unwrap_or("unknown-time");
349    let counterparty = match direction {
350        ThreadDirection::Received => message.from.clone().unwrap_or_default(),
351        ThreadDirection::Sent => message.to.join(", "),
352    };
353    let display_counterparty = markdown_inline(if counterparty.trim().is_empty() {
354        match language {
355            TemplateLanguage::EnUs => "unknown",
356            TemplateLanguage::ZhCn => "未知",
357        }
358    } else {
359        &counterparty
360    });
361    let (message_action, display_heading) = match (direction, language) {
362        (ThreadDirection::Received, TemplateLanguage::EnUs) => (
363            "received",
364            format!("Received from {display_counterparty} - {display_time}"),
365        ),
366        (ThreadDirection::Sent, TemplateLanguage::EnUs) => (
367            "sent",
368            format!("Sent to {display_counterparty} - {display_time}"),
369        ),
370        (ThreadDirection::Received, TemplateLanguage::ZhCn) => (
371            "received",
372            format!("收到自 {display_counterparty} - {display_time}"),
373        ),
374        (ThreadDirection::Sent, TemplateLanguage::ZhCn) => (
375            "sent",
376            format!("发送给 {display_counterparty} - {display_time}"),
377        ),
378    };
379    let from = message.from.as_deref().unwrap_or("");
380    let mut hints = Vec::new();
381    let mut possible_bcc = false;
382    if let Some(account) = account_email
383        .map(email_address)
384        .filter(|value| !value.is_empty())
385    {
386        let visible_recipients = message
387            .to
388            .iter()
389            .chain(message.cc.iter())
390            .map(|value| email_address(value))
391            .collect::<BTreeSet<_>>();
392        let routed_to_me = message
393            .delivered_to
394            .iter()
395            .chain(message.x_original_to.iter())
396            .chain(message.envelope_to.iter())
397            .any(|value| email_address(value) == account);
398        if routed_to_me && !visible_recipients.contains(&account) {
399            possible_bcc = true;
400            hints.push(json!({"kind": "possible_bcc"}));
401        }
402    }
403    let reply_to_differs =
404        !message.reply_to.is_empty() && reply_to_differs_from_from(&message.reply_to, from);
405    let reply_to_recipients = message.reply_to.join(", ");
406    if reply_to_differs {
407        hints.push(json!({
408            "kind": "reply_to_differs",
409            "recipients": reply_to_recipients.as_str(),
410        }));
411    }
412    let mut sender_differs = false;
413    let sender = message.sender.as_deref().unwrap_or("");
414    if let Some(sender) = &message.sender {
415        if email_address(sender) != email_address(from) {
416            sender_differs = true;
417            hints.push(json!({"kind": "sender_differs", "sender": sender}));
418        }
419    }
420    let mailing_list = message.list_id.as_deref().unwrap_or("");
421    let mailing_list_headers = message.mailing_list_headers.join(" | ");
422    if let Some(list_id) = &message.list_id {
423        hints.push(json!({"kind": "mailing_list", "list_id": list_id}));
424    } else if !message.mailing_list_headers.is_empty() {
425        hints.push(json!({
426            "kind": "mailing_list_headers",
427            "headers": mailing_list_headers.as_str(),
428        }));
429    }
430    let auth = &message.authentication;
431    let authentication_check = matches!(direction, ThreadDirection::Received) || auth.has_results();
432    let security = json!({
433        "authentication": {
434            "check": authentication_check,
435            "has_results": auth.has_results(),
436            "spf": auth.spf.as_str(),
437            "dkim": auth.dkim.as_str(),
438            "dmarc": auth.dmarc.as_str(),
439            "dmarc_policy": auth.dmarc_policy.clone(),
440            "authenticated_domain": auth.authenticated_domain.clone(),
441            "from_domain": auth.from_domain.clone(),
442            "alignment": auth.alignment.as_str(),
443        },
444        "possible_bcc": possible_bcc,
445        "reply_to_differs": reply_to_differs,
446        "reply_to_recipients": reply_to_recipients,
447        "sender_differs": sender_differs,
448        "sender": sender,
449        "mailing_list": mailing_list,
450        "mailing_list_headers": mailing_list_headers,
451    });
452    let mut body_text_block = body_text.to_string();
453    if !body_text_block.ends_with('\n') {
454        body_text_block.push('\n');
455    }
456    let visible_body = body_text_visible(body_text);
457    let mut body_text_visible_block = visible_body.visible.clone();
458    if !body_text_visible_block.ends_with('\n') {
459        body_text_visible_block.push('\n');
460    }
461    let body_text_fence = markdown_fence_for(&body_text_visible_block);
462    let quoted_message_id = if visible_body.has_quoted_reply {
463        quoted_local_message_id(root, message)?.unwrap_or_default()
464    } else {
465        String::new()
466    };
467    let attachments = message
468        .attachments
469        .iter()
470        .map(|attachment| {
471            let file_path = attachment.file_path.as_deref().unwrap_or("");
472            let preview_path = if attachment.fetched
473                && !file_path.is_empty()
474                && is_image_content_type(&attachment.content_type)
475            {
476                attachment_markdown_path(root, output_dir, file_path)
477            } else {
478                String::new()
479            };
480            json!({
481                "part_id": attachment.part_id.as_str(),
482                "filename": attachment.filename.as_str(),
483                "display_filename": markdown_inline(&attachment.filename),
484                "image_alt": markdown_image_alt(&attachment.filename),
485                "content_type": attachment.content_type.as_str(),
486                "size_bytes": attachment.size_bytes,
487                "file_path": file_path,
488                "saved_filename": saved_filename_for_attachment(attachment),
489                "source_path": attachment.source_path.as_deref().unwrap_or(""),
490                "fetched": attachment.fetched,
491                "is_image": is_image_content_type(&attachment.content_type),
492                "preview_path": preview_path,
493            })
494        })
495        .collect::<Vec<_>>();
496    Ok(json!({
497        "message": message_template_value(message)?,
498        "view": {
499            "language": language.as_str(),
500            "timestamp_rfc3339": timestamp_rfc3339,
501            "display_heading": display_heading,
502            "message_action": message_action,
503            "display_counterparty": display_counterparty,
504            "body_text": body_text,
505            "body_text_block": body_text_block,
506            "body_text_visible": visible_body.visible,
507            "body_text_visible_block": body_text_visible_block,
508            "body_text_fence": body_text_fence,
509            "has_quoted_reply": visible_body.has_quoted_reply,
510            "quoted_message_id": quoted_message_id,
511            "quoted_from": visible_body.quoted_from.unwrap_or_default(),
512            "quoted_at": visible_body.quoted_at.unwrap_or_default(),
513            "security": security,
514            "hints": hints,
515            "attachments": attachments,
516        },
517    }))
518}
519
520#[derive(Clone, Debug, Default)]
521struct VisibleBodyText {
522    visible: String,
523    has_quoted_reply: bool,
524    quoted_from: Option<String>,
525    quoted_at: Option<String>,
526}
527
528fn body_text_visible(body_text: &str) -> VisibleBodyText {
529    let lines = body_text.lines().collect::<Vec<_>>();
530    for (idx, line) in lines.iter().enumerate() {
531        if let Some((quoted_at, quoted_from)) = parse_apple_wrote_line(line) {
532            return VisibleBodyText {
533                visible: lines[..idx].join("\n").trim_end().to_string(),
534                has_quoted_reply: true,
535                quoted_from,
536                quoted_at,
537            };
538        }
539    }
540    if let Some(idx) = trailing_quote_block_start(&lines) {
541        return VisibleBodyText {
542            visible: lines[..idx].join("\n").trim_end().to_string(),
543            has_quoted_reply: true,
544            quoted_from: None,
545            quoted_at: None,
546        };
547    }
548    VisibleBodyText {
549        visible: body_text.trim_end().to_string(),
550        has_quoted_reply: false,
551        quoted_from: None,
552        quoted_at: None,
553    }
554}
555
556fn parse_apple_wrote_line(line: &str) -> Option<(Option<String>, Option<String>)> {
557    let trimmed = line.trim();
558    if !trimmed.starts_with("On ") || !trimmed.ends_with(" wrote:") {
559        return None;
560    }
561    let inner = trimmed.strip_prefix("On ")?.strip_suffix(" wrote:")?.trim();
562    let (quoted_at, quoted_from) = inner
563        .rsplit_once(',')
564        .map(|(at, from)| {
565            (
566                Some(at.trim().to_string()).filter(|value| !value.is_empty()),
567                Some(from.trim().to_string()).filter(|value| !value.is_empty()),
568            )
569        })
570        .unwrap_or_else(|| {
571            (
572                Some(inner.to_string()).filter(|value| !value.is_empty()),
573                None,
574            )
575        });
576    Some((quoted_at, quoted_from))
577}
578
579fn trailing_quote_block_start(lines: &[&str]) -> Option<usize> {
580    for idx in 0..lines.len() {
581        let rest = &lines[idx..];
582        let mut nonblank = rest.iter().filter(|line| !line.trim().is_empty());
583        if nonblank
584            .clone()
585            .next()
586            .is_some_and(|line| line.trim_start().starts_with('>'))
587            && nonblank.all(|line| line.trim_start().starts_with('>'))
588        {
589            return Some(idx);
590        }
591    }
592    None
593}
594
595fn quoted_local_message_id(root: Option<&Path>, message: &MessageFile) -> Result<Option<String>> {
596    let Some(root) = root else {
597        return Ok(None);
598    };
599    let candidates = message_reply_header_ids(message);
600    if candidates.is_empty() {
601        return Ok(None);
602    }
603    let index = Workspace::at(root).rfc822_message_id_index()?;
604    for candidate in candidates.into_iter().rev() {
605        if let Some(message_id) = index.get(&candidate) {
606            return Ok(Some(message_id.clone()));
607        }
608    }
609    Ok(None)
610}
611
612pub(super) fn message_template_value(message: &MessageFile) -> Result<Value> {
613    serde_json::to_value(message).map_err(|e| AppError::json("serialize message", &e))
614}
615
616pub(super) fn markdown_inline(value: &str) -> String {
617    value.replace(['\r', '\n'], " ").trim().to_string()
618}
619
620pub(super) fn markdown_image_alt(value: &str) -> String {
621    markdown_inline(value)
622        .replace('\\', "\\\\")
623        .replace('[', "\\[")
624        .replace(']', "\\]")
625}
626
627pub(super) fn markdown_fence_for(value: &str) -> String {
628    let mut max_run = 0usize;
629    let mut current = 0usize;
630    for ch in value.chars() {
631        if ch == '`' {
632            current += 1;
633            max_run = max_run.max(current);
634        } else {
635            current = 0;
636        }
637    }
638    "`".repeat(max_run.max(2) + 1)
639}
640
641pub(super) fn reply_to_differs_from_from(reply_to: &[String], from: &str) -> bool {
642    let from = email_address(from);
643    reply_to.len() != 1
644        || reply_to
645            .first()
646            .is_some_and(|value| email_address(value) != from)
647}
648
649pub(super) fn render_template(
650    root: &Path,
651    language: TemplateLanguage,
652    key: TemplateKey,
653    context: &Value,
654) -> Result<String> {
655    let mut renderer = MarkdownTemplateRenderer::new(root, language);
656    renderer.render(key, context)
657}
658
659pub(super) fn markdown_table_cell(value: &str) -> String {
660    value
661        .replace(['\r', '\n'], " ")
662        .replace('|', "\\|")
663        .trim()
664        .to_string()
665}