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}