1use std::fmt;
56use std::io::{BufRead, BufReader, Read, Write};
57use std::net::TcpStream;
58use std::time::Duration;
59
60#[cfg(test)]
61mod tests;
62
63#[derive(Debug)]
67pub enum MailerError {
68 MissingConfig(String),
70 Io(std::io::Error),
72 Smtp(String),
74 Build(String),
76}
77
78impl fmt::Display for MailerError {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 match self {
81 MailerError::MissingConfig(s) => write!(f, "missing SMTP config: {s}"),
82 MailerError::Io(e) => write!(f, "SMTP I/O error: {e}"),
83 MailerError::Smtp(s) => write!(f, "SMTP error: {s}"),
84 MailerError::Build(s) => write!(f, "email build error: {s}"),
85 }
86 }
87}
88
89impl std::error::Error for MailerError {
90 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
91 if let MailerError::Io(e) = self { Some(e) } else { None }
92 }
93}
94
95impl From<std::io::Error> for MailerError {
96 fn from(e: std::io::Error) -> Self { MailerError::Io(e) }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum SmtpTls {
104 None,
107 Starttls,
110 Smtps,
113}
114
115#[derive(Debug, Clone)]
121pub struct Email {
122 pub to: Vec<String>,
123 pub cc: Vec<String>,
124 pub bcc: Vec<String>,
125 pub subject: String,
126 pub text: Option<String>,
127 pub html: Option<String>,
128 pub reply_to: Option<String>,
129}
130
131impl Email {
132 pub fn builder() -> EmailBuilder {
134 EmailBuilder {
135 to: Vec::new(),
136 cc: Vec::new(),
137 bcc: Vec::new(),
138 subject: String::new(),
139 text: None,
140 html: None,
141 reply_to: None,
142 }
143 }
144}
145
146pub struct EmailBuilder {
148 to: Vec<String>,
149 cc: Vec<String>,
150 bcc: Vec<String>,
151 subject: String,
152 text: Option<String>,
153 html: Option<String>,
154 reply_to: Option<String>,
155}
156
157impl EmailBuilder {
158 pub fn to(mut self, addr: &str) -> Self {
160 self.to.push(addr.to_string());
161 self
162 }
163
164 pub fn cc(mut self, addr: &str) -> Self {
166 self.cc.push(addr.to_string());
167 self
168 }
169
170 pub fn bcc(mut self, addr: &str) -> Self {
172 self.bcc.push(addr.to_string());
173 self
174 }
175
176 pub fn subject(mut self, s: &str) -> Self {
178 self.subject = s.to_string();
179 self
180 }
181
182 pub fn text(mut self, body: &str) -> Self {
184 self.text = Some(body.to_string());
185 self
186 }
187
188 pub fn html(mut self, body: &str) -> Self {
190 self.html = Some(body.to_string());
191 self
192 }
193
194 pub fn reply_to(mut self, addr: &str) -> Self {
196 self.reply_to = Some(addr.to_string());
197 self
198 }
199
200 pub fn build(self) -> Result<Email, MailerError> {
202 if self.to.is_empty() {
203 return Err(MailerError::Build(
204 "at least one To: recipient is required".to_string(),
205 ));
206 }
207 if self.subject.is_empty() {
208 return Err(MailerError::Build("subject is required".to_string()));
209 }
210 if self.text.is_none() && self.html.is_none() {
211 return Err(MailerError::Build(
212 "at least one of text or html body is required".to_string(),
213 ));
214 }
215 Ok(Email {
216 to: self.to,
217 cc: self.cc,
218 bcc: self.bcc,
219 subject: self.subject,
220 text: self.text,
221 html: self.html,
222 reply_to: self.reply_to,
223 })
224 }
225}
226
227pub struct Mailer {
250 pub host: String,
251 pub port: u16,
252 pub user: Option<String>,
253 pub password: Option<String>,
254 pub from: String,
255 pub tls: SmtpTls,
256 pub timeout_ms: u64,
257}
258
259impl Mailer {
260 pub fn from_env() -> Result<Self, MailerError> {
264 let host = std::env::var("RWS_SMTP_HOST")
265 .map_err(|_| MailerError::MissingConfig("RWS_SMTP_HOST".into()))?;
266 let from = std::env::var("RWS_SMTP_FROM")
267 .map_err(|_| MailerError::MissingConfig("RWS_SMTP_FROM".into()))?;
268 let port: u16 = std::env::var("RWS_SMTP_PORT")
269 .ok()
270 .and_then(|s| s.parse().ok())
271 .unwrap_or(587);
272 let user = std::env::var("RWS_SMTP_USER").ok();
273 let password = std::env::var("RWS_SMTP_PASSWORD").ok();
274 let tls = match std::env::var("RWS_SMTP_TLS")
275 .unwrap_or_else(|_| "starttls".into())
276 .to_lowercase()
277 .as_str()
278 {
279 "smtps" | "ssl" => SmtpTls::Smtps,
280 "none" | "plain" => SmtpTls::None,
281 _ => SmtpTls::Starttls,
282 };
283 let timeout_ms: u64 = std::env::var("RWS_SMTP_TIMEOUT_MS")
284 .ok()
285 .and_then(|s| s.parse().ok())
286 .unwrap_or(10_000);
287 Ok(Mailer { host, port, user, password, from, tls, timeout_ms })
288 }
289
290 pub fn send(&self, email: &Email) -> Result<(), MailerError> {
292 match self.tls {
293 SmtpTls::None => self.send_plain(email),
294 SmtpTls::Starttls => self.send_starttls(email),
295 SmtpTls::Smtps => self.send_smtps(email),
296 }
297 }
298
299 fn connect(&self) -> Result<TcpStream, MailerError> {
300 let timeout = Duration::from_millis(self.timeout_ms);
301 let stream = TcpStream::connect(format!("{}:{}", self.host, self.port))?;
302 stream.set_read_timeout(Some(timeout))?;
303 stream.set_write_timeout(Some(timeout))?;
304 Ok(stream)
305 }
306
307 fn send_plain(&self, email: &Email) -> Result<(), MailerError> {
308 let stream = self.connect()?;
309 smtp_full_session(
310 stream,
311 self.user.as_deref(),
312 self.password.as_deref(),
313 &self.from,
314 email,
315 )
316 }
317
318 fn send_starttls(&self, email: &Email) -> Result<(), MailerError> {
319 #[cfg(not(any(feature = "http-client", feature = "http2")))]
320 {
321 return Err(MailerError::Smtp(
322 "STARTTLS requires the http-client or http2 feature; \
323 set RWS_SMTP_TLS=none to use a local relay without TLS"
324 .into(),
325 ));
326 }
327
328 #[cfg(any(feature = "http-client", feature = "http2"))]
329 {
330 let stream = self.connect()?;
331 let mut conn: SmtpConn<TcpStream> = SmtpConn::new(stream);
332
333 conn.expect(220)?;
335 conn.cmd(&format!("EHLO {}", smtp_local_hostname()))?;
336 conn.expect(250)?;
337 conn.cmd("STARTTLS")?;
338 conn.expect(220)?;
339
340 let tcp = conn.reader.into_inner();
344 let tls = smtp_tls_connect(tcp, &self.host)?;
345
346 smtp_full_session(
348 tls,
349 self.user.as_deref(),
350 self.password.as_deref(),
351 &self.from,
352 email,
353 )
354 }
355 }
356
357 fn send_smtps(&self, email: &Email) -> Result<(), MailerError> {
358 #[cfg(not(any(feature = "http-client", feature = "http2")))]
359 {
360 return Err(MailerError::Smtp(
361 "SMTPS (implicit TLS) requires the http-client or http2 feature".into(),
362 ));
363 }
364
365 #[cfg(any(feature = "http-client", feature = "http2"))]
366 {
367 let stream = self.connect()?;
368 let tls = smtp_tls_connect(stream, &self.host)?;
369 smtp_full_session(
370 tls,
371 self.user.as_deref(),
372 self.password.as_deref(),
373 &self.from,
374 email,
375 )
376 }
377 }
378}
379
380struct SmtpConn<S: Read + Write> {
385 reader: BufReader<S>,
386}
387
388impl<S: Read + Write> SmtpConn<S> {
389 fn new(stream: S) -> Self {
390 SmtpConn { reader: BufReader::new(stream) }
391 }
392
393 fn expect(&mut self, code: u16) -> Result<Vec<String>, MailerError> {
395 let lines = smtp_read_response(&mut self.reader)?;
396 let last = lines.last().cloned().unwrap_or_default();
397 let actual: u16 = last.get(..3).and_then(|s| s.parse().ok()).unwrap_or(0);
398 if actual != code {
399 return Err(MailerError::Smtp(format!("expected {code}, got: {last}")));
400 }
401 Ok(lines)
402 }
403
404 fn cmd(&mut self, cmd: &str) -> Result<(), MailerError> {
406 smtp_write(self.reader.get_mut(), cmd)
407 }
408}
409
410fn smtp_read_response(r: &mut impl BufRead) -> Result<Vec<String>, MailerError> {
412 let mut lines = Vec::new();
413 loop {
414 let mut line = String::new();
415 r.read_line(&mut line)?;
416 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r').to_string();
417 let is_last = trimmed.len() < 4 || trimmed.as_bytes().get(3).copied() != Some(b'-');
418 lines.push(trimmed);
419 if is_last { break; }
420 }
421 Ok(lines)
422}
423
424fn smtp_write(w: &mut impl Write, cmd: &str) -> Result<(), MailerError> {
426 w.write_all(cmd.as_bytes())?;
427 w.write_all(b"\r\n")?;
428 w.flush()?;
429 Ok(())
430}
431
432fn smtp_auth_plain<S: Read + Write>(
434 conn: &mut SmtpConn<S>,
435 user: &str,
436 pass: &str,
437) -> Result<(), MailerError> {
438 let payload = format!("\0{user}\0{pass}");
439 let encoded = crate::core::base64::Base64::encode(payload.as_bytes())
440 .unwrap_or_default();
441 conn.cmd(&format!("AUTH PLAIN {encoded}"))?;
442 conn.expect(235)?;
443 Ok(())
444}
445
446fn smtp_deliver<S: Read + Write>(
448 conn: &mut SmtpConn<S>,
449 from: &str,
450 email: &Email,
451) -> Result<(), MailerError> {
452 for addr in std::iter::once(&from.to_string()).chain(email.to.iter()).chain(email.cc.iter()).chain(email.bcc.iter()) {
455 if !smtp_address_ok(addr) {
456 return Err(MailerError::Build(format!("invalid email address: {:?}", addr)));
457 }
458 }
459 conn.cmd(&format!("MAIL FROM:<{from}>"))?;
460 conn.expect(250)?;
461
462 for addr in email.to.iter().chain(email.cc.iter()).chain(email.bcc.iter()) {
463 conn.cmd(&format!("RCPT TO:<{addr}>"))?;
464 conn.expect(250)?;
465 }
466
467 conn.cmd("DATA")?;
468 conn.expect(354)?;
469
470 let msg = build_message(from, email);
471 conn.reader.get_mut().write_all(msg.as_bytes())?;
472 conn.reader.get_mut().write_all(b"\r\n.\r\n")?;
474 conn.reader.get_mut().flush()?;
475 conn.expect(250)?;
476
477 conn.cmd("QUIT")?;
478 let _ = conn.expect(221);
481 Ok(())
482}
483
484fn smtp_full_session<S: Read + Write>(
486 stream: S,
487 user: Option<&str>,
488 pass: Option<&str>,
489 from: &str,
490 email: &Email,
491) -> Result<(), MailerError> {
492 let mut conn = SmtpConn::new(stream);
493 conn.expect(220)?;
494 conn.cmd(&format!("EHLO {}", smtp_local_hostname()))?;
495 conn.expect(250)?;
496 if let (Some(u), Some(p)) = (user, pass) {
497 smtp_auth_plain(&mut conn, u, p)?;
498 }
499 smtp_deliver(&mut conn, from, email)?;
500 Ok(())
501}
502
503fn smtp_local_hostname() -> &'static str { "localhost" }
504
505#[cfg(any(feature = "http-client", feature = "http2"))]
508fn smtp_tls_connect(
509 tcp: TcpStream,
510 host: &str,
511) -> Result<rustls::StreamOwned<rustls::ClientConnection, TcpStream>, MailerError> {
512 use std::sync::Arc;
513 use rustls::{pki_types::ServerName, ClientConnection};
514
515 let root_store = rustls::RootCertStore::from_iter(
516 webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
517 );
518 let config = Arc::new(
519 crate::tls_client_builder()
520 .with_root_certificates(root_store)
521 .with_no_client_auth(),
522 );
523 let server_name = ServerName::try_from(host.to_string())
524 .map_err(|e| MailerError::Smtp(format!("invalid SMTP hostname '{host}': {e}")))?;
525 let conn = ClientConnection::new(config, server_name)
526 .map_err(|e| MailerError::Smtp(e.to_string()))?;
527 Ok(rustls::StreamOwned::new(conn, tcp))
528}
529
530pub(crate) fn smtp_address_ok(addr: &str) -> bool {
532 !addr.is_empty() && !addr.contains(['\r', '\n', '<', '>', '\0'])
533}
534
535pub(crate) fn build_message(from: &str, email: &Email) -> String {
540 let mut msg = String::new();
541
542 use crate::header::Header;
545 msg.push_str(&format!("From: {}\r\n", Header::single_line(from)));
546 msg.push_str(&format!("To: {}\r\n", Header::single_line(&email.to.join(", "))));
547 if !email.cc.is_empty() {
548 msg.push_str(&format!("Cc: {}\r\n", Header::single_line(&email.cc.join(", "))));
549 }
550 msg.push_str(&format!("Subject: {}\r\n", Header::single_line(&email.subject)));
551 if let Some(ref rt) = email.reply_to {
552 msg.push_str(&format!("Reply-To: {}\r\n", Header::single_line(rt)));
553 }
554 msg.push_str("MIME-Version: 1.0\r\n");
555
556 match (&email.text, &email.html) {
557 (Some(text), None) => {
558 msg.push_str("Content-Type: text/plain; charset=utf-8\r\n\r\n");
559 msg.push_str(&dot_stuff(text));
560 }
561 (None, Some(html)) => {
562 msg.push_str("Content-Type: text/html; charset=utf-8\r\n\r\n");
563 msg.push_str(&dot_stuff(html));
564 }
565 (Some(text), Some(html)) => {
566 let boundary = "----=_Part_rws_alt";
567 msg.push_str(&format!(
568 "Content-Type: multipart/alternative; boundary=\"{boundary}\"\r\n\r\n"
569 ));
570 msg.push_str(&format!("--{boundary}\r\n"));
571 msg.push_str("Content-Type: text/plain; charset=utf-8\r\n\r\n");
572 msg.push_str(&dot_stuff(text));
573 msg.push_str(&format!("\r\n--{boundary}\r\n"));
574 msg.push_str("Content-Type: text/html; charset=utf-8\r\n\r\n");
575 msg.push_str(&dot_stuff(html));
576 msg.push_str(&format!("\r\n--{boundary}--\r\n"));
577 }
578 (None, None) => {} }
580
581 msg
582}
583
584pub(crate) fn dot_stuff(s: &str) -> String {
587 let mut result = String::with_capacity(s.len() + 8);
588 for line in s.split('\n') {
589 let line = line.trim_end_matches('\r');
590 if line.starts_with('.') {
591 result.push('.');
592 }
593 result.push_str(line);
594 result.push_str("\r\n");
595 }
596 if result.ends_with("\r\n") {
598 result.truncate(result.len() - 2);
599 }
600 result
601}