1use crate::mail::{Attachment, Draft, Email, EmailAddress, Identity, format_addresses};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Kind {
11 New,
12 Reply,
13 ReplyAll,
14 Forward,
15}
16
17#[derive(Debug, Clone)]
19pub struct Phrases {
20 pub wrote: String,
22 pub forwarded: String,
24 pub from: String,
25 pub date: String,
26 pub subject: String,
27 pub to: String,
28 pub date_format: String,
30}
31
32impl Default for Phrases {
33 fn default() -> Self {
34 Self {
35 wrote: "On {date}, {who} wrote:".into(),
36 forwarded: "---------- Forwarded message ----------".into(),
37 from: "From".into(),
38 date: "Date".into(),
39 subject: "Subject".into(),
40 to: "To".into(),
41 date_format: "%a, %d %b %Y %H:%M".into(),
42 }
43 }
44}
45
46pub fn prefixed(prefix: &str, subject: &str) -> String {
48 let subject = subject.trim();
49 if subject.to_lowercase().starts_with(&prefix.to_lowercase()) {
50 subject.to_owned()
51 } else {
52 format!("{prefix}{subject}")
53 }
54}
55
56pub fn quote(text: &str) -> String {
58 text.lines()
59 .map(|line| if line.is_empty() { ">".to_owned() } else { format!("> {line}") })
60 .collect::<Vec<_>>()
61 .join("\n")
62}
63
64fn mine(address: &EmailAddress, identities: &[Identity]) -> bool {
65 identities.iter().any(|identity| identity.email.eq_ignore_ascii_case(&address.email))
66}
67
68pub fn identity_for<'a>(original: Option<&Email>, identities: &'a [Identity]) -> Option<&'a Identity> {
70 let addressed = original.into_iter().flat_map(|email| {
71 email.summary.to.iter().flatten().chain(email.cc.iter().flatten()).cloned().collect::<Vec<_>>()
72 });
73 for address in addressed {
74 if let Some(identity) = identities.iter().find(|i| i.email.eq_ignore_ascii_case(&address.email)) {
75 return Some(identity);
76 }
77 }
78 identities.first()
79}
80
81pub fn start(kind: Kind, original: Option<(&Email, &str)>, identities: &[Identity], phrases: &Phrases) -> Draft {
84 let mut draft = Draft::default();
85 let Some((email, text)) = original.filter(|_| kind != Kind::New) else { return draft };
86 let subject = email.summary.subject.clone().unwrap_or_default();
87 let sender = email.summary.from.clone().unwrap_or_default();
88 let date = email.sent_at.clone().unwrap_or_else(|| email.summary.received_at.clone());
89 let date = chrono::DateTime::parse_from_rfc3339(&date)
90 .map(|time| time.with_timezone(&chrono::Local).format(&phrases.date_format).to_string())
91 .unwrap_or(date);
92 match kind {
93 Kind::Reply | Kind::ReplyAll => {
94 let reply_to = email.reply_to.clone().filter(|list| !list.is_empty()).unwrap_or_else(|| sender.clone());
95 draft.to = reply_to;
96 if kind == Kind::ReplyAll {
97 let mut cc: Vec<EmailAddress> = Vec::new();
98 for address in email.summary.to.iter().flatten().chain(email.cc.iter().flatten()) {
99 let known = draft.to.iter().chain(cc.iter()).any(|a| a.email.eq_ignore_ascii_case(&address.email));
100 if !known && !mine(address, identities) {
101 cc.push(address.clone());
102 }
103 }
104 draft.cc = cc;
105 }
106 draft.subject = prefixed("Re: ", &subject);
107 let wrote = phrases.wrote.replace("{date}", &date).replace("{who}", &format_addresses(&sender));
108 draft.body = format!("\n\n{wrote}\n{}\n", quote(text));
109 draft.in_reply_to = email.message_id.clone().unwrap_or_default();
110 draft.references = email.references.clone().unwrap_or_default();
111 draft.references.extend(draft.in_reply_to.iter().cloned());
112 }
113 Kind::Forward => {
114 draft.subject = prefixed("Fwd: ", &subject);
115 draft.body = format!(
116 "\n\n{}\n{}: {}\n{}: {date}\n{}: {subject}\n{}: {}\n\n{text}\n",
117 phrases.forwarded,
118 phrases.from,
119 format_addresses(&sender),
120 phrases.date,
121 phrases.subject,
122 phrases.to,
123 format_addresses(email.summary.to.as_deref().unwrap_or_default()),
124 );
125 draft.attachments = email
127 .attachments
128 .iter()
129 .filter_map(|part| {
130 Some(Attachment {
131 blob_id: part.blob_id.clone()?,
132 kind: part.kind.clone(),
133 name: part.name.clone().unwrap_or_else(|| "attachment".into()),
134 size: part.size,
135 })
136 })
137 .collect();
138 }
139 Kind::New => {}
140 }
141 draft
142}
143
144#[cfg(test)]
145mod tests {
146 use std::collections::BTreeMap;
147
148 use super::*;
149 use crate::mail::{BodyPart, EmailSummary};
150
151 fn identities() -> Vec<Identity> {
152 vec![Identity { id: "i1".into(), name: "Alice".into(), email: "alice@firma.example".into() }]
153 }
154
155 fn original() -> Email {
156 let address = |email: &str| EmailAddress { name: None, email: email.into() };
157 Email {
158 summary: EmailSummary {
159 id: "e1".into(),
160 thread_id: "t".into(),
161 mailbox_ids: BTreeMap::new(),
162 keywords: BTreeMap::new(),
163 from: Some(vec![EmailAddress { name: Some("Bob".into()), email: "bob@x.example".into() }]),
164 to: Some(vec![address("alice@firma.example"), address("carol@x.example")]),
165 subject: Some("Plans".into()),
166 received_at: "2026-10-28T10:00:00Z".into(),
167 preview: String::new(),
168 has_attachment: true,
169 size: 0,
170 encrypted: false,
171 encrypted_summary: None,
172 opened: false,
173 },
174 cc: Some(vec![address("dave@x.example"), address("bob@x.example")]),
175 reply_to: None,
176 message_id: Some(vec!["m2@x".into()]),
177 references: Some(vec!["m1@x".into()]),
178 sent_at: None,
179 text_body: Vec::new(),
180 html_body: Vec::new(),
181 attachments: vec![BodyPart {
182 part_id: Some("2".into()),
183 blob_id: Some("B".into()),
184 kind: "application/pdf".into(),
185 name: Some("plan.pdf".into()),
186 size: 10,
187 inner: None,
188 }],
189 body_values: BTreeMap::new(),
190 blob_id: None,
191 sealed: None,
192 inner: None,
193 }
194 }
195
196 #[test]
197 fn reply_to_all_without_the_user() {
198 let email = original();
199 let ids = identities();
200 assert_eq!(identity_for(Some(&email), &ids).map(|i| i.id.as_str()), Some("i1"));
201 let draft = start(Kind::ReplyAll, Some((&email, "Hi\n\nBye")), &ids, &Phrases::default());
202 assert_eq!(format_addresses(&draft.to), "Bob <bob@x.example>");
203 assert_eq!(format_addresses(&draft.cc), "carol@x.example, dave@x.example");
204 assert_eq!(draft.subject, "Re: Plans");
205 assert_eq!(draft.in_reply_to, ["m2@x"]);
206 assert_eq!(draft.references, ["m1@x", "m2@x"]);
207 assert!(draft.body.contains("Bob <bob@x.example> wrote:\n> Hi\n>\n> Bye"), "{}", draft.body);
208 assert!(draft.attachments.is_empty());
209 }
210
211 #[test]
212 fn a_forward_carries_the_attachments_in_the_users_words() {
213 let phrases = Phrases {
214 forwarded: "---------- Weitergeleitete Nachricht ----------".into(),
215 from: "Von".into(),
216 ..Phrases::default()
217 };
218 let draft = start(Kind::Forward, Some((&original(), "Hi")), &[], &phrases);
219 assert_eq!(draft.subject, "Fwd: Plans");
220 assert_eq!(draft.attachments.len(), 1);
221 assert!(
222 draft.body.contains("Weitergeleitete Nachricht ----------\nVon: Bob <bob@x.example>"),
223 "{}",
224 draft.body
225 );
226 assert_eq!(prefixed("Re: ", "RE: x"), "RE: x");
227 }
228}