Skip to main content

mailrs_outbound_queue/worker/
smtp.rs

1//! Per-MX SMTP delivery with STARTTLS / DANE policy handling.
2
3use std::sync::Arc;
4
5use hickory_resolver::TokioResolver;
6
7use crate::queue::QueuedMessage;
8use crate::{DeliveryEvent, DeliveryEventSender, TlsAttemptOutcome};
9
10/// TLS policy for outbound connections
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum TlsPolicy {
13    /// try STARTTLS, fall back to plaintext on failure (default)
14    Opportunistic,
15    /// require TLS, fail delivery if STARTTLS unavailable or fails
16    Require,
17}
18
19/// try to deliver messages via a specific MX host
20///
21/// `port` is the TCP port to connect to on `mx_host`. Production
22/// always passes 25 (the SMTP relay port); integration tests inject
23/// an ephemeral mock-server port. Kept as a parameter rather than
24/// hardcoded so the worker stays testable end-to-end without a real
25/// MTA on port 25.
26#[allow(clippy::too_many_arguments)]
27pub async fn try_deliver_via_mx(
28    hostname: &str,
29    mx_host: &str,
30    port: u16,
31    domain: &str,
32    messages: &[QueuedMessage],
33    resolver: &TokioResolver,
34    event_sender: Option<&DeliveryEventSender>,
35) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
36    try_deliver_via_mx_with_tls(
37        hostname,
38        mx_host,
39        port,
40        domain,
41        messages,
42        TlsPolicy::Opportunistic,
43        None,
44        resolver,
45        event_sender,
46    )
47    .await
48}
49
50/// Try to deliver messages via a specific MX host with explicit TLS
51/// policy and optional ClientConfig override.
52///
53/// `tls_config_override` lets integration tests inject a dangerous
54/// (skip-verify) `rustls::ClientConfig` so the STARTTLS-success path
55/// can be driven against a mock SMTP server presenting a self-signed
56/// cert. Production code (`try_deliver_via_mx`) passes `None` so the
57/// default `webpki-roots` PKIX verifier is used.
58#[allow(clippy::too_many_arguments)]
59pub async fn try_deliver_via_mx_with_tls(
60    hostname: &str,
61    mx_host: &str,
62    port: u16,
63    domain: &str,
64    messages: &[QueuedMessage],
65    tls_policy: TlsPolicy,
66    tls_config_override: Option<Arc<rustls::ClientConfig>>,
67    resolver: &TokioResolver,
68    event_sender: Option<&DeliveryEventSender>,
69) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
70    use mailrs_smtp_client::StarttlsResult;
71
72    // Helper to emit a TlsAttempt event with the given outcome.
73    let emit_tls = |outcome: TlsAttemptOutcome| {
74        if let Some(es) = event_sender {
75            es(DeliveryEvent::TlsAttempt {
76                domain: domain.to_string(),
77                mx_host: mx_host.to_string(),
78                outcome,
79            });
80        }
81    };
82
83    let mut smtp = mailrs_smtp_client::SmtpConnection::connect(mx_host, port).await?;
84    let ehlo_resp = smtp.ehlo(hostname).await?;
85
86    if !ehlo_resp.is_positive() {
87        return Err(format!("EHLO rejected: {}", ehlo_resp.message()).into());
88    }
89
90    // resolve TLSA records for DANE
91    let tlsa_records = mailrs_smtp_client::resolve_tlsa(resolver, mx_host).await;
92    let has_dane = !tlsa_records.is_empty();
93    if has_dane {
94        tracing::debug!("found {} TLSA records for {mx_host}", tlsa_records.len());
95    }
96
97    // try STARTTLS if advertised
98    if ehlo_resp.has_extension("STARTTLS") {
99        let tls_result = if has_dane {
100            // use DANE-verified TLS
101            smtp.try_starttls_dane(mx_host, tlsa_records).await
102        } else if let Some(ref cfg) = tls_config_override {
103            // caller-supplied ClientConfig (typically a test
104            // harness with a skip-verify verifier; never used in
105            // production paths)
106            smtp.try_starttls_with_config(mx_host, (**cfg).clone())
107                .await
108        } else {
109            // standard PKIX TLS
110            smtp.try_starttls(mx_host).await
111        };
112
113        match tls_result {
114            StarttlsResult::Success(tls_smtp) => {
115                smtp = tls_smtp;
116                let _ = smtp.ehlo(hostname).await?;
117                let policy: &'static str = if has_dane {
118                    tracing::debug!("DANE-verified TLS established with {mx_host}");
119                    "dane"
120                } else {
121                    tracing::debug!("TLS established with {mx_host}");
122                    "opportunistic"
123                };
124                emit_tls(TlsAttemptOutcome::Success { policy });
125            }
126            StarttlsResult::Rejected {
127                conn,
128                code,
129                message,
130            } => {
131                emit_tls(TlsAttemptOutcome::Rejected {
132                    code,
133                    message: message.clone(),
134                });
135                if has_dane || tls_policy == TlsPolicy::Require {
136                    return Err(format!(
137                        "STARTTLS rejected by {mx_host} ({code}): {message}{}",
138                        if has_dane {
139                            " (DANE required)"
140                        } else {
141                            " (TLS required)"
142                        }
143                    )
144                    .into());
145                }
146                tracing::warn!(
147                    "STARTTLS rejected by {mx_host} ({code}): {message}; continuing in plain"
148                );
149                // Connection is still usable in plain mode per
150                // StarttlsResult::Rejected contract.
151                smtp = conn;
152            }
153            StarttlsResult::HandshakeFailed { outcome, source } => {
154                emit_tls(TlsAttemptOutcome::HandshakeFailed(outcome.clone()));
155                if has_dane || tls_policy == TlsPolicy::Require {
156                    return Err(format!(
157                        "STARTTLS handshake failed for {mx_host} ({}): {source}{}",
158                        outcome.as_str(),
159                        if has_dane {
160                            " (DANE required)"
161                        } else {
162                            " (TLS required)"
163                        }
164                    )
165                    .into());
166                }
167                tracing::warn!(
168                    "STARTTLS handshake failed for {mx_host} ({}): {source}; reconnecting in plain",
169                    outcome.as_str()
170                );
171                smtp = mailrs_smtp_client::SmtpConnection::connect(mx_host, port).await?;
172                let resp = smtp.ehlo(hostname).await?;
173                if !resp.is_positive() {
174                    return Err(format!("EHLO rejected on reconnect: {}", resp.message()).into());
175                }
176            }
177        }
178    } else if has_dane || tls_policy == TlsPolicy::Require {
179        emit_tls(TlsAttemptOutcome::NotAdvertised);
180        return Err(format!(
181            "{mx_host} does not advertise STARTTLS{}",
182            if has_dane {
183                " (DANE TLSA records present, TLS required)"
184            } else {
185                " and TLS is required"
186            }
187        )
188        .into());
189    } else {
190        emit_tls(TlsAttemptOutcome::NotAdvertised);
191        tracing::info!("delivering to {mx_host} without TLS (STARTTLS not advertised)");
192    }
193
194    for msg in messages {
195        let to = [msg.recipient.as_str()];
196        let resp = smtp.deliver(&msg.sender, &to, &msg.message_data).await?;
197        if !resp.is_positive() {
198            return Err(format!("delivery failed: {}", resp.message()).into());
199        }
200    }
201
202    let _ = smtp.quit().await;
203    Ok(())
204}