Skip to main content

mxr_compose/
email.rs

1use crate::attachments::resolve_attachment_paths;
2use crate::frontmatter::ComposeError;
3use crate::render::render_markdown;
4use lettre::message::{
5    header::ContentType,
6    Attachment, Mailbox, Message, MultiPart, SinglePart,
7};
8use mxr_core::types::{Address, Draft};
9use std::fs;
10
11pub fn build_message(
12    draft: &Draft,
13    from: &Address,
14    keep_bcc: bool,
15) -> Result<Message, EmailBuildError> {
16    let from_mailbox = to_mailbox(from)?;
17    let message_id_domain = from
18        .email
19        .split_once('@')
20        .map(|(_, domain)| domain)
21        .filter(|domain| !domain.is_empty())
22        .unwrap_or("localhost");
23
24    let mut builder = Message::builder()
25        .from(from_mailbox)
26        .subject(&draft.subject)
27        .message_id(Some(format!(
28            "<{}@{}>",
29            uuid::Uuid::now_v7(),
30            message_id_domain
31        )));
32
33    if keep_bcc {
34        builder = builder.keep_bcc();
35    }
36
37    for addr in &draft.to {
38        builder = builder.to(to_mailbox(addr)?);
39    }
40
41    for addr in &draft.cc {
42        builder = builder.cc(to_mailbox(addr)?);
43    }
44
45    for addr in &draft.bcc {
46        builder = builder.bcc(to_mailbox(addr)?);
47    }
48
49    if let Some(reply_headers) = &draft.reply_headers {
50        builder = builder.in_reply_to(reply_headers.in_reply_to.clone());
51
52        let mut references = reply_headers.references.clone();
53        if !references
54            .iter()
55            .any(|reference| reference == &reply_headers.in_reply_to)
56        {
57            references.push(reply_headers.in_reply_to.clone());
58        }
59
60        if !references.is_empty() {
61            builder = builder.references(references.join(" "));
62        }
63    }
64
65    let rendered = render_markdown(&draft.body_markdown);
66    let alternative = MultiPart::alternative()
67        .singlepart(
68            SinglePart::builder()
69                .header(ContentType::parse("text/plain; charset=utf-8").unwrap())
70                .body(rendered.plain),
71        )
72        .singlepart(
73            SinglePart::builder()
74                .header(ContentType::parse("text/html; charset=utf-8").unwrap())
75                .body(rendered.html),
76        );
77
78    let body = if draft.attachments.is_empty() {
79        alternative
80    } else {
81        let mut mixed = MultiPart::mixed().multipart(alternative);
82        for attachment in resolve_attachment_paths(&draft.attachments)? {
83            let content_type = ContentType::parse(&attachment.mime_type)
84                .unwrap_or(ContentType::parse("application/octet-stream").unwrap());
85            let bytes = fs::read(&attachment.path)?;
86            mixed = mixed.singlepart(Attachment::new(attachment.filename).body(bytes, content_type));
87        }
88        mixed
89    };
90
91    builder
92        .multipart(body)
93        .map_err(|err| EmailBuildError::Message(err.to_string()))
94}
95
96pub fn format_message_for_gmail(message: &Message) -> Vec<u8> {
97    message.formatted()
98}
99
100fn to_mailbox(addr: &Address) -> Result<Mailbox, EmailBuildError> {
101    let email = addr
102        .email
103        .parse()
104        .map_err(|err: lettre::address::AddressError| {
105            EmailBuildError::InvalidAddress(err.to_string())
106        })?;
107    Ok(Mailbox::new(addr.name.clone(), email))
108}
109
110#[derive(Debug, thiserror::Error)]
111pub enum EmailBuildError {
112    #[error("invalid address: {0}")]
113    InvalidAddress(String),
114    #[error("attachment error: {0}")]
115    Attachment(#[from] ComposeError),
116    #[error("io error: {0}")]
117    Io(#[from] std::io::Error),
118    #[error("failed to build message: {0}")]
119    Message(String),
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125    use mxr_core::id::{AccountId, DraftId};
126    use mxr_core::types::ReplyHeaders;
127    use mxr_test_support::redact_rfc822;
128
129    fn draft() -> Draft {
130        Draft {
131            id: DraftId::new(),
132            account_id: AccountId::new(),
133            reply_headers: Some(ReplyHeaders {
134                in_reply_to: "<parent@example.com>".into(),
135                references: vec!["<root@example.com>".into()],
136            }),
137            to: vec![Address {
138                name: Some("Alice".into()),
139                email: "alice@example.com".into(),
140            }],
141            cc: vec![],
142            bcc: vec![Address {
143                name: None,
144                email: "hidden@example.com".into(),
145            }],
146            subject: "Hello".into(),
147            body_markdown: "hello".into(),
148            attachments: vec![],
149            created_at: chrono::Utc::now(),
150            updated_at: chrono::Utc::now(),
151        }
152    }
153
154    #[test]
155    fn build_message_keeps_bcc_for_gmail() {
156        let message = build_message(
157            &draft(),
158            &Address {
159                name: Some("Me".into()),
160                email: "me@example.com".into(),
161            },
162            true,
163        )
164        .unwrap();
165        let formatted = String::from_utf8(format_message_for_gmail(&message)).unwrap();
166        assert!(formatted.contains("Bcc: hidden@example.com\r\n"));
167        assert!(formatted.contains("References: <root@example.com> <parent@example.com>\r\n"));
168    }
169
170    #[test]
171    fn snapshot_reply_message_rfc822() {
172        let message = build_message(
173            &draft(),
174            &Address {
175                name: Some("Me".into()),
176                email: "me@example.com".into(),
177            },
178            true,
179        )
180        .unwrap();
181        let formatted = String::from_utf8(format_message_for_gmail(&message)).unwrap();
182        insta::assert_snapshot!("reply_message_rfc822", redact_rfc822(&formatted));
183    }
184
185    #[test]
186    fn snapshot_multipart_message_with_attachment() {
187        let dir = tempfile::tempdir().unwrap();
188        let path = dir.path().join("hello.txt");
189        std::fs::write(&path, "hello attachment").unwrap();
190
191        let mut draft = draft();
192        draft.subject = "Unicode café".into();
193        draft.reply_headers = None;
194        draft.attachments = vec![path];
195
196        let message = build_message(
197            &draft,
198            &Address {
199                name: Some("Më Sender".into()),
200                email: "sender@example.com".into(),
201            },
202            false,
203        )
204        .unwrap();
205        let formatted = String::from_utf8(message.formatted()).unwrap();
206        insta::assert_snapshot!("multipart_attachment_rfc822", redact_rfc822(&formatted));
207    }
208}