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}