1use 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#[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 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 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 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 ],
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 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 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 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 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}