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}