Skip to main content

smtp_test_tool/
smtp.rs

1//! SMTP connectivity test using lettre.
2//!
3//! Emits `tracing` events; both CLI and GUI subscribe.  Translates any
4//! server error into actionable hints via `diagnostics::smtp_hints_for`.
5
6use crate::config::Profile;
7use crate::diagnostics::smtp_hints_for;
8use crate::tls::Security;
9use anyhow::{anyhow, Context, Result};
10use lettre::message::{header::ContentType, Mailbox, Message};
11use lettre::transport::smtp::authentication::{Credentials, Mechanism};
12use lettre::transport::smtp::client::{Tls, TlsParametersBuilder};
13use lettre::transport::smtp::SmtpTransport;
14use lettre::Transport;
15use serde::{Deserialize, Serialize};
16use std::str::FromStr;
17use std::time::Duration;
18use tracing::{error, info, warn};
19
20#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum AuthMech {
23    #[default]
24    Auto,
25    Login,
26    Plain,
27    #[serde(rename = "cram-md5")]
28    CramMd5,
29    #[serde(rename = "xoauth2")]
30    XOauth2,
31}
32
33impl AuthMech {
34    pub fn as_str(self) -> &'static str {
35        match self {
36            AuthMech::Auto => "auto",
37            AuthMech::Login => "login",
38            AuthMech::Plain => "plain",
39            AuthMech::CramMd5 => "cram-md5",
40            AuthMech::XOauth2 => "xoauth2",
41        }
42    }
43}
44
45/// Run the SMTP test.  Returns `Ok(true)` on success; `Ok(false)` on a
46/// reachable-server-but-rejected outcome; `Err` on lower-level failure
47/// (DNS, connection refused, ...).
48#[tracing::instrument(level = "info", skip(p), fields(protocol = "smtp"))]
49pub fn run(p: &Profile) -> Result<bool> {
50    info!(
51        "SMTP target {}:{} ({})",
52        p.smtp_host,
53        p.smtp_port,
54        p.smtp_security.as_str()
55    );
56
57    // ----- TLS parameters (lettre takes a rustls-backed builder) ------
58    let tls_params = TlsParametersBuilder::new(p.smtp_host.clone())
59        .dangerous_accept_invalid_certs(p.insecure_tls)
60        .dangerous_accept_invalid_hostnames(p.insecure_tls)
61        .build()
62        .context("building TLS parameters")?;
63    let tls = match p.smtp_security {
64        Security::None => Tls::None,
65        Security::StartTls => Tls::Required(tls_params),
66        Security::Implicit => Tls::Wrapper(tls_params),
67    };
68    if p.insecure_tls {
69        warn!("TLS certificate verification DISABLED (insecure_tls=true)");
70    }
71
72    // ----- transport --------------------------------------------------
73    let mut builder = SmtpTransport::builder_dangerous(&p.smtp_host)
74        .port(p.smtp_port)
75        .tls(tls)
76        .timeout(Some(Duration::from_secs(p.timeout_secs)));
77
78    if let Some(ehlo) = &p.ehlo_name {
79        builder = builder.hello_name(lettre::transport::smtp::extension::ClientId::Domain(
80            ehlo.clone(),
81        ));
82    }
83
84    // ----- credentials ------------------------------------------------
85    if let (Some(user), Some(pass)) = (p.user.as_ref(), p.password.as_ref()) {
86        builder = builder.credentials(Credentials::new(user.clone(), pass.clone()));
87        let mech = match p.auth_mech {
88            AuthMech::Auto => vec![Mechanism::Plain, Mechanism::Login],
89            AuthMech::Login => vec![Mechanism::Login],
90            AuthMech::Plain => vec![Mechanism::Plain],
91            AuthMech::CramMd5 => vec![Mechanism::Xoauth2 /* placeholder, see below */],
92            AuthMech::XOauth2 => vec![Mechanism::Xoauth2],
93        };
94        builder = builder.authentication(mech);
95        info!(
96            "Configured SMTP AUTH as {user} (mech={})",
97            p.auth_mech.as_str()
98        );
99    } else if let Some(token) = p.oauth_token.as_ref() {
100        // XOAUTH2: lettre accepts the bearer token in the password field.
101        let user = p.user.clone().unwrap_or_default();
102        builder = builder.credentials(Credentials::new(user.clone(), token.clone()));
103        builder = builder.authentication(vec![Mechanism::Xoauth2]);
104        info!("Configured SMTP XOAUTH2 as {user}");
105    } else {
106        info!("No credentials supplied - testing connectivity only (no AUTH)");
107    }
108
109    let transport = builder.build();
110
111    // ----- optional test message --------------------------------------
112    if p.send_test {
113        match build_message(p) {
114            Ok(msg) => match transport.send(&msg) {
115                Ok(resp) => {
116                    info!("Message accepted (code {})", resp.code());
117                    return Ok(true);
118                }
119                Err(e) => {
120                    error!("MESSAGE SUBMISSION FAILED: {e}");
121                    for hint in smtp_hints_for(&e.to_string()) {
122                        error!("{hint}");
123                    }
124                    return Ok(false);
125                }
126            },
127            Err(e) => {
128                error!("Could not build test message: {e}");
129                return Ok(false);
130            }
131        }
132    }
133
134    // ----- otherwise just verify the AUTH/handshake -------------------
135    match transport.test_connection() {
136        Ok(true) => {
137            info!("SMTP handshake + AUTH succeeded");
138            Ok(true)
139        }
140        Ok(false) => {
141            error!("SMTP server did not accept the connection probe");
142            Ok(false)
143        }
144        Err(e) => {
145            error!("SMTP test failed: {e}");
146            for hint in smtp_hints_for(&e.to_string()) {
147                error!("{hint}");
148            }
149            Ok(false)
150        }
151    }
152}
153
154fn build_message(p: &Profile) -> Result<Message> {
155    let header_from = p
156        .from_addr
157        .clone()
158        .or_else(|| p.mail_from.clone())
159        .or_else(|| p.user.clone())
160        .ok_or_else(|| anyhow!("no From: address (set 'from_addr', 'mail_from', or 'user')"))?;
161    let envelope_from = p
162        .mail_from
163        .clone()
164        .or_else(|| p.user.clone())
165        .unwrap_or(header_from.clone());
166
167    let to_addrs: Vec<String> = if p.to.is_empty() {
168        // default: send to ourselves so the test is harmless.
169        vec![envelope_from.clone()]
170    } else {
171        p.to.clone()
172    };
173
174    if header_from != envelope_from {
175        info!(
176            "Header From <{}> differs from envelope MAIL FROM <{}> - this exercises 'Send As' rights",
177            header_from, envelope_from
178        );
179    }
180
181    let mut msg = Message::builder()
182        .from(Mailbox::from_str(&header_from).context("invalid From: address")?)
183        .subject(&p.subject);
184
185    for t in &to_addrs {
186        msg = msg.to(Mailbox::from_str(t).with_context(|| format!("invalid To: {t}"))?);
187    }
188    for c in &p.cc {
189        msg = msg.cc(Mailbox::from_str(c).with_context(|| format!("invalid Cc: {c}"))?);
190    }
191    for b in &p.bcc {
192        msg = msg.bcc(Mailbox::from_str(b).with_context(|| format!("invalid Bcc: {b}"))?);
193    }
194    if let Some(r) = &p.reply_to {
195        msg = msg.reply_to(Mailbox::from_str(r).context("invalid Reply-To:")?);
196    }
197
198    let msg = msg
199        .header(ContentType::TEXT_PLAIN)
200        .body(p.body.clone())
201        .context("building MIME body")?;
202    Ok(msg)
203}