1use std::fmt;
56use std::io::{BufRead, BufReader, Read, Write};
57use std::net::TcpStream;
58use std::time::Duration;
59
60#[cfg(test)]
61mod tests;
62mod delivery;
63pub use delivery::{Deliver, Mail, MailAttachment, MailPreviews, TestMailbox, MAIL_JOB_TYPE};
64#[cfg(all(feature = "jobs", any(feature = "model-sqlite", feature = "model-postgres", feature = "model-mysql")))]
65pub use delivery::register_delivery;
66
67#[derive(Debug)]
71pub enum MailerError {
72 MissingConfig(String),
74 Io(std::io::Error),
76 Smtp(String),
78 Build(String),
80}
81
82impl fmt::Display for MailerError {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 match self {
85 MailerError::MissingConfig(s) => write!(f, "missing SMTP config: {s}"),
86 MailerError::Io(e) => write!(f, "SMTP I/O error: {e}"),
87 MailerError::Smtp(s) => write!(f, "SMTP error: {s}"),
88 MailerError::Build(s) => write!(f, "email build error: {s}"),
89 }
90 }
91}
92
93impl std::error::Error for MailerError {
94 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
95 if let MailerError::Io(e) = self { Some(e) } else { None }
96 }
97}
98
99impl From<std::io::Error> for MailerError {
100 fn from(e: std::io::Error) -> Self { MailerError::Io(e) }
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum SmtpTls {
108 None,
111 Starttls,
114 Smtps,
117}
118
119#[derive(Debug, Clone)]
125pub struct Email {
126 pub to: Vec<String>,
127 pub cc: Vec<String>,
128 pub bcc: Vec<String>,
129 pub subject: String,
130 pub text: Option<String>,
131 pub html: Option<String>,
132 pub reply_to: Option<String>,
133}
134
135impl Email {
136 pub fn builder() -> EmailBuilder {
138 EmailBuilder {
139 to: Vec::new(),
140 cc: Vec::new(),
141 bcc: Vec::new(),
142 subject: String::new(),
143 text: None,
144 html: None,
145 reply_to: None,
146 }
147 }
148}
149
150pub struct EmailBuilder {
152 to: Vec<String>,
153 cc: Vec<String>,
154 bcc: Vec<String>,
155 subject: String,
156 text: Option<String>,
157 html: Option<String>,
158 reply_to: Option<String>,
159}
160
161impl EmailBuilder {
162 pub fn to(mut self, addr: &str) -> Self {
164 self.to.push(addr.to_string());
165 self
166 }
167
168 pub fn cc(mut self, addr: &str) -> Self {
170 self.cc.push(addr.to_string());
171 self
172 }
173
174 pub fn bcc(mut self, addr: &str) -> Self {
176 self.bcc.push(addr.to_string());
177 self
178 }
179
180 pub fn subject(mut self, s: &str) -> Self {
182 self.subject = s.to_string();
183 self
184 }
185
186 pub fn text(mut self, body: &str) -> Self {
188 self.text = Some(body.to_string());
189 self
190 }
191
192 pub fn html(mut self, body: &str) -> Self {
194 self.html = Some(body.to_string());
195 self
196 }
197
198 pub fn reply_to(mut self, addr: &str) -> Self {
200 self.reply_to = Some(addr.to_string());
201 self
202 }
203
204 pub fn build(self) -> Result<Email, MailerError> {
206 if self.to.is_empty() {
207 return Err(MailerError::Build(
208 "at least one To: recipient is required".to_string(),
209 ));
210 }
211 if self.subject.is_empty() {
212 return Err(MailerError::Build("subject is required".to_string()));
213 }
214 if self.text.is_none() && self.html.is_none() {
215 return Err(MailerError::Build(
216 "at least one of text or html body is required".to_string(),
217 ));
218 }
219 Ok(Email {
220 to: self.to,
221 cc: self.cc,
222 bcc: self.bcc,
223 subject: self.subject,
224 text: self.text,
225 html: self.html,
226 reply_to: self.reply_to,
227 })
228 }
229}
230
231pub struct Mailer {
254 pub host: String,
255 pub port: u16,
256 pub user: Option<String>,
257 pub password: Option<String>,
258 pub from: String,
259 pub tls: SmtpTls,
260 pub timeout_ms: u64,
261}
262
263impl Mailer {
264 pub fn from_env() -> Result<Self, MailerError> {
268 let host = std::env::var("RWS_SMTP_HOST")
269 .map_err(|_| MailerError::MissingConfig("RWS_SMTP_HOST".into()))?;
270 let from = std::env::var("RWS_SMTP_FROM")
271 .map_err(|_| MailerError::MissingConfig("RWS_SMTP_FROM".into()))?;
272 let port: u16 = std::env::var("RWS_SMTP_PORT")
273 .ok()
274 .and_then(|s| s.parse().ok())
275 .unwrap_or(587);
276 let user = std::env::var("RWS_SMTP_USER").ok();
277 let password = std::env::var("RWS_SMTP_PASSWORD").ok();
278 let tls = match std::env::var("RWS_SMTP_TLS")
279 .unwrap_or_else(|_| "starttls".into())
280 .to_lowercase()
281 .as_str()
282 {
283 "smtps" | "ssl" => SmtpTls::Smtps,
284 "none" | "plain" => SmtpTls::None,
285 _ => SmtpTls::Starttls,
286 };
287 let timeout_ms: u64 = std::env::var("RWS_SMTP_TIMEOUT_MS")
288 .ok()
289 .and_then(|s| s.parse().ok())
290 .unwrap_or(10_000);
291 Ok(Mailer { host, port, user, password, from, tls, timeout_ms })
292 }
293
294 pub fn send(&self, email: &Email) -> Result<(), MailerError> {
296 self.send_parts(email, &[])
297 }
298
299 pub fn send_mail(&self, mail: &Mail) -> Result<(), MailerError> {
301 self.send_parts(&mail.email, &mail.attachments)
302 }
303
304 fn send_parts(&self, email: &Email, attachments: &[MailAttachment]) -> Result<(), MailerError> {
305 match self.tls {
306 SmtpTls::None => self.send_plain(email, attachments),
307 SmtpTls::Starttls => self.send_starttls(email, attachments),
308 SmtpTls::Smtps => self.send_smtps(email, attachments),
309 }
310 }
311
312 fn connect(&self) -> Result<TcpStream, MailerError> {
313 let timeout = Duration::from_millis(self.timeout_ms);
314 let stream = TcpStream::connect(format!("{}:{}", self.host, self.port))?;
315 stream.set_read_timeout(Some(timeout))?;
316 stream.set_write_timeout(Some(timeout))?;
317 Ok(stream)
318 }
319
320 fn send_plain(&self, email: &Email, attachments: &[MailAttachment]) -> Result<(), MailerError> {
321 let stream = self.connect()?;
322 smtp_full_session(
323 stream,
324 self.user.as_deref(),
325 self.password.as_deref(),
326 &self.from,
327 email,
328 attachments,
329 )
330 }
331
332 fn send_starttls(&self, email: &Email, attachments: &[MailAttachment]) -> Result<(), MailerError> {
333 #[cfg(not(any(feature = "http-client", feature = "http2")))]
334 {
335 let _ = (email, attachments);
336 return Err(MailerError::Smtp(
337 "STARTTLS requires the http-client or http2 feature; \
338 set RWS_SMTP_TLS=none to use a local relay without TLS"
339 .into(),
340 ));
341 }
342
343 #[cfg(any(feature = "http-client", feature = "http2"))]
344 {
345 let stream = self.connect()?;
346 let mut conn: SmtpConn<TcpStream> = SmtpConn::new(stream);
347
348 conn.expect(220)?;
350 conn.cmd(&format!("EHLO {}", smtp_local_hostname()))?;
351 conn.expect(250)?;
352 conn.cmd("STARTTLS")?;
353 conn.expect(220)?;
354
355 let tcp = conn.reader.into_inner();
359 let tls = smtp_tls_connect(tcp, &self.host)?;
360
361 smtp_full_session(
363 tls,
364 self.user.as_deref(),
365 self.password.as_deref(),
366 &self.from,
367 email,
368 attachments,
369 )
370 }
371 }
372
373 fn send_smtps(&self, email: &Email, attachments: &[MailAttachment]) -> Result<(), MailerError> {
374 #[cfg(not(any(feature = "http-client", feature = "http2")))]
375 {
376 let _ = (email, attachments);
377 return Err(MailerError::Smtp(
378 "SMTPS (implicit TLS) requires the http-client or http2 feature".into(),
379 ));
380 }
381
382 #[cfg(any(feature = "http-client", feature = "http2"))]
383 {
384 let stream = self.connect()?;
385 let tls = smtp_tls_connect(stream, &self.host)?;
386 smtp_full_session(
387 tls,
388 self.user.as_deref(),
389 self.password.as_deref(),
390 &self.from,
391 email,
392 attachments,
393 )
394 }
395 }
396}
397
398struct SmtpConn<S: Read + Write> {
403 reader: BufReader<S>,
404}
405
406impl<S: Read + Write> SmtpConn<S> {
407 fn new(stream: S) -> Self {
408 SmtpConn { reader: BufReader::new(stream) }
409 }
410
411 fn expect(&mut self, code: u16) -> Result<Vec<String>, MailerError> {
413 let lines = smtp_read_response(&mut self.reader)?;
414 let last = lines.last().cloned().unwrap_or_default();
415 let actual: u16 = last.get(..3).and_then(|s| s.parse().ok()).unwrap_or(0);
416 if actual != code {
417 return Err(MailerError::Smtp(format!("expected {code}, got: {last}")));
418 }
419 Ok(lines)
420 }
421
422 fn cmd(&mut self, cmd: &str) -> Result<(), MailerError> {
424 smtp_write(self.reader.get_mut(), cmd)
425 }
426}
427
428fn smtp_read_response(r: &mut impl BufRead) -> Result<Vec<String>, MailerError> {
430 let mut lines = Vec::new();
431 loop {
432 let mut line = String::new();
433 r.read_line(&mut line)?;
434 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r').to_string();
435 let is_last = trimmed.len() < 4 || trimmed.as_bytes().get(3).copied() != Some(b'-');
436 lines.push(trimmed);
437 if is_last { break; }
438 }
439 Ok(lines)
440}
441
442fn smtp_write(w: &mut impl Write, cmd: &str) -> Result<(), MailerError> {
444 w.write_all(cmd.as_bytes())?;
445 w.write_all(b"\r\n")?;
446 w.flush()?;
447 Ok(())
448}
449
450fn smtp_auth_plain<S: Read + Write>(
452 conn: &mut SmtpConn<S>,
453 user: &str,
454 pass: &str,
455) -> Result<(), MailerError> {
456 let payload = format!("\0{user}\0{pass}");
457 let encoded = crate::core::base64::Base64::encode(payload.as_bytes())
458 .unwrap_or_default();
459 conn.cmd(&format!("AUTH PLAIN {encoded}"))?;
460 conn.expect(235)?;
461 Ok(())
462}
463
464fn smtp_deliver<S: Read + Write>(
466 conn: &mut SmtpConn<S>,
467 from: &str,
468 email: &Email,
469 attachments: &[MailAttachment],
470) -> Result<(), MailerError> {
471 for addr in std::iter::once(&from.to_string()).chain(email.to.iter()).chain(email.cc.iter()).chain(email.bcc.iter()) {
474 if !smtp_address_ok(addr) {
475 return Err(MailerError::Build(format!("invalid email address: {:?}", addr)));
476 }
477 }
478 conn.cmd(&format!("MAIL FROM:<{from}>"))?;
479 conn.expect(250)?;
480
481 for addr in email.to.iter().chain(email.cc.iter()).chain(email.bcc.iter()) {
482 conn.cmd(&format!("RCPT TO:<{addr}>"))?;
483 conn.expect(250)?;
484 }
485
486 conn.cmd("DATA")?;
487 conn.expect(354)?;
488
489 let msg = build_message_with(from, email, attachments);
490 conn.reader.get_mut().write_all(msg.as_bytes())?;
491 conn.reader.get_mut().write_all(b"\r\n.\r\n")?;
493 conn.reader.get_mut().flush()?;
494 conn.expect(250)?;
495
496 conn.cmd("QUIT")?;
497 let _ = conn.expect(221);
500 Ok(())
501}
502
503fn smtp_full_session<S: Read + Write>(
505 stream: S,
506 user: Option<&str>,
507 pass: Option<&str>,
508 from: &str,
509 email: &Email,
510 attachments: &[MailAttachment],
511) -> Result<(), MailerError> {
512 let mut conn = SmtpConn::new(stream);
513 conn.expect(220)?;
514 conn.cmd(&format!("EHLO {}", smtp_local_hostname()))?;
515 conn.expect(250)?;
516 if let (Some(u), Some(p)) = (user, pass) {
517 smtp_auth_plain(&mut conn, u, p)?;
518 }
519 smtp_deliver(&mut conn, from, email, attachments)?;
520 Ok(())
521}
522
523fn smtp_local_hostname() -> &'static str { "localhost" }
524
525#[cfg(any(feature = "http-client", feature = "http2"))]
528fn smtp_tls_connect(
529 tcp: TcpStream,
530 host: &str,
531) -> Result<rustls::StreamOwned<rustls::ClientConnection, TcpStream>, MailerError> {
532 use std::sync::Arc;
533 use rustls::{pki_types::ServerName, ClientConnection};
534
535 let root_store = rustls::RootCertStore::from_iter(
536 webpki_roots::TLS_SERVER_ROOTS.iter().cloned(),
537 );
538 let config = Arc::new(
539 crate::tls_client_builder()
540 .with_root_certificates(root_store)
541 .with_no_client_auth(),
542 );
543 let server_name = ServerName::try_from(host.to_string())
544 .map_err(|e| MailerError::Smtp(format!("invalid SMTP hostname '{host}': {e}")))?;
545 let conn = ClientConnection::new(config, server_name)
546 .map_err(|e| MailerError::Smtp(e.to_string()))?;
547 Ok(rustls::StreamOwned::new(conn, tcp))
548}
549
550pub(crate) fn smtp_address_ok(addr: &str) -> bool {
552 !addr.is_empty() && !addr.contains(['\r', '\n', '<', '>', '\0'])
553}
554
555#[cfg(test)]
560pub(crate) fn build_message(from: &str, email: &Email) -> String {
561 build_message_with(from, email, &[])
562}
563
564pub(crate) fn build_message_with(from: &str, email: &Email, attachments: &[MailAttachment]) -> String {
567 let mut msg = String::new();
568
569 use crate::header::Header;
572 msg.push_str(&format!("From: {}\r\n", Header::single_line(from)));
573 msg.push_str(&format!("To: {}\r\n", Header::single_line(&email.to.join(", "))));
574 if !email.cc.is_empty() {
575 msg.push_str(&format!("Cc: {}\r\n", Header::single_line(&email.cc.join(", "))));
576 }
577 msg.push_str(&format!("Subject: {}\r\n", Header::single_line(&email.subject)));
578 if let Some(ref rt) = email.reply_to {
579 msg.push_str(&format!("Reply-To: {}\r\n", Header::single_line(rt)));
580 }
581 msg.push_str("MIME-Version: 1.0\r\n");
582
583 let mixed = "----=_Part_rws_mixed";
584 if !attachments.is_empty() {
585 msg.push_str(&format!("Content-Type: multipart/mixed; boundary=\"{mixed}\"\r\n\r\n"));
586 msg.push_str(&format!("--{mixed}\r\n"));
587 }
588
589 match (&email.text, &email.html) {
590 (Some(text), None) => {
591 msg.push_str("Content-Type: text/plain; charset=utf-8\r\n\r\n");
592 msg.push_str(&dot_stuff(text));
593 }
594 (None, Some(html)) => {
595 msg.push_str("Content-Type: text/html; charset=utf-8\r\n\r\n");
596 msg.push_str(&dot_stuff(html));
597 }
598 (Some(text), Some(html)) => {
599 let boundary = "----=_Part_rws_alt";
600 msg.push_str(&format!(
601 "Content-Type: multipart/alternative; boundary=\"{boundary}\"\r\n\r\n"
602 ));
603 msg.push_str(&format!("--{boundary}\r\n"));
604 msg.push_str("Content-Type: text/plain; charset=utf-8\r\n\r\n");
605 msg.push_str(&dot_stuff(text));
606 msg.push_str(&format!("\r\n--{boundary}\r\n"));
607 msg.push_str("Content-Type: text/html; charset=utf-8\r\n\r\n");
608 msg.push_str(&dot_stuff(html));
609 msg.push_str(&format!("\r\n--{boundary}--\r\n"));
610 }
611 (None, None) => {} }
613
614 for attachment in attachments {
615 let name = attachment_name(&attachment.filename);
616 let content_type = Header::single_line(&attachment.content_type).replace(['"', ';'], "");
617 msg.push_str(&format!("\r\n--{mixed}\r\n"));
618 msg.push_str(&format!("Content-Type: {content_type}; name=\"{name}\"\r\n"));
619 msg.push_str(&format!("Content-Disposition: attachment; filename=\"{name}\"\r\n"));
620 msg.push_str("Content-Transfer-Encoding: base64\r\n\r\n");
621 let encoded = crate::core::base64::Base64::encode(&attachment.data).unwrap_or_default();
622 let lines: Vec<&str> = encoded.as_bytes().chunks(76).map(|c| std::str::from_utf8(c).unwrap_or("")).collect();
623 msg.push_str(&lines.join("\r\n"));
624 }
625 if !attachments.is_empty() {
626 msg.push_str(&format!("\r\n--{mixed}--\r\n"));
627 }
628
629 msg
630}
631
632fn attachment_name(name: &str) -> String {
635 let name: String = crate::header::Header::single_line(name)
636 .chars()
637 .map(|c| if c.is_ascii() && !matches!(c, '"' | '\\' | '\r' | '\n') && !c.is_ascii_control() { c } else { '_' })
638 .collect();
639 if name.is_empty() { "attachment".to_string() } else { name }
640}
641
642pub(crate) fn dot_stuff(s: &str) -> String {
645 let mut result = String::with_capacity(s.len() + 8);
646 for line in s.split('\n') {
647 let line = line.trim_end_matches('\r');
648 if line.starts_with('.') {
649 result.push('.');
650 }
651 result.push_str(line);
652 result.push_str("\r\n");
653 }
654 if result.ends_with("\r\n") {
656 result.truncate(result.len() - 2);
657 }
658 result
659}