Skip to main content

max / goingson

13.1 KB · 372 lines History Blame Raw
1 //! SMTP client for sending email messages.
2 //!
3 //! Supports password and OAuth2 (XOAUTH2) authentication, with
4 //! optional STARTTLS. Used by the email commands to send replies
5 //! and compose new messages through the user's configured account.
6
7 use goingson_core::EmailAccount;
8 use lettre::{
9 message::{
10 header::ContentType,
11 Attachment, MultiPart, SinglePart,
12 },
13 transport::smtp::authentication::{Credentials, Mechanism},
14 AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor,
15 };
16
17 /// Process a plain text body for format=flowed (RFC 3676).
18 ///
19 /// - Wraps long lines at 72 chars with trailing space (soft line break)
20 /// - Space-stuffs lines starting with `>`, `From `, or a space
21 /// - Preserves intentional line breaks (no trailing space)
22 /// - Leaves quoted lines and signature separator unchanged
23 fn format_flowed(body: &str) -> String {
24 const MAX_LINE: usize = 72;
25 let mut out = String::with_capacity(body.len() + body.len() / 8);
26
27 for line in body.split('\n') {
28 // Signature separator: preserve exactly as-is
29 if line == "-- " {
30 out.push_str(line);
31 out.push('\n');
32 continue;
33 }
34
35 // Quoted lines: space-stuff but don't rewrap (already formatted by frontend)
36 if line.starts_with('>') {
37 out.push(' ');
38 out.push_str(line);
39 out.push('\n');
40 continue;
41 }
42
43 // Space-stuff lines starting with "From " or a space
44 let needs_stuffing = line.starts_with("From ") || line.starts_with(' ');
45
46 // Short lines: no wrapping needed
47 let effective_len = line.len() + if needs_stuffing { 1 } else { 0 };
48 if effective_len <= MAX_LINE || line.trim().is_empty() {
49 if needs_stuffing {
50 out.push(' ');
51 }
52 out.push_str(line);
53 out.push('\n');
54 continue;
55 }
56
57 // Wrap long lines with soft breaks (trailing space)
58 let prefix = if needs_stuffing { " " } else { "" };
59 let mut remaining = line;
60
61 while !remaining.is_empty() {
62 let budget = MAX_LINE - prefix.len();
63
64 if remaining.len() <= budget {
65 // Last segment: no trailing space (hard break)
66 out.push_str(prefix);
67 out.push_str(remaining);
68 out.push('\n');
69 break;
70 }
71
72 // `budget` is a byte count, but `remaining` may contain multibyte
73 // characters, so slicing at `budget` can land mid-character and
74 // panic. Back off to the largest char boundary at or below budget.
75 let mut boundary = budget;
76 while boundary > 0 && !remaining.is_char_boundary(boundary) {
77 boundary -= 1;
78 }
79 // A single character wider than the budget would leave boundary at
80 // 0 and stall the loop; take that whole first character instead so
81 // we always make forward progress.
82 if boundary == 0 {
83 boundary = remaining
84 .char_indices()
85 .nth(1)
86 .map_or(remaining.len(), |(i, _)| i);
87 }
88
89 // Find the last space within the boundary for a clean word break.
90 // `rfind(' ')` returns an ASCII byte index, so `+ 1` stays on a
91 // char boundary; the fallback `boundary` is a boundary by construction.
92 let break_at = remaining[..boundary]
93 .rfind(' ')
94 .map(|i| i + 1) // include the space in this line
95 .unwrap_or(boundary); // no space found, hard break at boundary
96
97 let (chunk, rest) = remaining.split_at(break_at);
98 out.push_str(prefix);
99 out.push_str(chunk);
100 // Trailing space signals a soft line break (format=flowed)
101 if !chunk.ends_with(' ') {
102 out.push(' ');
103 }
104 out.push('\n');
105 remaining = rest;
106 }
107 }
108
109 // Remove the final trailing newline if the original didn't have one
110 if !body.ends_with('\n') && out.ends_with('\n') {
111 out.pop();
112 }
113
114 out
115 }
116
117 /// A file to attach to an outbound email.
118 pub struct AttachmentFile {
119 pub filename: String,
120 pub mime_type: String,
121 pub data: Vec<u8>,
122 }
123
124 /// Parameters for sending an email message.
125 pub struct SendParams<'a> {
126 pub to: &'a str,
127 pub cc: Option<&'a str>,
128 pub bcc: Option<&'a str>,
129 pub subject: &'a str,
130 pub body: &'a str,
131 pub in_reply_to: Option<&'a str>,
132 pub references: Option<&'a str>,
133 pub attachments: Vec<AttachmentFile>,
134 }
135
136 /// Authentication method for SMTP
137 #[derive(Debug, Clone)]
138 pub enum SmtpAuth {
139 /// Traditional username/password
140 Password { username: String, password: String },
141 /// OAuth2 XOAUTH2 mechanism
142 XOAuth2 {
143 email: String,
144 access_token: String,
145 },
146 }
147
148 pub struct SmtpClient {
149 server: String,
150 port: u16,
151 auth: SmtpAuth,
152 from_address: String,
153 }
154
155 impl SmtpClient {
156 /// Create an SMTP client with explicit password authentication.
157 ///
158 /// Use this when retrieving credentials from secure storage (keychain).
159 pub fn with_password(account: &EmailAccount, password: &str) -> Self {
160 Self {
161 server: account.smtp_server.clone(),
162 port: account.smtp_port as u16,
163 auth: SmtpAuth::Password {
164 username: account.username.clone(),
165 password: password.to_string(),
166 },
167 from_address: account.email_address.clone(),
168 }
169 }
170
171 /// Create an SMTP client with OAuth2 XOAUTH2 authentication
172 pub fn with_oauth(
173 server: &str,
174 port: u16,
175 email: &str,
176 access_token: &str,
177 ) -> Self {
178 Self {
179 server: server.to_string(),
180 port,
181 auth: SmtpAuth::XOAuth2 {
182 email: email.to_string(),
183 access_token: access_token.to_string(),
184 },
185 from_address: email.to_string(),
186 }
187 }
188
189 /// Builds an SMTP transport with a mandatory encryption floor.
190 ///
191 /// The transport always negotiates STARTTLS, so credentials and OAuth
192 /// access tokens are never transmitted over an unencrypted connection. A
193 /// server that does not offer STARTTLS fails the handshake rather than
194 /// silently downgrading to cleartext (the previous `builder_dangerous`
195 /// path, which leaked secrets whenever the account had TLS unticked).
196 fn build_mailer(&self) -> Result<AsyncSmtpTransport<Tokio1Executor>, String> {
197 let builder = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.server)
198 .map_err(|e| format!("SMTP relay error: {}", e))?
199 .port(self.port);
200
201 let mailer = match &self.auth {
202 SmtpAuth::Password { username, password } => builder
203 .credentials(Credentials::new(username.clone(), password.clone()))
204 .build(),
205 SmtpAuth::XOAuth2 { email, access_token } => builder
206 // For XOAUTH2 the email is the username and the access token is
207 // the secret, negotiated with the Xoauth2 mechanism.
208 .credentials(Credentials::new(email.clone(), access_token.clone()))
209 .authentication(vec![Mechanism::Xoauth2])
210 .build(),
211 };
212 Ok(mailer)
213 }
214
215 #[tracing::instrument(skip_all)]
216 pub async fn send_message(&self, params: &SendParams<'_>) -> Result<String, String> {
217 // Generate message ID before building so sent email and local DB agree
218 let message_id = format!(
219 "<{}.{}@{}>",
220 uuid::Uuid::new_v4(),
221 chrono::Utc::now().timestamp(),
222 self.server
223 );
224
225 let mut builder = Message::builder()
226 .message_id(Some(message_id.clone()))
227 .from(
228 self.from_address
229 .parse()
230 .map_err(|e| format!("Invalid from address: {}", e))?,
231 )
232 .subject(params.subject);
233
234 // To recipients (comma-separated)
235 for addr in params.to.split(',').map(str::trim).filter(|a| !a.is_empty()) {
236 builder = builder.to(addr.parse().map_err(|e| format!("Invalid to address '{}': {}", addr, e))?);
237 }
238
239 // CC recipients
240 if let Some(cc) = params.cc {
241 for addr in cc.split(',').map(str::trim).filter(|a| !a.is_empty()) {
242 builder = builder.cc(addr.parse().map_err(|e| format!("Invalid CC address '{}': {}", addr, e))?);
243 }
244 }
245
246 // BCC recipients
247 if let Some(bcc) = params.bcc {
248 for addr in bcc.split(',').map(str::trim).filter(|a| !a.is_empty()) {
249 builder = builder.bcc(addr.parse().map_err(|e| format!("Invalid BCC address '{}': {}", addr, e))?);
250 }
251 }
252
253 if let Some(irt) = params.in_reply_to {
254 builder = builder.in_reply_to(irt.to_string());
255 }
256 if let Some(refs) = params.references {
257 builder = builder.references(refs.to_string());
258 }
259
260 let flowed_body = format_flowed(params.body);
261 let flowed_ct = ContentType::parse("text/plain; charset=UTF-8; format=flowed")
262 .unwrap_or(ContentType::TEXT_PLAIN);
263
264 let email = if params.attachments.is_empty() {
265 builder
266 .header(flowed_ct)
267 .body(flowed_body)
268 .map_err(|e| format!("Failed to build email: {}", e))?
269 } else {
270 let body_part = SinglePart::builder()
271 .content_type(flowed_ct)
272 .body(flowed_body);
273 let mut multipart = MultiPart::mixed().singlepart(body_part);
274
275 for file in &params.attachments {
276 let content_type = file.mime_type.parse::<ContentType>()
277 .or_else(|_| "application/octet-stream".parse::<ContentType>())
278 .map_err(|e| format!("Invalid attachment content type: {}", e))?;
279 let attachment = Attachment::new(file.filename.clone())
280 .body(file.data.clone(), content_type);
281 multipart = multipart.singlepart(attachment);
282 }
283
284 builder
285 .multipart(multipart)
286 .map_err(|e| format!("Failed to build email: {}", e))?
287 };
288
289 let mailer = self.build_mailer()?;
290
291 mailer
292 .send(email)
293 .await
294 .map_err(|e| format!("Failed to send email: {}", e))?;
295
296 Ok(message_id)
297 }
298
299 #[tracing::instrument(skip_all)]
300 pub async fn test_connection(&self) -> Result<(), String> {
301 let mailer = self.build_mailer()?;
302
303 mailer
304 .test_connection()
305 .await
306 .map_err(|e| format!("SMTP connection test failed: {}", e))?;
307
308 Ok(())
309 }
310 }
311
312 #[cfg(test)]
313 mod tests {
314 use super::format_flowed;
315
316 #[test]
317 fn short_ascii_line_unchanged() {
318 assert_eq!(format_flowed("hello world"), "hello world");
319 }
320
321 #[test]
322 fn signature_separator_preserved() {
323 assert_eq!(format_flowed("body\n-- \nsig"), "body\n-- \nsig");
324 }
325
326 #[test]
327 fn long_ascii_line_wraps_with_soft_break() {
328 let line = "word ".repeat(20); // 100 chars, spaces every 5
329 let out = format_flowed(line.trim_end());
330 // Content per wrapped segment stays within 72; format=flowed may append
331 // one trailing soft-break space, so the emitted line is content + 1.
332 for seg in out.split('\n') {
333 assert!(seg.len() <= 73, "segment too long: {:?}", seg);
334 }
335 }
336
337 #[test]
338 fn long_multibyte_line_does_not_panic() {
339 // A line of CJK characters (3 bytes each) well over 72 bytes with no
340 // ASCII space: the old byte-index slice panicked on the char boundary.
341 let line = "".repeat(60); // 180 bytes
342 let out = format_flowed(&line);
343 // Output must reconstruct to the same characters (sans soft-break spaces
344 // and newlines) and never split a character.
345 let stripped: String = out.chars().filter(|c| *c != '\n' && *c != ' ').collect();
346 assert_eq!(stripped, line);
347 }
348
349 #[test]
350 fn long_emoji_line_does_not_panic() {
351 // Emoji are 4 bytes; a long run with no space is the worst case.
352 let line = "😀".repeat(40); // 160 bytes
353 let out = format_flowed(&line);
354 let stripped: String = out.chars().filter(|c| *c != '\n' && *c != ' ').collect();
355 assert_eq!(stripped, line);
356 }
357
358 #[test]
359 fn mixed_multibyte_with_spaces_word_breaks() {
360 let line = format!("{} {} {}", "".repeat(30), "".repeat(30), "".repeat(30));
361 let out = format_flowed(&line);
362 // Content per segment <= 72 bytes; allow up to 2 extra bytes for a
363 // trailing soft-break space plus the word-break space included in chunk.
364 for seg in out.split('\n') {
365 assert!(seg.len() <= 74, "segment too long: {} bytes", seg.len());
366 }
367 // No character was split: reconstruction yields the original text.
368 let stripped: String = out.replace('\n', "");
369 assert!(stripped.contains(&"".repeat(24)));
370 }
371 }
372