Skip to main content

kernoid_client/
reply.rs

1// SPDX-License-Identifier: MIT
2//! Replies and forwards, as every one of kernoid's clients writes them: `Re: `/`Fwd: ` before the subject (once), the
3//! text quoted, the thread kept (`In-Reply-To`, `References`), "reply to all" without the user's own addresses, and a
4//! forward carrying the attachments along. The words around the quote are the caller's ([`Phrases`]).
5
6use crate::mail::{Attachment, Draft, Email, EmailAddress, Identity, format_addresses};
7
8/// How a message refers to another.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum Kind {
11    New,
12    Reply,
13    ReplyAll,
14    Forward,
15}
16
17/// The words around a quote, in the user's language. `{date}` and `{who}` are filled in.
18#[derive(Debug, Clone)]
19pub struct Phrases {
20    /// Above a quoted reply: `On {date}, {who} wrote:`.
21    pub wrote: String,
22    /// The line that begins a forwarded message.
23    pub forwarded: String,
24    pub from: String,
25    pub date: String,
26    pub subject: String,
27    pub to: String,
28    /// How a date is written (chrono's format), in the local time zone.
29    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
46/// `Re: ` or `Fwd: ` before a subject, unless it is there already (in any case).
47pub 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
56/// Each line with `> ` before it.
57pub 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
68/// The identity to write from: the one the original was sent to, or the first.
69pub 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
81/// A new message, or a reply to or a forward of `original` (with `text`, its text as read); `identities` are the
82/// user's, left out of "reply to all".
83pub 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            // Parts on the server; those of an encrypted message are in the decrypted one only (OPEN: forwarding them).
126            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}