Skip to main content

rust_web_server/mailer/
mod.rs

1//! Transactional email via SMTP.
2//!
3//! [`Mailer`] opens a plain or TLS-encrypted TCP connection to an SMTP server,
4//! optionally authenticates with `AUTH PLAIN`, and sends RFC 5321 / RFC 5322
5//! email. No third-party mail library is required.
6//!
7//! # Quick start
8//!
9//! ```rust,no_run
10//! use rust_web_server::mailer::{Email, Mailer};
11//!
12//! let mailer = Mailer::from_env().expect("SMTP config missing");
13//! let email = Email::builder()
14//!     .to("user@example.com")
15//!     .subject("Welcome!")
16//!     .text("Thanks for signing up.")
17//!     .build()
18//!     .unwrap();
19//! mailer.send(&email).expect("send failed");
20//! ```
21//!
22//! # Environment variables
23//!
24//! | Variable | Default | Description |
25//! |---|---|---|
26//! | `RWS_SMTP_HOST` | — (required) | SMTP server hostname |
27//! | `RWS_SMTP_PORT` | `587` | SMTP port |
28//! | `RWS_SMTP_USER` | — | SMTP username; omit to skip AUTH |
29//! | `RWS_SMTP_PASSWORD` | — | SMTP password |
30//! | `RWS_SMTP_FROM` | — (required) | Envelope / `From:` address |
31//! | `RWS_SMTP_TLS` | `starttls` | TLS mode: `starttls`, `smtps`, `none` |
32//! | `RWS_SMTP_TIMEOUT_MS` | `10000` | Connect / read / write timeout in ms |
33//!
34//! # Provider examples
35//!
36//! **Gmail** (STARTTLS, port 587):
37//! ```bash
38//! RWS_SMTP_HOST=smtp.gmail.com RWS_SMTP_PORT=587 RWS_SMTP_TLS=starttls
39//! RWS_SMTP_USER=you@gmail.com RWS_SMTP_PASSWORD=app-password
40//! RWS_SMTP_FROM=you@gmail.com
41//! ```
42//!
43//! **SendGrid** (STARTTLS, port 587):
44//! ```bash
45//! RWS_SMTP_HOST=smtp.sendgrid.net RWS_SMTP_PORT=587 RWS_SMTP_TLS=starttls
46//! RWS_SMTP_USER=apikey RWS_SMTP_PASSWORD=SG.xxxx RWS_SMTP_FROM=you@domain.com
47//! ```
48//!
49//! **Localhost relay** (no TLS, port 25):
50//! ```bash
51//! RWS_SMTP_HOST=127.0.0.1 RWS_SMTP_PORT=25 RWS_SMTP_TLS=none
52//! RWS_SMTP_FROM=app@internal
53//! ```
54
55use 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// ── Error ──────────────────────────────────────────────────────────────────────
64
65/// Error returned by [`Mailer::send`] and [`EmailBuilder::build`].
66#[derive(Debug)]
67pub enum MailerError {
68    /// A required environment variable is missing.
69    MissingConfig(String),
70    /// An I/O error on the TCP or TLS stream.
71    Io(std::io::Error),
72    /// The SMTP server returned an unexpected reply code.
73    Smtp(String),
74    /// The [`EmailBuilder`] was given invalid or incomplete input.
75    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// ── TLS mode ───────────────────────────────────────────────────────────────────
100
101/// TLS mode for the SMTP connection.
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum SmtpTls {
104    /// Plain TCP — no encryption. Suitable for localhost relays and trusted
105    /// internal networks only.
106    None,
107    /// STARTTLS upgrade on the initial plain connection (default, port 587).
108    /// Requires the `http-client` or `http2` feature at compile time.
109    Starttls,
110    /// Implicit TLS from the first byte (port 465 / SMTPS).
111    /// Requires the `http-client` or `http2` feature at compile time.
112    Smtps,
113}
114
115// ── Email ─────────────────────────────────────────────────────────────────────
116
117/// An outgoing email message.
118///
119/// Construct with [`Email::builder`] then call [`Mailer::send`].
120#[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    /// Start building an email.
133    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
146/// Builder for [`Email`].
147pub 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    /// Add a `To:` recipient.
159    pub fn to(mut self, addr: &str) -> Self {
160        self.to.push(addr.to_string());
161        self
162    }
163
164    /// Add a `Cc:` recipient.
165    pub fn cc(mut self, addr: &str) -> Self {
166        self.cc.push(addr.to_string());
167        self
168    }
169
170    /// Add a `Bcc:` recipient (envelope only; not in headers).
171    pub fn bcc(mut self, addr: &str) -> Self {
172        self.bcc.push(addr.to_string());
173        self
174    }
175
176    /// Set the `Subject:` header.
177    pub fn subject(mut self, s: &str) -> Self {
178        self.subject = s.to_string();
179        self
180    }
181
182    /// Set a plain-text body.
183    pub fn text(mut self, body: &str) -> Self {
184        self.text = Some(body.to_string());
185        self
186    }
187
188    /// Set an HTML body.
189    pub fn html(mut self, body: &str) -> Self {
190        self.html = Some(body.to_string());
191        self
192    }
193
194    /// Set the `Reply-To:` header.
195    pub fn reply_to(mut self, addr: &str) -> Self {
196        self.reply_to = Some(addr.to_string());
197        self
198    }
199
200    /// Validate and build the [`Email`].
201    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
227// ── Mailer ────────────────────────────────────────────────────────────────────
228
229/// SMTP mailer.
230///
231/// Create via [`Mailer::from_env`] or construct directly. Call [`Mailer::send`]
232/// to deliver an [`Email`].
233///
234/// # Example — Gmail with STARTTLS
235///
236/// ```rust,no_run
237/// use rust_web_server::mailer::{Email, Mailer, SmtpTls};
238///
239/// let mailer = Mailer {
240///     host: "smtp.gmail.com".into(),
241///     port: 587,
242///     user: Some("you@gmail.com".into()),
243///     password: Some("app-password".into()),
244///     from: "you@gmail.com".into(),
245///     tls: SmtpTls::Starttls,
246///     timeout_ms: 10_000,
247/// };
248/// ```
249pub 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    /// Construct a `Mailer` from `RWS_SMTP_*` environment variables.
261    ///
262    /// Required: `RWS_SMTP_HOST` and `RWS_SMTP_FROM`.
263    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    /// Send an email.
291    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            // Pre-TLS SMTP session — EHLO + STARTTLS, no AUTH
334            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            // Extract TcpStream; the BufReader buffer is empty here because
341            // the server sends no bytes after `220 Go ahead` until we initiate
342            // the TLS handshake.
343            let tcp = conn.reader.into_inner();
344            let tls = smtp_tls_connect(tcp, &self.host)?;
345
346            // Full session over TLS (includes EHLO, optional AUTH, mail exchange)
347            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
380// ── SMTP session helpers ───────────────────────────────────────────────────────
381
382/// Wraps a `BufReader<S>` so we can alternate reads and writes without
383/// reborrowing the same `BufReader` for both simultaneously.
384struct 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    /// Read a potentially multiline SMTP response and check for `code`.
394    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    /// Send a single SMTP command followed by CRLF.
405    fn cmd(&mut self, cmd: &str) -> Result<(), MailerError> {
406        smtp_write(self.reader.get_mut(), cmd)
407    }
408}
409
410/// Read a single SMTP response (one or more lines; last line has `NNN ` prefix).
411fn 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
424/// Write `cmd\r\n` to `w` and flush.
425fn 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
432/// AUTH PLAIN — encodes `\0user\0pass` in base64 and sends in one line.
433fn 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
446/// MAIL FROM → RCPT TO(s) → DATA → body → QUIT.
447fn smtp_deliver<S: Read + Write>(
448    conn: &mut SmtpConn<S>,
449    from: &str,
450    email: &Email,
451) -> Result<(), MailerError> {
452    // An address goes into an SMTP command line: one with CR, LF or angle
453    // brackets could end the command and inject others (#47).
454    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    // SMTP DATA terminator: end the message body then signal end-of-data.
473    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    // Read 221 Bye — synchronizes with the server so the connection is cleanly
479    // drained before the caller drops the stream.
480    let _ = conn.expect(221);
481    Ok(())
482}
483
484/// Full SMTP session starting from the server banner (220).
485fn 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// ── TLS upgrade (requires rustls) ─────────────────────────────────────────────
506
507#[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
530/// An address that can go into `MAIL FROM:<…>` / `RCPT TO:<…>` as is.
531pub(crate) fn smtp_address_ok(addr: &str) -> bool {
532    !addr.is_empty() && !addr.contains(['\r', '\n', '<', '>', '\0'])
533}
534
535// ── RFC 5322 message builder ───────────────────────────────────────────────────
536
537/// Build a minimal RFC 5322 message string (headers + blank line + body).
538/// The SMTP DATA terminator (`\r\n.\r\n`) is NOT included here.
539pub(crate) fn build_message(from: &str, email: &Email) -> String {
540    let mut msg = String::new();
541
542    // Header values on one line each (#47): a CR/LF in a subject or
543    // address would start a new header (Bcc: injection).
544    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) => {} // caught by EmailBuilder::build
579    }
580
581    msg
582}
583
584/// SMTP dot-stuffing (RFC 5321 §4.5.2): prefix any line starting with `.`
585/// with an extra `.` so the DATA terminator is not triggered early.
586pub(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    // Remove the trailing \r\n added after the last line
597    if result.ends_with("\r\n") {
598        result.truncate(result.len() - 2);
599    }
600    result
601}