From 9d2550376511a76ca1af3d7760423f66f8b27f1d Mon Sep 17 00:00:00 2001 From: argszero Date: Wed, 30 Sep 2026 08:17:02 +0800 Subject: [PATCH] fix(config): make the SMTP port match the implicit-TLS transport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 63fc76fd switched the sender from STARTTLS to implicit TLS (`SmtpTransport::relay`) and its message says "port changed to 465", but its diff only touched prose: both port-carrying values stayed 587 — `config/config.example.toml`'s sample draft and `config.rs::default_smtp_port()`. Copying the sample (`ensure_config` writes it to `/config.toml` on first start, and `DEFAULT_CONFIG` embeds the same file) or omitting the key (serde default) therefore pairs 587 with an implicit-TLS transport: the client starts a TLS handshake on the STARTTLS port, Gmail answers in clear text and rustls reports InvalidContentType, so the verification mail is never delivered (registration returns 502). Both carriers now say 465, and `config.rs` names the mechanism so the next protocol change updates the value instead of only the prose. New test-only gate `src/smtp_port_gate.rs` derives the expected port from the transport mode in `mail.rs` (`SmtpTransport::relay` -> lettre's `SUBMISSIONS_PORT`, `builder_dangerous` -> `SUBMISSION_PORT`) rather than hardcoding it, so switching protocols demands both carriers follow. --- config/config.example.toml | 2 +- src/config.rs | 7 +- src/main.rs | 4 + src/smtp_port_gate.rs | 384 +++++++++++++++++++++++++++++++++++++ 4 files changed, 395 insertions(+), 2 deletions(-) create mode 100644 src/smtp_port_gate.rs diff --git a/config/config.example.toml b/config/config.example.toml index b77eaba..ffd662d 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -30,7 +30,7 @@ public_url = "https://gateway.example.com" # ============================================================ # [mail] # smtp_host = "smtp.example.com" -# smtp_port = 587 +# smtp_port = 465 # smtp_user = "noreply@example.com" # smtp_password = "your-smtp-password" # from = "noreply@example.com" diff --git a/src/config.rs b/src/config.rs index 2eda094..69916b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -67,8 +67,13 @@ pub struct Mail { pub verify_subject: String, } +/// SMTP 端口的缺省值:**由 `mail.rs` 选用的传输模式决定** —— +/// `SmtpTransport::relay` = implicit TLS ⇒ 465(`lettre` 的 `SUBMISSIONS_PORT`)。 +/// 填 587(STARTTLS 口)会在连接后立即对 587 做 TLS 握手 ⇒ rustls `InvalidContentType` +/// (2026-08-20 实测,详见 `src/mail.rs` 文件头)。 +/// `config.example.toml` 里的同一个值由 `smtp_port_gate.rs` 守着,两者必须一致。 fn default_smtp_port() -> u16 { - 587 + 465 } impl Mail { diff --git a/src/main.rs b/src/main.rs index c55b3cb..9e7e3c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,10 @@ mod citation_gate; // 加 #[cfg(test)] 后不会进入发布产物。 #[cfg(test)] mod readme_gate; +// SMTP 端口门禁(R127):同样是仅测试期编译 —— config 示例 / config.rs / mail.rs +// 都在编译期读入,加 #[cfg(test)] 后不会进入发布产物。 +#[cfg(test)] +mod smtp_port_gate; use std::sync::Arc; diff --git a/src/smtp_port_gate.rs b/src/smtp_port_gate.rs new file mode 100644 index 0000000..061dbef --- /dev/null +++ b/src/smtp_port_gate.rs @@ -0,0 +1,384 @@ +//! SMTP 端口门禁(R127):**示例配置**与 **serde 默认值**里的 `smtp_port` 必须等于 +//! 发信实现真正使用的那个 TLS 模式所要求的端口。 +//! +//! 起因:`63fc76fd`(2026-08-20)把发信从 STARTTLS 切成 implicit TLS +//!(`SmtpTransport::relay`),提交信息写着「修复:端口改 465」—— 但那次提交**只改了散文** +//!(`config.example.toml` 的说明行与 `mail.rs` 文件头),**两个真正携带端口值的载体一个没动**: +//! 示例草稿仍是 `smtp_port = 587`,`config.rs::default_smtp_port()` 仍返回 587。 +//! 于是照抄示例(或省略该键、走 serde 默认)的用户会得到「对 587 立即做 TLS 握手」 +//! ⇒ Gmail 回明文 ⇒ rustls 报 `InvalidContentType` ⇒ 验证码发不出(注册 502)。 +//! 与 `deploy_gate.rs` 第二条规则同判:**跨格式的副本能抄,前提是有人守**。 +//! +//! # 期望值**派生**,不写快照 +//! +//! 真源是**实现自己选用的传输模式**(`src/mail.rs` 的**代码**): +//! +//! | `mail.rs` 代码里的构造 | 模式 | 期望端口 | +//! |---|---|---| +//! | `SmtpTransport::relay(` | implicit TLS | `lettre::transport::smtp::SUBMISSIONS_PORT`(465) | +//! | `builder_dangerous(` | STARTTLS | `lettre::transport::smtp::SUBMISSION_PORT`(587) | +//! +//! 两个常量都取自 `lettre`(本仓已启用 `smtp-transport` feature)⇒ **端口不写死**; +//! 将来若换成 STARTTLS,门禁会要求那两个载体跟着改成 587。 +//! +//! # ⚠️ 两处提取器的处理方式**相反**(本门禁最容易写错的地方) +//! +//! - 读 `config.example.toml` 时,`smtp_port` 那一行**本身就是注释**(示例即草稿) +//! ⇒ **必须读注释行**(照 `deploy_gate::config_master_keys` 的做法,剥一个前缀 `#`)。 +//! - 读 `mail.rs` / `config.rs` 时**必须先清空注释** —— 否则 `mail.rs` 文件头那句 +//! 「若要用 587 STARTTLS 需改用 `builder_dangerous(…)`」与 `config.rs` 的文档注释 +//! 都会被当成代码,模式判定会同时看到两种模式、默认值提取器会读到注释里的数字。 +//! +//! 设计约束(与 `i18n_pack` / `catalog_gate` / `deploy_gate` 同型): +//! **仅测试期编译**、**零新依赖**、**编译期读入**(不依赖工作目录)。 +//! +//! 射程:只证「这两个值载体与实现选用的模式一致」—— **不**证 SMTP 真能连通(无网络仪器), +//! 也**不**校验 `mail.rs` 头部的散文。散文属 `doc-comment-claims` 轴(修数据、不上门禁); +//! 若将来把 `relay` 换成 STARTTLS,这条门禁会先红,提醒同时更新散文。 + +use lettre::transport::smtp::{SUBMISSIONS_PORT, SUBMISSION_PORT}; + +/// 编译期读入的三份载体。 +const MAIL_RS: &str = include_str!("mail.rs"); +const CONFIG_RS: &str = include_str!("config.rs"); +const CONFIG_EXAMPLE: &str = include_str!("../config/config.example.toml"); + +/// 实现选用的 SMTP TLS 模式 —— 由 `mail.rs` 的**代码**判定。 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TlsMode { + /// `SmtpTransport::relay`:连接后立即 TLS 握手 ⇒ 提交口 465。 + Implicit, + /// `builder_dangerous(…).tls(Tls::Opportunistic(…))`:先明文、再 STARTTLS ⇒ 587。 + StartTls, +} + +impl TlsMode { + /// 该模式在 `lettre` 里的默认提交端口 —— **派生**,不写快照。 + fn expected_port(self) -> u16 { + match self { + TlsMode::Implicit => SUBMISSIONS_PORT, + TlsMode::StartTls => SUBMISSION_PORT, + } + } + + fn what(self) -> &'static str { + match self { + TlsMode::Implicit => "implicit TLS(SmtpTransport::relay)", + TlsMode::StartTls => "STARTTLS(builder_dangerous)", + } + } +} + +/// 从 `mail.rs` 的代码(注释已清空)判定传输模式。 +fn tls_mode(mail_code: &str) -> Option { + if mail_code.contains("SmtpTransport::relay(") { + Some(TlsMode::Implicit) + } else if mail_code.contains("builder_dangerous(") { + Some(TlsMode::StartTls) + } else { + None + } +} + +/// `mail.rs` 确实把配置里的端口送进了 transport(`.port(…smtp_port…)`)—— +/// 否则那两个值载体就算一致也没有意义(值是死的)。 +fn forwards_configured_port(mail_code: &str) -> bool { + let mut rest = mail_code; + while let Some(pos) = rest.find(".port(") { + let after = &rest[pos + ".port(".len()..]; + let end = after.find(')').unwrap_or(after.len()); + if after[..end].contains("smtp_port") { + return true; + } + rest = &after[end..]; + } + false +} + +/// `config.example.toml` 里的每一处 `smtp_port = `(**允许带 `#` 前缀** —— 那正是示例草稿的形态), +/// 返回 `(行号, 值)`。空集 = 提取器失效,门禁必须响亮失败而不是静默通过。 +fn example_smtp_ports(src: &str) -> Vec<(usize, u16)> { + let mut out = Vec::new(); + for (i, line) in src.lines().enumerate() { + let body = line.trim_start(); + // 示例草稿就是注释行 ⇒ 剥掉一个 `#` 再看。 + let body = body.strip_prefix('#').unwrap_or(body).trim_start(); + let Some(rest) = body.strip_prefix("smtp_port") else { + continue; + }; + let Some(rest) = rest.trim_start().strip_prefix('=') else { + continue; // 例如 `smtp_port_backup` 之类,不是赋值 + }; + let value = rest.trim().trim_matches(|c| c == '"' || c == '\''); + if let Ok(n) = value.parse::() { + out.push((i + 1, n)); + } + } + out +} + +/// `src/config.rs::default_smtp_port()` 的返回字面量(`(行号, 值)`)。 +fn default_smtp_port(src: &str) -> Option<(usize, u16)> { + let masked = mask_comments(src); + let start = masked.find("fn default_smtp_port(")?; + let body = &masked[start..]; + let open = body.find('{')?; + let close = body[open..].find('}')? + open; + let region = &body[open + 1..close]; + let ds = region.find(|c: char| c.is_ascii_digit())?; + let digits: String = region[ds..] + .chars() + .take_while(|c| c.is_ascii_digit()) + .collect(); + let n = digits.parse::().ok()?; + let line = masked[..start + open + 1 + ds].matches('\n').count() + 1; + Some((line, n)) +} + +/// 词法掩码:把注释替换成**等长**空格(字节长度不变 ⇒ 偏移与行号与原文一一对应)。 +/// 字符串/字符字面量**整段跳过但不清空** —— `"https://…"` 里的 `//` 不是注释。 +fn mask_comments(src: &str) -> String { + let b = src.as_bytes(); + let mut out = b.to_vec(); + let mut i = 0usize; + while i < b.len() { + if b[i] == b'/' && b.get(i + 1) == Some(&b'/') { + while i < b.len() && b[i] != b'\n' { + out[i] = b' '; + i += 1; + } + } else if b[i] == b'/' && b.get(i + 1) == Some(&b'*') { + let mut depth = 0usize; + while i < b.len() { + if b[i] == b'/' && b.get(i + 1) == Some(&b'*') { + depth += 1; + out[i] = b' '; + if i + 1 < out.len() { + out[i + 1] = b' '; + } + i += 2; + } else if b[i] == b'*' && b.get(i + 1) == Some(&b'/') { + depth -= 1; + out[i] = b' '; + if i + 1 < out.len() { + out[i + 1] = b' '; + } + i += 2; + if depth == 0 { + break; + } + } else { + if b[i] != b'\n' { + out[i] = b' '; + } + i += 1; + } + } + } else if b[i] == b'"' { + i += 1; + while i < b.len() { + if b[i] == b'\\' { + i += 2; // 转义序列 + continue; + } + if b[i] == b'"' { + i += 1; + break; + } + i += 1; + } + } else if b[i] == b'\'' { + match char_literal_len(&b[i..]) { + Some(len) => i += len, + None => i += 1, // 生命周期标注(`&'static str`)不是字面量 + } + } else { + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// 从 `'` 起的**字符字面量**整段长度(含两侧引号);生命周期标注返回 `None`。 +fn char_literal_len(s: &[u8]) -> Option { + let mut j = 1usize; + if s.get(j) == Some(&b'\\') { + j += 2; // `'\n'` / `'\''` + } else { + j += utf8_len(*s.get(j)?); + } + if s.get(j) == Some(&b'\'') { + Some(j + 1) + } else { + None + } +} + +/// UTF-8 首字节给出的整个码点长度。 +fn utf8_len(b: u8) -> usize { + if b < 0x80 { + 1 + } else if b >> 5 == 0b110 { + 2 + } else if b >> 4 == 0b1110 { + 3 + } else { + 4 + } +} + +#[test] +fn the_smtp_port_carriers_match_the_mode_the_sender_uses() { + let mail_code = mask_comments(MAIL_RS); + let mode = tls_mode(&mail_code).unwrap_or_else(|| { + panic!( + "找不到 SMTP 传输模式:`src/mail.rs` 的代码里既没有 `SmtpTransport::relay(` \ + 也没有 `builder_dangerous(` —— 门禁的判据失效了,请更新 `tls_mode()`" + ) + }); + let expected = mode.expected_port(); + + let samples = example_smtp_ports(CONFIG_EXAMPLE); + assert!( + !samples.is_empty(), + "`config/config.example.toml` 里找不到 `smtp_port = ` —— 门禁的提取器失效了" + ); + let (df_line, df_port) = default_smtp_port(CONFIG_RS).unwrap_or_else(|| { + panic!("`src/config.rs::default_smtp_port()` 里读不到返回值 —— 门禁的提取器失效了") + }); + + let mut wrong = Vec::new(); + for (line, port) in &samples { + if *port != expected { + wrong.push(format!( + "config/config.example.toml:{line} 示例草稿写 `smtp_port = {port}`,应为 {expected}" + )); + } + } + if df_port != expected { + wrong.push(format!( + "src/config.rs:{df_line} `default_smtp_port()` 返回 {df_port},应为 {expected}" + )); + } + assert!( + wrong.is_empty(), + "`src/mail.rs` 用 {} ⇒ 提交口是 {expected}。但端口值载体与它不一致:\n - {}\n\ + 端口必须与实现选用的传输模式一致:照抄示例、或省略 `smtp_port` 走 serde 默认的用户,\ + 会因协议不匹配而连接失败(rustls InvalidContentType)。", + mode.what(), + wrong.join("\n - ") + ); +} + +#[test] +fn the_smtp_port_scanner_reaches_every_carrier_and_strips_comments() { + // 阳性对照:三个提取点都必须**真的取到东西** —— 否则「扫到 0 条」也会让轴测试通过。 + let mail_code = mask_comments(MAIL_RS); + let mode = tls_mode(&mail_code).expect("模式提取器失效(空集)"); + assert!( + forwards_configured_port(&mail_code), + "`mail.rs` 里没有把配置端口送进 `.port(…)` —— 值成了死的" + ); + let samples = example_smtp_ports(CONFIG_EXAMPLE); + assert!(!samples.is_empty(), "示例配置提取器失效(空集)"); + assert!( + samples.iter().all(|(_, p)| *p > 0), + "示例配置里读到 0 —— 提取器读错了行" + ); + let (_, d) = default_smtp_port(CONFIG_RS).expect("config.rs 提取器失效(空集)"); + assert!(d > 0, "config.rs 默认值读到 0 —— 提取器读错了行"); + + // 掩码器**在干活**(判据是派生的、不是快照):`mail.rs` 文件头那句散文提到了 + // STARTTLS 的改法关键字 —— 原文里必须有、清空注释后必须没有。 + // 否则模式判定会同时看到 relay 与 builder_dangerous,门禁就废了。 + assert!( + MAIL_RS.contains("builder_dangerous("), + "前提变了:`mail.rs` 文件头不再提 STARTTLS 改法,请复核本对照" + ); + assert!( + !mail_code.contains("builder_dangerous("), + "注释没有被掩码掉 —— 模式判定会同时看到两种模式" + ); + // `config.rs` 的文档注释里也有数字(本门禁写下的「465」)—— 必须被掩掉, + // 否则默认值提取器可能读到注释里的字面量。 + // 判据用**差值**而不是「掩码后不含 `//`」:`config.rs` 的字符串字面量里本来就有 + // 形如 `"http://localhost:8080"` 的 `//`,掩码器**刻意**保留字符串正文。 + let masked_config = mask_comments(CONFIG_RS); + assert!( + CONFIG_RS.matches("//").count() > masked_config.matches("//").count(), + "config.rs 的注释未被掩码(掩码前后 `//` 数应减少;字符串里的 `//` 会保留)" + ); + assert!( + masked_config.contains("http://"), + "掩码器连字符串正文一起清了 —— 它会误吞代码里的 `//` 判定" + ); + + // `TlsMode` 的映射确实是 lettre 的两个常量(派生自依赖,不写快照)。 + assert_ne!( + TlsMode::Implicit.expected_port(), + TlsMode::StartTls.expected_port(), + "lettre 的两个提交口不该相同" + ); + assert_eq!(mode, TlsMode::Implicit, "本仓当前用的是 implicit TLS"); +} + +#[test] +fn the_smtp_port_rule_rejects_a_mismatch_and_the_extractors_have_teeth() { + // 合成输入:拿**另一个模式**的端口当示例值(派生,不写字面量)⇒ 提取器应读出它, + // 且它与 implicit TLS 的期望值不等(这正是轴测试会判红的那种形态)。 + let implicit = TlsMode::Implicit.expected_port(); + let starttls = TlsMode::StartTls.expected_port(); + let fake_example = format!("# [mail]\n# smtp_port = {starttls}\n"); + assert_eq!( + example_smtp_ports(&fake_example), + vec![(2, starttls)], + "示例提取器没能读出注释草稿里的值" + ); + assert_ne!(starttls, implicit, "两种模式的端口应不同 ⇒ 错值必须被判红"); + + // 未注释的形态也要认(用户可能把草稿取消注释后再提交)。 + assert_eq!( + example_smtp_ports(&format!("smtp_port = {starttls}\n")), + vec![(1, starttls)] + ); + // 相邻键不得被误当成 `smtp_port`(`smtp_port_backup` 这类)。 + assert!( + example_smtp_ports("# smtp_port_backup = 1\n").is_empty(), + "前缀匹配把 `smtp_port_backup` 读成了 `smtp_port`" + ); + + // 默认值提取器:读函数体里的那个数字。 + assert_eq!( + default_smtp_port(&format!( + "fn default_smtp_port() -> u16 {{\n {starttls}\n}}\n" + )), + Some((2, starttls)) + ); + // 注释里的数字不得被读出来(否则「注释里写着 465、代码是 587」会假绿)。 + let commented = + format!("/// 应为 {implicit}\nfn default_smtp_port() -> u16 {{\n {starttls}\n}}\n"); + assert_eq!( + default_smtp_port(&commented), + Some((3, starttls)), + "默认值提取器读到了注释里的数字" + ); + + // 掩码器:字符串里的 `//` 不是注释,字符串里的 `/*` 也不是。 + let with_url = "let u = \"https://example.com/a\"; // 尾注释\nlet v = 1;\n"; + let m = mask_comments(with_url); + assert!( + m.contains("https://example.com/a"), + "字符串里的 // 被误当注释" + ); + assert!( + !m.contains("尾注释") && m.contains("let v = 1;"), + "行注释未被掩码" + ); + let with_block = "let s = \"/* not a comment */\";\n/* real */ let t = 2;\n"; + let mb = mask_comments(with_block); + assert!( + mb.contains("/* not a comment */"), + "字符串里的块注释起始被误判" + ); + assert!(!mb.contains("/* real */"), "块注释未被掩码"); +}