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}