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;
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// ── Error ──────────────────────────────────────────────────────────────────────
68
69/// Error returned by [`Mailer::send`] and [`EmailBuilder::build`].
70#[derive(Debug)]
71pub enum MailerError {
72    /// A required environment variable is missing.
73    MissingConfig(String),
74    /// An I/O error on the TCP or TLS stream.
75    Io(std::io::Error),
76    /// The SMTP server returned an unexpected reply code.
77    Smtp(String),
78    /// The [`EmailBuilder`] was given invalid or incomplete input.
79    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// ── TLS mode ───────────────────────────────────────────────────────────────────
104
105/// TLS mode for the SMTP connection.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub enum SmtpTls {
108    /// Plain TCP — no encryption. Suitable for localhost relays and trusted
109    /// internal networks only.
110    None,
111    /// STARTTLS upgrade on the initial plain connection (default, port 587).
112    /// Requires the `http-client` or `http2` feature at compile time.
113    Starttls,
114    /// Implicit TLS from the first byte (port 465 / SMTPS).
115    /// Requires the `http-client` or `http2` feature at compile time.
116    Smtps,
117}
118
119// ── Email ─────────────────────────────────────────────────────────────────────
120
121/// An outgoing email message.
122///
123/// Construct with [`Email::builder`] then call [`Mailer::send`].
124#[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    /// Start building an email.
137    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
150/// Builder for [`Email`].
151pub 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    /// Add a `To:` recipient.
163    pub fn to(mut self, addr: &str) -> Self {
164        self.to.push(addr.to_string());
165        self
166    }
167
168    /// Add a `Cc:` recipient.
169    pub fn cc(mut self, addr: &str) -> Self {
170        self.cc.push(addr.to_string());
171        self
172    }
173
174    /// Add a `Bcc:` recipient (envelope only; not in headers).
175    pub fn bcc(mut self, addr: &str) -> Self {
176        self.bcc.push(addr.to_string());
177        self
178    }
179
180    /// Set the `Subject:` header.
181    pub fn subject(mut self, s: &str) -> Self {
182        self.subject = s.to_string();
183        self
184    }
185
186    /// Set a plain-text body.
187    pub fn text(mut self, body: &str) -> Self {
188        self.text = Some(body.to_string());
189        self
190    }
191
192    /// Set an HTML body.
193    pub fn html(mut self, body: &str) -> Self {
194        self.html = Some(body.to_string());
195        self
196    }
197
198    /// Set the `Reply-To:` header.
199    pub fn reply_to(mut self, addr: &str) -> Self {
200        self.reply_to = Some(addr.to_string());
201        self
202    }
203
204    /// Validate and build the [`Email`].
205    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
231// ── Mailer ────────────────────────────────────────────────────────────────────
232
233/// SMTP mailer.
234///
235/// Create via [`Mailer::from_env`] or construct directly. Call [`Mailer::send`]
236/// to deliver an [`Email`].
237///
238/// # Example — Gmail with STARTTLS
239///
240/// ```rust,no_run
241/// use rust_web_server::mailer::{Email, Mailer, SmtpTls};
242///
243/// let mailer = Mailer {
244///     host: "smtp.gmail.com".into(),
245///     port: 587,
246///     user: Some("you@gmail.com".into()),
247///     password: Some("app-password".into()),
248///     from: "you@gmail.com".into(),
249///     tls: SmtpTls::Starttls,
250///     timeout_ms: 10_000,
251/// };
252/// ```
253pub 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    /// Construct a `Mailer` from `RWS_SMTP_*` environment variables.
265    ///
266    /// Required: `RWS_SMTP_HOST` and `RWS_SMTP_FROM`.
267    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    /// Send an email.
295    pub fn send(&self, email: &Email) -> Result<(), MailerError> {
296        self.send_parts(email, &[])
297    }
298
299    /// Send an email with its attachments (18.35.0).
300    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            // Pre-TLS SMTP session — EHLO + STARTTLS, no AUTH
349            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            // Extract TcpStream; the BufReader buffer is empty here because
356            // the server sends no bytes after `220 Go ahead` until we initiate
357            // the TLS handshake.
358            let tcp = conn.reader.into_inner();
359            let tls = smtp_tls_connect(tcp, &self.host)?;
360
361            // Full session over TLS (includes EHLO, optional AUTH, mail exchange)
362            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
398// ── SMTP session helpers ───────────────────────────────────────────────────────
399
400/// Wraps a `BufReader<S>` so we can alternate reads and writes without
401/// reborrowing the same `BufReader` for both simultaneously.
402struct 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    /// Read a potentially multiline SMTP response and check for `code`.
412    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    /// Send a single SMTP command followed by CRLF.
423    fn cmd(&mut self, cmd: &str) -> Result<(), MailerError> {
424        smtp_write(self.reader.get_mut(), cmd)
425    }
426}
427
428/// Read a single SMTP response (one or more lines; last line has `NNN ` prefix).
429fn 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
442/// Write `cmd\r\n` to `w` and flush.
443fn 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
450/// AUTH PLAIN — encodes `\0user\0pass` in base64 and sends in one line.
451fn 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
464/// MAIL FROM → RCPT TO(s) → DATA → body → QUIT.
465fn smtp_deliver<S: Read + Write>(
466    conn: &mut SmtpConn<S>,
467    from: &str,
468    email: &Email,
469    attachments: &[MailAttachment],
470) -> Result<(), MailerError> {
471    // An address goes into an SMTP command line: one with CR, LF or angle
472    // brackets could end the command and inject others (#47).
473    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    // SMTP DATA terminator: end the message body then signal end-of-data.
492    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    // Read 221 Bye — synchronizes with the server so the connection is cleanly
498    // drained before the caller drops the stream.
499    let _ = conn.expect(221);
500    Ok(())
501}
502
503/// Full SMTP session starting from the server banner (220).
504fn 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// ── TLS upgrade (requires rustls) ─────────────────────────────────────────────
526
527#[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
550/// An address that can go into `MAIL FROM:<…>` / `RCPT TO:<…>` as is.
551pub(crate) fn smtp_address_ok(addr: &str) -> bool {
552    !addr.is_empty() && !addr.contains(['\r', '\n', '<', '>', '\0'])
553}
554
555// ── RFC 5322 message builder ───────────────────────────────────────────────────
556
557/// Build a minimal RFC 5322 message string (headers + blank line + body).
558/// The SMTP DATA terminator (`\r\n.\r\n`) is NOT included here.
559#[cfg(test)]
560pub(crate) fn build_message(from: &str, email: &Email) -> String {
561    build_message_with(from, email, &[])
562}
563
564/// [`build_message`] with attachments: a `multipart/mixed` message whose
565/// first part is the body, then each attachment in base64.
566pub(crate) fn build_message_with(from: &str, email: &Email, attachments: &[MailAttachment]) -> String {
567    let mut msg = String::new();
568
569    // Header values on one line each (#47): a CR/LF in a subject or
570    // address would start a new header (Bcc: injection).
571    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) => {} // caught by EmailBuilder::build
612    }
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
632/// An attachment's file name for a quoted header parameter: one line,
633/// no quotes or backslashes, ASCII (others become `_`).
634fn 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
642/// SMTP dot-stuffing (RFC 5321 §4.5.2): prefix any line starting with `.`
643/// with an extra `.` so the DATA terminator is not triggered early.
644pub(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    // Remove the trailing \r\n added after the last line
655    if result.ends_with("\r\n") {
656        result.truncate(result.len() - 2);
657    }
658    result
659}