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