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 let decoded = decode_quoted_printable(&body);
102 if let Some(code) = extract_guard_code(&decoded) {
104 let _ = item_tx.send(SteamMailItem::GuardCode(code)).await;
105 } else if let Some(url) = extract_verification_link(&decoded) {
106 let _ = item_tx.send(SteamMailItem::VerificationLink(url)).await;
107 }
108 body.clear();
109 writer.write_all(b"250 OK\r\n").await?;
110 } else {
111 body.push_str(&line);
112 body.push('\n');
113 }
114 continue;
115 }
116
117 let upper = line.to_ascii_uppercase();
118 if upper.starts_with("EHLO") || upper.starts_with("HELO") {
119 writer.write_all(b"250 Hello\r\n").await?;
120 } else if upper.starts_with("MAIL FROM") {
121 writer.write_all(b"250 OK\r\n").await?;
122 } else if upper.starts_with("RCPT TO") {
123 writer.write_all(b"250 OK\r\n").await?;
124 } else if upper == "DATA" {
125 writer
126 .write_all(b"354 Start mail input; end with <CRLF>.<CRLF>\r\n")
127 .await?;
128 in_data = true;
129 } else if upper == "QUIT" {
130 writer.write_all(b"221 Bye\r\n").await?;
131 break;
132 } else if upper == "RSET" {
133 body.clear();
134 writer.write_all(b"250 OK\r\n").await?;
135 } else {
136 writer.write_all(b"250 OK\r\n").await?;
137 }
138 }
139
140 Ok(())
141}
142
143fn decode_quoted_printable(input: &str) -> String {
147 let mut out = String::with_capacity(input.len());
148 let bytes = input.as_bytes();
149 let mut i = 0;
150 while i < bytes.len() {
151 if bytes[i] == b'=' && i + 2 < bytes.len() {
152 if bytes[i + 1] == b'\r' && i + 2 < bytes.len() && bytes[i + 2] == b'\n' {
154 i += 3;
155 continue;
156 }
157 if bytes[i + 1] == b'\n' {
158 i += 2;
159 continue;
160 }
161 if let (Some(hi), Some(lo)) = (
163 hex_val(bytes[i + 1]),
164 hex_val(bytes[i + 2]),
165 ) {
166 out.push((hi << 4 | lo) as char);
167 i += 3;
168 continue;
169 }
170 }
171 out.push(bytes[i] as char);
172 i += 1;
173 }
174 out
175}
176
177fn hex_val(b: u8) -> Option<u8> {
178 match b {
179 b'0'..=b'9' => Some(b - b'0'),
180 b'A'..=b'F' => Some(b - b'A' + 10),
181 b'a'..=b'f' => Some(b - b'a' + 10),
182 _ => None,
183 }
184}
185
186fn extract_verification_link(body: &str) -> Option<String> {
191 for line in body.lines() {
192 let trimmed = line.trim();
193 for segment in trimmed.split(|c: char| c == '"' || c == '\'' || c == ' ' || c == '<' || c == '>') {
195 let s = segment.trim();
196 if (s.starts_with("https://store.steampowered.com/")
197 || s.starts_with("https://help.steampowered.com/")
198 || s.starts_with("https://login.steampowered.com/"))
199 && (s.contains("verify") || s.contains("confirm") || s.contains("newaccountverification")
200 || s.contains("login/emailconf") || s.contains("creationconfirm"))
201 {
202 let url = s.trim_end_matches(|c: char| c == '"' || c == '\'' || c == '>' || c == ';');
204 return Some(url.to_string());
205 }
206 }
207 }
208 None
209}
210
211fn extract_guard_code(body: &str) -> Option<String> {
217 for line in body.lines() {
219 let trimmed = line.trim();
220
221 if let Some(pos) = trimmed.to_ascii_lowercase().find("code") {
223 let after = &trimmed[pos + 4..];
224 let after = after
226 .trim_start_matches(|c: char| c == ':' || c == ' ' || c.eq_ignore_ascii_case(&'i') || c.eq_ignore_ascii_case(&'s'));
227 let candidate: String = after.chars().take_while(|c| c.is_alphanumeric()).collect();
228 if candidate.len() == 5 && candidate.chars().all(|c| c.is_ascii_alphanumeric()) {
229 return Some(candidate);
230 }
231 }
232 }
233
234 for line in body.lines() {
236 let trimmed = line.trim();
237 if trimmed.len() == 5 && trimmed.chars().all(|c| c.is_ascii_alphanumeric()) {
238 return Some(trimmed.to_string());
239 }
240 }
241
242 None
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248
249 #[test]
250 fn extract_code_with_label() {
251 let body = "Subject: Steam Guard code\n\nYour Steam Guard code is: F4K2N\n\nThanks.";
252 assert_eq!(extract_guard_code(body), Some("F4K2N".to_string()));
253 }
254
255 #[test]
256 fn extract_code_standalone_line() {
257 let body = "Some header stuff\n\nAB3XY\n\nFooter";
258 assert_eq!(extract_guard_code(body), Some("AB3XY".to_string()));
259 }
260
261 #[test]
262 fn no_code_found() {
263 let body = "Hello, this is a regular email with no code.";
264 assert_eq!(extract_guard_code(body), None);
265 }
266
267 #[test]
268 fn extract_verification_link_plain() {
269 let body = "Click here to verify your email:\nhttps://store.steampowered.com/newaccountverification?stoken=abc123&creationid=456\n\nThanks.";
270 assert_eq!(
271 extract_verification_link(body),
272 Some("https://store.steampowered.com/newaccountverification?stoken=abc123&creationid=456".to_string())
273 );
274 }
275
276 #[test]
277 fn extract_verification_link_html() {
278 let body = r#"<a href="https://store.steampowered.com/creationconfirm?token=xyz">Verify</a>"#;
279 assert_eq!(
280 extract_verification_link(body),
281 Some("https://store.steampowered.com/creationconfirm?token=xyz".to_string())
282 );
283 }
284
285 #[test]
286 fn extract_verification_link_login_emailconf() {
287 let body = "https://login.steampowered.com/login/emailconf?token=abc";
288 assert_eq!(
289 extract_verification_link(body),
290 Some("https://login.steampowered.com/login/emailconf?token=abc".to_string())
291 );
292 }
293
294 #[test]
295 fn extract_verification_link_real_format() {
296 let body = "Click below to verify:\nhttps://store.steampowered.com/account/newaccountverification?stoken=deadbeef1234567890abcdef&creationid=1234567890123456789\nThanks";
297 assert_eq!(
298 extract_verification_link(body),
299 Some("https://store.steampowered.com/account/newaccountverification?stoken=deadbeef1234567890abcdef&creationid=1234567890123456789".to_string())
300 );
301 }
302
303 #[test]
304 fn extract_verification_link_quoted_printable() {
305 let raw = "Click here:\nhttps://store.steampowered.com/account/newaccountverification?stoken=3Deb4d1234&creationid=3D5678\nThanks";
308 let decoded = decode_quoted_printable(raw);
309 assert_eq!(
310 extract_verification_link(&decoded),
311 Some("https://store.steampowered.com/account/newaccountverification?stoken=eb4d1234&creationid=5678".to_string())
312 );
313 }
314
315 #[test]
316 fn decode_qp_basic() {
317 assert_eq!(decode_quoted_printable("foo=3Dbar"), "foo=bar");
318 assert_eq!(decode_quoted_printable("line1=\nline2"), "line1line2");
319 assert_eq!(decode_quoted_printable("no encoding"), "no encoding");
320 }
321
322 #[test]
323 fn no_verification_link() {
324 let body = "Hello, this is a regular email with no Steam links.";
325 assert_eq!(extract_verification_link(body), None);
326 }
327
328 #[tokio::test]
329 async fn smtp_server_binds() {
330 let server = SteamMailServer::new("127.0.0.1:0").await.unwrap();
331 let addr = server.local_addr();
332 assert_ne!(addr.port(), 0);
333 }
334}