1use std::net::SocketAddr;
2
3use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
4use tokio::net::TcpListener;
5use tokio::sync::mpsc;
6
7#[derive(Debug, Clone)]
9pub enum SteamMailItem {
10 GuardCode(String),
12 VerificationLink(String),
14}
15
16pub struct SteamMailServer {
19 item_rx: mpsc::Receiver<SteamMailItem>,
20 local_addr: SocketAddr,
21}
22
23impl SteamMailServer {
24 pub async fn new(addr: impl tokio::net::ToSocketAddrs) -> std::io::Result<Self> {
29 let listener = TcpListener::bind(addr).await?;
30 let local_addr = listener.local_addr()?;
31 let (item_tx, item_rx) = mpsc::channel(16);
32
33 tokio::spawn(async move {
34 loop {
35 let (stream, _) = match listener.accept().await {
36 Ok(v) => v,
37 Err(_) => continue,
38 };
39 let tx = item_tx.clone();
40 tokio::spawn(async move {
41 let _ = handle_smtp(stream, tx).await;
42 });
43 }
44 });
45
46 Ok(Self { item_rx, local_addr })
47 }
48
49 pub fn local_addr(&self) -> SocketAddr {
51 self.local_addr
52 }
53
54 pub async fn recv(&mut self) -> Option<SteamMailItem> {
56 self.item_rx.recv().await
57 }
58
59 pub async fn recv_code(&mut self) -> Option<String> {
63 loop {
64 match self.item_rx.recv().await? {
65 SteamMailItem::GuardCode(code) => return Some(code),
66 _ => continue,
67 }
68 }
69 }
70
71 pub async fn recv_link(&mut self) -> Option<String> {
75 loop {
76 match self.item_rx.recv().await? {
77 SteamMailItem::VerificationLink(url) => return Some(url),
78 _ => continue,
79 }
80 }
81 }
82}
83
84async fn handle_smtp(
85 stream: tokio::net::TcpStream,
86 item_tx: mpsc::Sender<SteamMailItem>,
87) -> std::io::Result<()> {
88 let (reader, mut writer) = stream.into_split();
89 let mut lines = BufReader::new(reader).lines();
90
91 writer.write_all(b"220 steamdepot ESMTP\r\n").await?;
92
93 let mut in_data = false;
94 let mut body = String::new();
95
96 while let Some(line) = lines.next_line().await? {
97 if in_data {
98 if line == "." {
99 in_data = false;
100 if let Some(code) = extract_guard_code(&body) {
102 let _ = item_tx.send(SteamMailItem::GuardCode(code)).await;
103 } else if let Some(url) = extract_verification_link(&body) {
104 let _ = item_tx.send(SteamMailItem::VerificationLink(url)).await;
105 }
106 body.clear();
107 writer.write_all(b"250 OK\r\n").await?;
108 } else {
109 body.push_str(&line);
110 body.push('\n');
111 }
112 continue;
113 }
114
115 let upper = line.to_ascii_uppercase();
116 if upper.starts_with("EHLO") || upper.starts_with("HELO") {
117 writer.write_all(b"250 Hello\r\n").await?;
118 } else if upper.starts_with("MAIL FROM") {
119 writer.write_all(b"250 OK\r\n").await?;
120 } else if upper.starts_with("RCPT TO") {
121 writer.write_all(b"250 OK\r\n").await?;
122 } else if upper == "DATA" {
123 writer
124 .write_all(b"354 Start mail input; end with <CRLF>.<CRLF>\r\n")
125 .await?;
126 in_data = true;
127 } else if upper == "QUIT" {
128 writer.write_all(b"221 Bye\r\n").await?;
129 break;
130 } else if upper == "RSET" {
131 body.clear();
132 writer.write_all(b"250 OK\r\n").await?;
133 } else {
134 writer.write_all(b"250 OK\r\n").await?;
135 }
136 }
137
138 Ok(())
139}
140
141fn extract_verification_link(body: &str) -> Option<String> {
146 for line in body.lines() {
147 let trimmed = line.trim();
148 for segment in trimmed.split(|c: char| c == '"' || c == '\'' || c == ' ' || c == '<' || c == '>') {
150 let s = segment.trim();
151 if (s.starts_with("https://store.steampowered.com/")
152 || s.starts_with("https://help.steampowered.com/")
153 || s.starts_with("https://login.steampowered.com/"))
154 && (s.contains("verify") || s.contains("confirm") || s.contains("newaccountverification")
155 || s.contains("login/emailconf") || s.contains("creationconfirm"))
156 {
157 let url = s.trim_end_matches(|c: char| c == '"' || c == '\'' || c == '>' || c == ';');
159 return Some(url.to_string());
160 }
161 }
162 }
163 None
164}
165
166fn extract_guard_code(body: &str) -> Option<String> {
172 for line in body.lines() {
174 let trimmed = line.trim();
175
176 if let Some(pos) = trimmed.to_ascii_lowercase().find("code") {
178 let after = &trimmed[pos + 4..];
179 let after = after
181 .trim_start_matches(|c: char| c == ':' || c == ' ' || c.eq_ignore_ascii_case(&'i') || c.eq_ignore_ascii_case(&'s'));
182 let candidate: String = after.chars().take_while(|c| c.is_alphanumeric()).collect();
183 if candidate.len() == 5 && candidate.chars().all(|c| c.is_ascii_alphanumeric()) {
184 return Some(candidate);
185 }
186 }
187 }
188
189 for line in body.lines() {
191 let trimmed = line.trim();
192 if trimmed.len() == 5 && trimmed.chars().all(|c| c.is_ascii_alphanumeric()) {
193 return Some(trimmed.to_string());
194 }
195 }
196
197 None
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn extract_code_with_label() {
206 let body = "Subject: Steam Guard code\n\nYour Steam Guard code is: F4K2N\n\nThanks.";
207 assert_eq!(extract_guard_code(body), Some("F4K2N".to_string()));
208 }
209
210 #[test]
211 fn extract_code_standalone_line() {
212 let body = "Some header stuff\n\nAB3XY\n\nFooter";
213 assert_eq!(extract_guard_code(body), Some("AB3XY".to_string()));
214 }
215
216 #[test]
217 fn no_code_found() {
218 let body = "Hello, this is a regular email with no code.";
219 assert_eq!(extract_guard_code(body), None);
220 }
221
222 #[test]
223 fn extract_verification_link_plain() {
224 let body = "Click here to verify your email:\nhttps://store.steampowered.com/newaccountverification?stoken=abc123&creationid=456\n\nThanks.";
225 assert_eq!(
226 extract_verification_link(body),
227 Some("https://store.steampowered.com/newaccountverification?stoken=abc123&creationid=456".to_string())
228 );
229 }
230
231 #[test]
232 fn extract_verification_link_html() {
233 let body = r#"<a href="https://store.steampowered.com/creationconfirm?token=xyz">Verify</a>"#;
234 assert_eq!(
235 extract_verification_link(body),
236 Some("https://store.steampowered.com/creationconfirm?token=xyz".to_string())
237 );
238 }
239
240 #[test]
241 fn extract_verification_link_login_emailconf() {
242 let body = "https://login.steampowered.com/login/emailconf?token=abc";
243 assert_eq!(
244 extract_verification_link(body),
245 Some("https://login.steampowered.com/login/emailconf?token=abc".to_string())
246 );
247 }
248
249 #[test]
250 fn extract_verification_link_real_format() {
251 let body = "Click below to verify:\nhttps://store.steampowered.com/account/newaccountverification?stoken=deadbeef1234567890abcdef&creationid=1234567890123456789\nThanks";
252 assert_eq!(
253 extract_verification_link(body),
254 Some("https://store.steampowered.com/account/newaccountverification?stoken=deadbeef1234567890abcdef&creationid=1234567890123456789".to_string())
255 );
256 }
257
258 #[test]
259 fn no_verification_link() {
260 let body = "Hello, this is a regular email with no Steam links.";
261 assert_eq!(extract_verification_link(body), None);
262 }
263
264 #[tokio::test]
265 async fn smtp_server_binds() {
266 let server = SteamMailServer::new("127.0.0.1:0").await.unwrap();
267 let addr = server.local_addr();
268 assert_ne!(addr.port(), 0);
269 }
270}