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                // Decode quoted-printable if the email uses it
101                let decoded = decode_quoted_printable(&body);
102                // Try guard code first, then verification link
103                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
143/// Decode quoted-printable encoded text.
144///
145/// In SMTP emails, `=` is encoded as `=3D`, soft line breaks as `=\n`, etc.
146fn 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            // Soft line break: = followed by \r\n or \n
153            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            // Hex-encoded byte
162            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
186/// Extract a Steam verification/confirmation link from an email body.
187///
188/// Looks for URLs from `store.steampowered.com` or `help.steampowered.com`
189/// that contain common verification path segments.
190fn extract_verification_link(body: &str) -> Option<String> {
191    for line in body.lines() {
192        let trimmed = line.trim();
193        // Look for Steam URLs — could be in HTML href or plain text
194        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                // Trim any trailing HTML/punctuation
203                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
211/// Extract a 5-character alphanumeric Steam Guard code from an email body.
212///
213/// Steam Guard emails contain the code on its own line or in a pattern like
214/// "Your Steam Guard code is: XXXXX" or just the 5-char code surrounded by
215/// whitespace/newlines.
216fn extract_guard_code(body: &str) -> Option<String> {
217    // Try pattern: "code is: XXXXX" or "code: XXXXX"
218    for line in body.lines() {
219        let trimmed = line.trim();
220
221        // Look for explicit "code" mentions
222        if let Some(pos) = trimmed.to_ascii_lowercase().find("code") {
223            let after = &trimmed[pos + 4..];
224            // Skip "is", ":", whitespace
225            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    // Fallback: look for a standalone 5-char alphanumeric token on its own line
235    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        // Real SMTP emails encode = as =3D in quoted-printable.
306        // decode_quoted_printable is called in handle_smtp before extraction.
307        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}