Skip to content

Commit 85ec2ed

Browse files
committed
Address PR #50 review feedback on enable_ipv6 flag
- Extract connect-target selection into pure `select_connect_target()` with `ConnectTarget` enum; add unit tests covering all four IPv4/IPv6 combinations. - Skip MX hosts with no A record when `enable_ipv6 = false` instead of silently falling through to the hostname (which could still resolve to IPv6 and violate the flag). Emits a clear "no A record; skipping" error so `try_deliver` moves on to the next MX. - Replace blocking `ToSocketAddrs` in `resolve_ipv4` with a new `mx::resolve_a()` helper built on `hickory-resolver`, for consistency with MX resolution and to avoid blocking getaddrinfo in tokio workers. - Add a doc comment on `resolve_ipv4` explaining why it exists (flag semantics, third add/remove in three sprints) to prevent future deletion. - Add a doc comment on TLS SNI vs. connect-target mismatch while `dangerous_accept_invalid_certs` is set. - Extract `detect_server_ipv6(enable_ipv6, net)` gate in setup.rs and add tests using `get_server_ipv6_calls` counter on `MockNetworkOps` to verify the network call is made iff the flag is true. - Correct book/configuration.md: `aimx verify` only probes port 25, not IPv6; the flag only affects `aimx setup`. - Add post-merge addendum under Sprint 26 in docs/sprint.md noting that this follow-up flipped the default to opt-in IPv6.
1 parent fd5f520 commit 85ec2ed

5 files changed

Lines changed: 189 additions & 23 deletions

File tree

book/configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ If your server has a global IPv6 address and you want outbound mail to use it:
111111

112112
See the full DNS records table in [Setup](setup.md#dns-configuration) for formats. Without these DNS updates, messages delivered over IPv6 will fail SPF and may be rejected under your DMARC policy.
113113

114-
**When `enable_ipv6` is unset or `false`:** `aimx setup` and `aimx verify` ignore IPv6 entirely — no AAAA is advertised, no `ip6:` SPF is generated, and existing AAAA records in DNS are not validated (but their presence is harmless).
114+
**When `enable_ipv6` is unset or `false`:** `aimx setup` ignores IPv6 entirely — no AAAA is advertised, no `ip6:` SPF is generated, and existing AAAA records in DNS are not validated (their presence is harmless). `aimx verify` only probes port 25 connectivity and is unaffected by this flag.
115115

116116
Leave `enable_ipv6` unset (or `false`) if any of these apply:
117117
- Your server does not have a global IPv6 address

docs/sprint.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,16 @@ Completed sprints 1–21 have been archived for context window efficiency.
244244

245245
## Sprint 26 — IPv6 Support for Outbound SMTP (Days 73–75.5) [DONE]
246246

247+
> **Follow-up addendum (post-merge):** A later PR (`enable-ipv6-config-flag`)
248+
> flipped the default back to IPv4-only and made IPv6 outbound opt-in via a
249+
> new `enable_ipv6` bool in `config.toml`. The Sprint 26 ACs below still
250+
> describe the original "OS chooses the family" behaviour that shipped when
251+
> this sprint merged; the current shipped default is IPv4-only, and the
252+
> dual-stack SPF / AAAA guidance is only emitted by `aimx setup` when
253+
> `enable_ipv6 = true`. See PRD FR-7, FR-19, resolved-decision #8 and
254+
> `book/configuration.md` "IPv6 delivery (advanced)" for the current
255+
> behaviour.
256+
247257
**Goal:** Remove the IPv4-only workaround from outbound delivery and properly support IPv6 across SPF records, DNS guidance, and verification. The IPv4 preference was added in Sprint 25 as a workaround for SPF failures — now that DKIM is fixed, let the OS resolve addresses naturally and ensure SPF covers both address families.
248258

249259
**Dependencies:** Sprint 25

src/mx.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,23 @@ async fn fallback_to_a_record(
3737
}
3838
}
3939

40+
/// Resolves a host to its IPv4 (A-record) addresses only, skipping AAAA.
41+
///
42+
/// Used by outbound SMTP when `enable_ipv6 = false`, so the connect target is
43+
/// pinned to an IPv4 address rather than letting the OS pick an address family.
44+
/// Returns an empty Vec when the host has no A record (e.g. AAAA-only hosts).
45+
pub async fn resolve_a(host: &str) -> Result<Vec<std::net::Ipv4Addr>, Box<dyn std::error::Error>> {
46+
let resolver = TokioResolver::builder_tokio()
47+
.map_err(|e| format!("Failed to create DNS resolver: {e}"))?
48+
.build();
49+
50+
match resolver.ipv4_lookup(host).await {
51+
Ok(response) => Ok(response.iter().map(|a| a.0).collect()),
52+
Err(e) if e.is_no_records_found() || e.is_nx_domain() => Ok(vec![]),
53+
Err(e) => Err(format!("DNS A-record lookup failed for {host}: {e}").into()),
54+
}
55+
}
56+
4057
#[cfg(test)]
4158
mod tests {
4259
use super::*;

src/send.rs

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,64 @@ pub struct LettreTransport {
1919
enable_ipv6: bool,
2020
}
2121

22+
/// Outcome of picking a connect target for outbound SMTP.
23+
///
24+
/// Exists so the `enable_ipv6 = false` path can be tested without real DNS.
25+
#[derive(Debug, PartialEq, Eq)]
26+
pub(crate) enum ConnectTarget {
27+
/// Connect over this literal (hostname when `enable_ipv6 = true`, or an
28+
/// IPv4 string when `enable_ipv6 = false` and an A record was found).
29+
Target(String),
30+
/// IPv4-only was requested but the MX host has no A record. Caller should
31+
/// skip this MX so the flag is not silently violated.
32+
SkipNoIpv4,
33+
}
34+
35+
/// Pure, testable connect-target selection.
36+
///
37+
/// - `enable_ipv6 = true` → always use the hostname; OS picks the family.
38+
/// - `enable_ipv6 = false` + at least one A record → use the first A.
39+
/// - `enable_ipv6 = false` + no A records → `SkipNoIpv4` so the caller can
40+
/// move on to the next MX instead of silently falling through to the
41+
/// hostname (which may resolve to IPv6 and violate the flag).
42+
pub(crate) fn select_connect_target(
43+
host: &str,
44+
enable_ipv6: bool,
45+
ipv4_addrs: &[std::net::Ipv4Addr],
46+
) -> ConnectTarget {
47+
if enable_ipv6 {
48+
return ConnectTarget::Target(host.to_string());
49+
}
50+
match ipv4_addrs.first() {
51+
Some(addr) => ConnectTarget::Target(addr.to_string()),
52+
None => ConnectTarget::SkipNoIpv4,
53+
}
54+
}
55+
2256
impl LettreTransport {
2357
pub fn new(enable_ipv6: bool) -> Self {
2458
Self { enable_ipv6 }
2559
}
2660

27-
fn resolve_ipv4(host: &str) -> Option<std::net::Ipv4Addr> {
28-
use std::net::ToSocketAddrs;
29-
format!("{host}:25")
30-
.to_socket_addrs()
31-
.ok()?
32-
.find_map(|addr| match addr {
33-
std::net::SocketAddr::V4(v4) => Some(*v4.ip()),
34-
_ => None,
35-
})
61+
/// Resolves an MX hostname's A records only (no AAAA).
62+
///
63+
/// This helper has been added, removed, and re-added across Sprints 25, 26,
64+
/// and this follow-up PR. It exists specifically to honour the opt-in
65+
/// `enable_ipv6` flag: when the flag is false we pin the connect target to
66+
/// an IPv4 literal so `lettre`/the OS cannot silently select an AAAA
67+
/// record. Do not delete without re-auditing the flag semantics.
68+
fn resolve_ipv4(host: &str) -> Result<Vec<std::net::Ipv4Addr>, Box<dyn std::error::Error>> {
69+
let rt = tokio::runtime::Handle::try_current();
70+
match rt {
71+
Ok(handle) => {
72+
tokio::task::block_in_place(|| handle.block_on(crate::mx::resolve_a(host)))
73+
}
74+
Err(_) => {
75+
let rt = tokio::runtime::Runtime::new()
76+
.map_err(|e| format!("Failed to create runtime: {e}"))?;
77+
rt.block_on(crate::mx::resolve_a(host))
78+
}
79+
}
3680
}
3781

3882
fn extract_domain(recipient: &str) -> Result<String, Box<dyn std::error::Error>> {
@@ -117,17 +161,27 @@ impl LettreTransport {
117161
) -> Result<String, Box<dyn std::error::Error>> {
118162
use lettre::Transport;
119163

164+
// Note: SNI uses `host` while the connect target may be a bare IPv4
165+
// literal (IPv4-only mode). This is fine here because
166+
// `dangerous_accept_invalid_certs(true)` is set — cert name mismatch
167+
// is accepted. If that flag is ever flipped, SNI and the TLS peer
168+
// identity would need to be reconciled.
120169
let tls_params = lettre::transport::smtp::client::TlsParameters::builder(host.to_string())
121170
.dangerous_accept_invalid_certs(true)
122171
.build_rustls()
123172
.map_err(|e| format!("TLS configuration error: {e}"))?;
124173

125-
let connect_target = if self.enable_ipv6 {
126-
host.to_string()
174+
let ipv4_addrs = if self.enable_ipv6 {
175+
Vec::new()
127176
} else {
128-
Self::resolve_ipv4(host)
129-
.map(|ip| ip.to_string())
130-
.unwrap_or_else(|| host.to_string())
177+
Self::resolve_ipv4(host).unwrap_or_default()
178+
};
179+
180+
let connect_target = match select_connect_target(host, self.enable_ipv6, &ipv4_addrs) {
181+
ConnectTarget::Target(t) => t,
182+
ConnectTarget::SkipNoIpv4 => {
183+
return Err(format!("{host}: no A record (enable_ipv6 = false); skipping").into());
184+
}
131185
};
132186

133187
let transport = lettre::SmtpTransport::builder_dangerous(&connect_target)
@@ -997,6 +1051,35 @@ mod tests {
9971051
);
9981052
}
9991053

1054+
#[test]
1055+
fn select_connect_target_ipv6_enabled_returns_hostname() {
1056+
let target = select_connect_target("mx.example.com", true, &[]);
1057+
assert_eq!(target, ConnectTarget::Target("mx.example.com".to_string()));
1058+
}
1059+
1060+
#[test]
1061+
fn select_connect_target_ipv6_enabled_ignores_ipv4_addrs() {
1062+
let addrs = vec!["1.2.3.4".parse().unwrap()];
1063+
let target = select_connect_target("mx.example.com", true, &addrs);
1064+
assert_eq!(target, ConnectTarget::Target("mx.example.com".to_string()));
1065+
}
1066+
1067+
#[test]
1068+
fn select_connect_target_ipv4_mode_uses_first_a_record() {
1069+
let addrs: Vec<std::net::Ipv4Addr> = vec![
1070+
"203.0.113.10".parse().unwrap(),
1071+
"203.0.113.11".parse().unwrap(),
1072+
];
1073+
let target = select_connect_target("mx.example.com", false, &addrs);
1074+
assert_eq!(target, ConnectTarget::Target("203.0.113.10".to_string()));
1075+
}
1076+
1077+
#[test]
1078+
fn select_connect_target_ipv4_mode_without_a_record_skips() {
1079+
let target = select_connect_target("aaaa-only.example.com", false, &[]);
1080+
assert_eq!(target, ConnectTarget::SkipNoIpv4);
1081+
}
1082+
10001083
#[test]
10011084
fn lettre_transport_extract_domain() {
10021085
assert_eq!(

src/setup.rs

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,6 +1063,24 @@ pub fn is_already_configured(sys: &dyn SystemOps, _domain: &str, data_dir: &Path
10631063
service_running && cert_exists && dkim_exists
10641064
}
10651065

1066+
/// Detects the server's IPv6 address iff `enable_ipv6 = true`.
1067+
///
1068+
/// IPv6 outbound is opt-in via `enable_ipv6` in `config.toml`. When disabled,
1069+
/// the call to `net.get_server_ipv6()` is skipped entirely — AAAA + `ip6:`
1070+
/// SPF guidance/verification are omitted to match the IPv4-only default of
1071+
/// `aimx send`. Extracted as a standalone helper so the gating can be tested
1072+
/// without driving the whole setup flow.
1073+
pub(crate) fn detect_server_ipv6(
1074+
enable_ipv6: bool,
1075+
net: &dyn NetworkOps,
1076+
) -> Result<Option<IpAddr>, Box<dyn std::error::Error>> {
1077+
if enable_ipv6 {
1078+
net.get_server_ipv6()
1079+
} else {
1080+
Ok(None)
1081+
}
1082+
}
1083+
10661084
pub fn run_setup(
10671085
domain: Option<&str>,
10681086
data_dir: Option<&Path>,
@@ -1187,14 +1205,7 @@ pub fn run_setup(
11871205

11881206
// Step 7: DNS guidance and verification (section [DNS])
11891207
let server_ip = net.get_server_ip()?;
1190-
// IPv6 outbound is opt-in via `enable_ipv6` in config.toml. When disabled,
1191-
// skip IPv6 detection entirely so AAAA and `ip6:` SPF guidance/verification
1192-
// are omitted — matching the IPv4-only default of `aimx send`.
1193-
let server_ipv6 = if enable_ipv6 {
1194-
net.get_server_ipv6()?
1195-
} else {
1196-
None
1197-
};
1208+
let server_ipv6 = detect_server_ipv6(enable_ipv6, net)?;
11981209
let dkim_value = dkim::dns_record_value(data_dir)?;
11991210

12001211
let local_dkim_pubkey = dkim_value
@@ -1285,6 +1296,7 @@ mod tests {
12851296
a_records: HashMap<String, Vec<IpAddr>>,
12861297
aaaa_records: HashMap<String, Vec<IpAddr>>,
12871298
txt_records: HashMap<String, Vec<String>>,
1299+
get_server_ipv6_calls: std::cell::Cell<u32>,
12881300
}
12891301

12901302
impl Default for MockNetworkOps {
@@ -1299,6 +1311,7 @@ mod tests {
12991311
a_records: HashMap::new(),
13001312
aaaa_records: HashMap::new(),
13011313
txt_records: HashMap::new(),
1314+
get_server_ipv6_calls: std::cell::Cell::new(0),
13021315
}
13031316
}
13041317
}
@@ -1317,6 +1330,8 @@ mod tests {
13171330
Ok(self.server_ip)
13181331
}
13191332
fn get_server_ipv6(&self) -> Result<Option<IpAddr>, Box<dyn std::error::Error>> {
1333+
self.get_server_ipv6_calls
1334+
.set(self.get_server_ipv6_calls.get() + 1);
13201335
Ok(self.server_ipv6)
13211336
}
13221337
fn resolve_mx(&self, domain: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
@@ -2211,6 +2226,47 @@ mod tests {
22112226
);
22122227
}
22132228

2229+
#[test]
2230+
fn detect_server_ipv6_false_does_not_query_net() {
2231+
let net = MockNetworkOps {
2232+
server_ipv6: Some("2001:db8::1".parse().unwrap()),
2233+
..Default::default()
2234+
};
2235+
let result = detect_server_ipv6(false, &net).unwrap();
2236+
assert!(result.is_none(), "ipv6 should be None when flag disabled");
2237+
assert_eq!(
2238+
net.get_server_ipv6_calls.get(),
2239+
0,
2240+
"get_server_ipv6 must NOT be called when enable_ipv6 = false"
2241+
);
2242+
}
2243+
2244+
#[test]
2245+
fn detect_server_ipv6_true_queries_net() {
2246+
let net = MockNetworkOps {
2247+
server_ipv6: Some("2001:db8::1".parse().unwrap()),
2248+
..Default::default()
2249+
};
2250+
let result = detect_server_ipv6(true, &net).unwrap();
2251+
assert_eq!(result, Some("2001:db8::1".parse().unwrap()));
2252+
assert_eq!(
2253+
net.get_server_ipv6_calls.get(),
2254+
1,
2255+
"get_server_ipv6 must be called exactly once when enable_ipv6 = true"
2256+
);
2257+
}
2258+
2259+
#[test]
2260+
fn detect_server_ipv6_true_returns_none_when_net_has_none() {
2261+
let net = MockNetworkOps {
2262+
server_ipv6: None,
2263+
..Default::default()
2264+
};
2265+
let result = detect_server_ipv6(true, &net).unwrap();
2266+
assert!(result.is_none());
2267+
assert_eq!(net.get_server_ipv6_calls.get(), 1);
2268+
}
2269+
22142270
#[test]
22152271
fn setup_with_domain_arg_skips_prompt() {
22162272
let tmp = TempDir::new().unwrap();

0 commit comments

Comments
 (0)