Skip to main content

max / goingson

10.9 KB · 324 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 // Find the last space within budget for a clean word break
73 let break_at = remaining[..budget]
74 .rfind(' ')
75 .map(|i| i + 1) // include the space in this line
76 .unwrap_or(budget); // no space found, hard break at budget
77
78 let (chunk, rest) = remaining.split_at(break_at);
79 out.push_str(prefix);
80 out.push_str(chunk);
81 // Trailing space signals a soft line break (format=flowed)
82 if !chunk.ends_with(' ') {
83 out.push(' ');
84 }
85 out.push('\n');
86 remaining = rest;
87 }
88 }
89
90 // Remove the final trailing newline if the original didn't have one
91 if !body.ends_with('\n') && out.ends_with('\n') {
92 out.pop();
93 }
94
95 out
96 }
97
98 /// A file to attach to an outbound email.
99 pub struct AttachmentFile {
100 pub filename: String,
101 pub mime_type: String,
102 pub data: Vec<u8>,
103 }
104
105 /// Parameters for sending an email message.
106 pub struct SendParams<'a> {
107 pub to: &'a str,
108 pub cc: Option<&'a str>,
109 pub bcc: Option<&'a str>,
110 pub subject: &'a str,
111 pub body: &'a str,
112 pub in_reply_to: Option<&'a str>,
113 pub references: Option<&'a str>,
114 pub attachments: Vec<AttachmentFile>,
115 }
116
117 /// Authentication method for SMTP
118 #[derive(Debug, Clone)]
119 pub enum SmtpAuth {
120 /// Traditional username/password
121 Password { username: String, password: String },
122 /// OAuth2 XOAUTH2 mechanism
123 XOAuth2 {
124 email: String,
125 access_token: String,
126 },
127 }
128
129 pub struct SmtpClient {
130 server: String,
131 port: u16,
132 auth: SmtpAuth,
133 from_address: String,
134 use_tls: bool,
135 }
136
137 impl SmtpClient {
138 /// Create an SMTP client with password authentication.
139 ///
140 /// Note: Prefer `with_password()` for secure credential handling.
141 /// This method reads the password from the account struct (legacy).
142 pub fn new(account: &EmailAccount) -> Self {
143 Self {
144 server: account.smtp_server.clone(),
145 port: account.smtp_port as u16,
146 auth: SmtpAuth::Password {
147 username: account.username.clone(),
148 password: account.password.clone(),
149 },
150 from_address: account.email_address.clone(),
151 use_tls: account.use_tls,
152 }
153 }
154
155 /// Create an SMTP client with explicit password authentication.
156 ///
157 /// Use this when retrieving credentials from secure storage (keychain).
158 pub fn with_password(account: &EmailAccount, password: &str) -> Self {
159 Self {
160 server: account.smtp_server.clone(),
161 port: account.smtp_port as u16,
162 auth: SmtpAuth::Password {
163 username: account.username.clone(),
164 password: password.to_string(),
165 },
166 from_address: account.email_address.clone(),
167 use_tls: account.use_tls,
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 use_tls: bool,
178 ) -> Self {
179 Self {
180 server: server.to_string(),
181 port,
182 auth: SmtpAuth::XOAuth2 {
183 email: email.to_string(),
184 access_token: access_token.to_string(),
185 },
186 from_address: email.to_string(),
187 use_tls,
188 }
189 }
190
191 fn build_mailer(&self) -> Result<AsyncSmtpTransport<Tokio1Executor>, String> {
192 match &self.auth {
193 SmtpAuth::Password { username, password } => {
194 let creds = Credentials::new(username.clone(), password.clone());
195 if self.use_tls {
196 Ok(AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.server)
197 .map_err(|e| format!("SMTP relay error: {}", e))?
198 .credentials(creds)
199 .port(self.port)
200 .build())
201 } else {
202 Ok(AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.server)
203 .credentials(creds)
204 .port(self.port)
205 .build())
206 }
207 }
208 SmtpAuth::XOAuth2 { email, access_token } => {
209 // For XOAUTH2, we use the email as username and access_token as password
210 // with the Xoauth2 mechanism
211 let creds = Credentials::new(email.clone(), access_token.clone());
212 if self.use_tls {
213 Ok(AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.server)
214 .map_err(|e| format!("SMTP relay error: {}", e))?
215 .credentials(creds)
216 .authentication(vec![Mechanism::Xoauth2])
217 .port(self.port)
218 .build())
219 } else {
220 Ok(AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&self.server)
221 .credentials(creds)
222 .authentication(vec![Mechanism::Xoauth2])
223 .port(self.port)
224 .build())
225 }
226 }
227 }
228 }
229
230 pub async fn send_message(&self, params: &SendParams<'_>) -> Result<String, String> {
231 // Generate message ID before building so sent email and local DB agree
232 let message_id = format!(
233 "<{}.{}@{}>",
234 uuid::Uuid::new_v4(),
235 chrono::Utc::now().timestamp(),
236 self.server
237 );
238
239 let mut builder = Message::builder()
240 .message_id(Some(message_id.clone()))
241 .from(
242 self.from_address
243 .parse()
244 .map_err(|e| format!("Invalid from address: {}", e))?,
245 )
246 .subject(params.subject);
247
248 // To recipients (comma-separated)
249 for addr in params.to.split(',').map(str::trim).filter(|a| !a.is_empty()) {
250 builder = builder.to(addr.parse().map_err(|e| format!("Invalid to address '{}': {}", addr, e))?);
251 }
252
253 // CC recipients
254 if let Some(cc) = params.cc {
255 for addr in cc.split(',').map(str::trim).filter(|a| !a.is_empty()) {
256 builder = builder.cc(addr.parse().map_err(|e| format!("Invalid CC address '{}': {}", addr, e))?);
257 }
258 }
259
260 // BCC recipients
261 if let Some(bcc) = params.bcc {
262 for addr in bcc.split(',').map(str::trim).filter(|a| !a.is_empty()) {
263 builder = builder.bcc(addr.parse().map_err(|e| format!("Invalid BCC address '{}': {}", addr, e))?);
264 }
265 }
266
267 if let Some(irt) = params.in_reply_to {
268 builder = builder.in_reply_to(irt.to_string());
269 }
270 if let Some(refs) = params.references {
271 builder = builder.references(refs.to_string());
272 }
273
274 let flowed_body = format_flowed(params.body);
275 let flowed_ct = ContentType::parse("text/plain; charset=UTF-8; format=flowed")
276 .unwrap_or(ContentType::TEXT_PLAIN);
277
278 let email = if params.attachments.is_empty() {
279 builder
280 .header(flowed_ct)
281 .body(flowed_body)
282 .map_err(|e| format!("Failed to build email: {}", e))?
283 } else {
284 let body_part = SinglePart::builder()
285 .content_type(flowed_ct)
286 .body(flowed_body);
287 let mut multipart = MultiPart::mixed().singlepart(body_part);
288
289 for file in &params.attachments {
290 let content_type = file.mime_type.parse::<ContentType>()
291 .or_else(|_| "application/octet-stream".parse::<ContentType>())
292 .map_err(|e| format!("Invalid attachment content type: {}", e))?;
293 let attachment = Attachment::new(file.filename.clone())
294 .body(file.data.clone(), content_type);
295 multipart = multipart.singlepart(attachment);
296 }
297
298 builder
299 .multipart(multipart)
300 .map_err(|e| format!("Failed to build email: {}", e))?
301 };
302
303 let mailer = self.build_mailer()?;
304
305 mailer
306 .send(email)
307 .await
308 .map_err(|e| format!("Failed to send email: {}", e))?;
309
310 Ok(message_id)
311 }
312
313 pub async fn test_connection(&self) -> Result<(), String> {
314 let mailer = self.build_mailer()?;
315
316 mailer
317 .test_connection()
318 .await
319 .map_err(|e| format!("SMTP connection test failed: {}", e))?;
320
321 Ok(())
322 }
323 }
324