steam-user 0.1.0

Steam User web client for Rust - HTTP-based Steam Community interactions
Documentation
//! Email parsing utilities for Steam Guard codes.

use std::time::Duration;

use reqwest::Client;
use scraper::{Html, Selector};
use serde::{Deserialize, Serialize};

/// Connect timeout for the mail-fetching client (Google Apps Script endpoint).
const MAIL_CONNECT_TIMEOUT: Duration = Duration::from_secs(30);

/// Total request timeout for the mail-fetching client. GAS cold starts can be
/// slow, so this matches the GAS client's 60s default.
const MAIL_TIMEOUT: Duration = Duration::from_secs(60);

#[derive(Debug, Serialize, Deserialize)]
struct MailResponse {
    mails: Option<Vec<Mail>>,
}

#[derive(Debug, Serialize, Deserialize)]
struct Mail {
    body: String,
}

#[derive(Debug, Serialize)]
struct MailFilter {
    date: Option<String>,
    body: Option<Vec<String>>,
    from: Option<String>,
}

fn parse_steam_guard_code(html_content: &str) -> Option<String> {
    let document = Html::parse_document(html_content);
    let selector = Selector::parse("table tbody tr td").ok()?;

    for element in document.select(&selector) {
        let style = element.value().attr("style").unwrap_or("");
        let text = element.text().collect::<String>().trim().to_string();

        // Check styles approximately (scraper doesn't parse CSS, just attributes)
        // JS: css.color === "#3a9aed" && css["font-weight"] === "bold" &&
        // css["text-align"] === "center" We have to check the style string.
        let style_lower = style.to_lowercase();
        if style_lower.contains("color: #3a9aed") && style_lower.contains("font-weight: bold") && style_lower.contains("text-align: center") && text.len() == 5 && text.to_uppercase() == text {
            return Some(text);
        }

        // Sometimes style might be parsed differently or space-separated.
        // Let's be slightly more lenient or strict depending on how consistent
        // the email format is. The JS checks computed style, which is
        // hard in Rust. We will rely on text pattern if style check is
        // too brittle, but let's try to match the specific conditional.
        // "color: #3a9aed; font-weight: bold; text-align: center;"
    }
    None
}

/// Fetches Steam Guard codes from the email inbox via a macro URL.
///
/// # Arguments
/// * `macro_url` - The Google Apps Script macro URL endpoint for accessing the
///   email inbox.
///
/// # Returns
/// A vector of Steam Guard codes found in the last 10 minutes of emails.
pub async fn get_mail_steam_guard_codes(macro_url: &str) -> anyhow::Result<Vec<String>> {
    if macro_url.is_empty() {
        return Ok(vec![]);
    }

    let filter = MailFilter { date: Some("LAST_10_MIN".to_string()), body: None, from: None };

    let filter_json = serde_json::to_string(&filter)?;
    let filter_encoded = urlencoding::encode(&filter_json);
    let url = format!("{}?action=getEmailInbox&filter={}", macro_url, filter_encoded);

    let client = Client::builder().connect_timeout(MAIL_CONNECT_TIMEOUT).timeout(MAIL_TIMEOUT).build()?;
    let resp = client.get(&url).send().await?;

    // JS ignores errors and returns undefined.
    if !resp.status().is_success() {
        return Ok(vec![]);
    }

    let result: MailResponse = resp.json().await?;
    let mut codes = Vec::new();

    if let Some(mails) = result.mails {
        for mail in mails {
            if let Some(code) = parse_steam_guard_code(&mail.body) {
                codes.push(code);
            }
        }
    }

    Ok(codes)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_steam_guard_code() {
        let html_success = r#"
            <html>
                <body>
                    <table>
                        <tbody>
                            <tr>
                                <td style="color: #3a9aed; font-weight: bold; text-align: center;">PHV79</td>
                            </tr>
                        </tbody>
                    </table>
                </body>
            </html>
        "#;
        assert_eq!(parse_steam_guard_code(html_success), Some("PHV79".to_string()));

        let html_fail_color = r#"
            <html>
                <body>
                    <table>
                        <tbody>
                            <tr>
                                <td style="color: red; font-weight: bold; text-align: center;">PHV79</td>
                            </tr>
                        </tbody>
                    </table>
                </body>
            </html>
        "#;
        assert_eq!(parse_steam_guard_code(html_fail_color), None);

        let html_fail_text = r#"
            <html>
                <body>
                    <table>
                        <tbody>
                            <tr>
                                <td style="color: #3a9aed; font-weight: bold; text-align: center;">invalid</td>
                            </tr>
                        </tbody>
                    </table>
                </body>
            </html>
        "#;
        assert_eq!(parse_steam_guard_code(html_fail_text), None);
    }
}