Skip to main content

steam_mail/
lib.rs

1use std::net::SocketAddr;
2
3use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
4use tokio::net::TcpListener;
5use tokio::sync::mpsc;
6
7/// Item extracted from a Steam email.
8#[derive(Debug, Clone)]
9pub enum SteamMailItem {
10    /// A 5-character Steam Guard code.
11    GuardCode(String),
12    /// A verification/confirmation URL from Steam.
13    VerificationLink(String),
14}
15
16/// Minimal SMTP server that extracts Steam Guard codes and verification links
17/// from incoming emails.
18pub struct SteamMailServer {
19    item_rx: mpsc::Receiver<SteamMailItem>,
20    local_addr: SocketAddr,
21}
22
23impl SteamMailServer {
24    /// Bind to `addr` and start accepting SMTP connections.
25    ///
26    /// Spawns a background task that handles the SMTP handshake and
27    /// extracts Steam Guard codes from email bodies.
28    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    /// The local address this server is bound to.
50    pub fn local_addr(&self) -> SocketAddr {
51        self.local_addr
52    }
53
54    /// Wait for the next item (guard code or verification link) from an email.
55    pub async fn recv(&mut self) -> Option<SteamMailItem> {
56        self.item_rx.recv().await
57    }
58
59    /// Wait for the next Steam Guard code to arrive via email.
60    ///
61    /// Skips any non-code items (e.g. verification links).
62    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    /// Wait for the next verification link to arrive via email.
72    ///
73    /// Skips any non-link items (e.g. guard codes).
74    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                // Try guard code first, then verification link
101                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
141/// Extract a Steam verification/confirmation link from an email body.
142///
143/// Looks for URLs from `store.steampowered.com` or `help.steampowered.com`
144/// that contain common verification path segments.
145fn extract_verification_link(body: &str) -> Option<String> {
146    for line in body.lines() {
147        let trimmed = line.trim();
148        // Look for Steam URLs — could be in HTML href or plain text
149        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                // Trim any trailing HTML/punctuation
158                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
166/// Extract a 5-character alphanumeric Steam Guard code from an email body.
167///
168/// Steam Guard emails contain the code on its own line or in a pattern like
169/// "Your Steam Guard code is: XXXXX" or just the 5-char code surrounded by
170/// whitespace/newlines.
171fn extract_guard_code(body: &str) -> Option<String> {
172    // Try pattern: "code is: XXXXX" or "code: XXXXX"
173    for line in body.lines() {
174        let trimmed = line.trim();
175
176        // Look for explicit "code" mentions
177        if let Some(pos) = trimmed.to_ascii_lowercase().find("code") {
178            let after = &trimmed[pos + 4..];
179            // Skip "is", ":", whitespace
180            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    // Fallback: look for a standalone 5-char alphanumeric token on its own line
190    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}