Skip to main content

steam_user/services/
email.rs

1//! Email management services.
2//!
3//! This module provides functionality for:
4//! - Getting the account email address
5//! - Getting the current Steam login username
6//! - Changing the account email (multi-step wizard flow)
7
8use std::{future::Future, sync::OnceLock, time::Duration};
9
10use regex::Regex;
11use scraper::{Html, Selector};
12
13static SEL_ACCOUNT_BLOCK: OnceLock<Selector> = OnceLock::new();
14fn sel_account_block() -> &'static Selector {
15    SEL_ACCOUNT_BLOCK.get_or_init(|| Selector::parse(".account_setting_block").expect("valid CSS selector"))
16}
17
18static SEL_ACCOUNT_LABEL: OnceLock<Selector> = OnceLock::new();
19fn sel_account_label() -> &'static Selector {
20    SEL_ACCOUNT_LABEL.get_or_init(|| Selector::parse(".account_manage_label").expect("valid CSS selector"))
21}
22
23static SEL_ACCOUNT_FIELD: OnceLock<Selector> = OnceLock::new();
24fn sel_account_field() -> &'static Selector {
25    SEL_ACCOUNT_FIELD.get_or_init(|| Selector::parse(".account_data_field").expect("valid CSS selector"))
26}
27
28static SEL_CLIENT_CONN_MACHINE: OnceLock<Selector> = OnceLock::new();
29fn sel_client_conn_machine() -> &'static Selector {
30    SEL_CLIENT_CONN_MACHINE.get_or_init(|| Selector::parse(".clientConnMachineText").expect("valid CSS selector"))
31}
32
33static SEL_HELP_WIZARD_BUTTON: OnceLock<Selector> = OnceLock::new();
34fn sel_help_wizard_button() -> &'static Selector {
35    SEL_HELP_WIZARD_BUTTON.get_or_init(|| Selector::parse("a.help_wizard_button").expect("valid CSS selector"))
36}
37
38static SEL_FORGOT_LOGIN_FORM: OnceLock<Selector> = OnceLock::new();
39fn sel_forgot_login_form() -> &'static Selector {
40    SEL_FORGOT_LOGIN_FORM.get_or_init(|| Selector::parse("#forgot_login_code_form").expect("valid CSS selector"))
41}
42
43static RE_SESSION_ID: OnceLock<Regex> = OnceLock::new();
44fn re_session_id() -> &'static Regex {
45    RE_SESSION_ID.get_or_init(|| Regex::new(r#"var g_sessionID = "([^"]+)";"#).expect("valid regex"))
46}
47
48static RE_WIZARD_PARAMS: OnceLock<Regex> = OnceLock::new();
49fn re_wizard_params() -> &'static Regex {
50    RE_WIZARD_PARAMS.get_or_init(|| Regex::new(r"g_rgDefaultWizardPageParams = (\{.*?\});").expect("valid regex"))
51}
52
53use crate::{
54    client::SteamUser,
55    endpoint::{steam_endpoint, Host},
56    error::SteamUserError,
57    types::{AccountRecoveryStatus, ChangeEmailResult, ConfirmEmailResponse, SendRecoveryCodeResponse, SubmitEmailResponse, WizardDefaultParams, WizardIssue, WizardPageParams},
58};
59
60impl SteamUser {
61    /// Retrieves the email address associated with the current Steam account.
62    ///
63    /// Scrapes the account settings page at `https://store.steampowered.com/account/`.
64    ///
65    /// # Returns
66    ///
67    /// Returns the account email address as a `String`, or an empty string if
68    /// not found.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error if the request fails.
73    ///
74    /// # Example
75    ///
76    /// ```rust,no_run
77    /// # use steam_user::client::SteamUser;
78    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
79    /// let email = user.get_account_email().await?;
80    /// println!("Account email: {}", email);
81    /// # Ok(())
82    /// # }
83    /// ```
84    #[steam_endpoint(GET, host = Store, path = "/account/", kind = Read)]
85    pub async fn get_account_email(&self) -> Result<String, SteamUserError> {
86        let response = self.get_path("/account/").send().await?.text().await?;
87
88        let document = Html::parse_document(&response);
89
90        for block in document.select(sel_account_block()) {
91            // Find label and adjacent field
92            if let Some(label) = block.select(sel_account_label()).next() {
93                let label_text = label.text().collect::<String>();
94                if label_text.trim() == "Email address:" {
95                    // Look for the next sibling field
96                    if let Some(field) = block.select(sel_account_field()).next() {
97                        return Ok(field.text().collect::<String>().trim().to_string());
98                    }
99                }
100            }
101        }
102
103        Ok(String::new())
104    }
105
106    /// Retrieves the current Steam login username.
107    ///
108    /// Scrapes the games page to extract the logged-in machine text.
109    ///
110    /// # Returns
111    ///
112    /// Returns the current login username as a `String`, or an empty string if
113    /// not found.
114    ///
115    /// # Errors
116    ///
117    /// Returns an error if the request fails.
118    ///
119    /// # Example
120    ///
121    /// ```rust,no_run
122    /// # use steam_user::client::SteamUser;
123    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
124    /// let login = user.get_current_steam_login().await?;
125    /// println!("Current login: {}", login);
126    /// # Ok(())
127    /// # }
128    /// ```
129    #[steam_endpoint(GET, host = Community, path = "/my/games/", kind = Read)]
130    pub async fn get_current_steam_login(&self) -> Result<String, SteamUserError> {
131        let response = self.get_path("/my/games/?tab=all").send().await?.text().await?;
132
133        let document = Html::parse_document(&response);
134
135        if let Some(el) = document.select(sel_client_conn_machine()).next() {
136            let text = el.text().collect::<String>();
137            // Extract text before the last "|"
138            if let Some(pos) = text.rfind('|') {
139                return Ok(text[..pos].trim().to_string());
140            }
141            return Ok(text.trim().to_string());
142        }
143
144        Ok(String::new())
145    }
146
147    /// Changes the email address associated with the Steam account.
148    ///
149    /// This is a multi-step wizard flow that:
150    /// 1. Initiates the help wizard for email change
151    /// 2. Requests mobile app confirmation
152    /// 3. Sends account recovery code
153    /// 4. Accepts mobile confirmations
154    /// 5. Polls for confirmation completion
155    /// 6. Submits the new email address
156    /// 7. Confirms with OTP code from the new email
157    ///
158    /// # Arguments
159    ///
160    /// * `new_email` - The new email address to set.
161    /// * `identity_secret` - The identity secret for mobile confirmations.
162    /// * `get_email_otp` - An async function that returns OTP codes from the
163    ///   new email inbox. This will be called multiple times with increasing
164    ///   delays.
165    ///
166    /// # Returns
167    ///
168    /// Returns [`ChangeEmailResult::Success`] if the email was changed
169    /// successfully, or [`ChangeEmailResult::Error`] with an error message
170    /// if it failed.
171    ///
172    /// # Example
173    ///
174    /// ```rust,no_run
175    /// # use steam_user::client::SteamUser;
176    /// # async fn example(user: SteamUser) -> Result<(), Box<dyn std::error::Error>> {
177    /// let result = user
178    ///     .change_email(
179    ///         "new_email@example.com",
180    ///         "identity_secret_base64",
181    ///         || async {
182    ///             // Fetch OTP code from new email inbox
183    ///             // Return None if not available yet, or Some(codes) with list of codes
184    ///             Some(vec!["123456".to_string()])
185    ///         },
186    ///     )
187    ///     .await;
188    ///
189    /// match result {
190    ///     Ok(r) if r.is_success() => println!("Email changed!"),
191    ///     Ok(r) => println!("Failed: {:?}", r.error_message()),
192    ///     Err(e) => println!("Error: {}", e),
193    /// }
194    /// # Ok(())
195    /// # }
196    /// ```
197    // composite multi-step wizard — delegates to private helpers — no #[steam_endpoint]
198    #[tracing::instrument(skip(self, identity_secret, get_email_otp, new_email))]
199    pub async fn change_email<F, Fut>(&self, new_email: &str, identity_secret: &str, get_email_otp: F) -> Result<ChangeEmailResult, SteamUserError>
200    where
201        F: Fn() -> Fut,
202        Fut: Future<Output = Option<Vec<String>>>,
203    {
204        let account = self.get_miniprofile_id();
205
206        // Step 1: Get help link
207        let help_link = match self.get_email_help_link().await? {
208            Some(link) => link,
209            None => return Ok(ChangeEmailResult::Error("Can't get help link".into())),
210        };
211
212        // Step 2: Send app confirmation and get wizard params
213        let wizard_params = match self.send_email_app_confirmation(&help_link).await? {
214            Some(params) => params,
215            None => return Ok(ChangeEmailResult::Error("Can't send app confirmation".into())),
216        };
217
218        let issue = &wizard_params.issue;
219        let default_params = &wizard_params.default_params;
220
221        // Navigate to the enter code page
222        let enter_code_path = format!(
223            "/en/wizard/HelpWithLoginInfoEnterCode?s={}&account={}&reset={}&lost={}&issueid={}&wizard_ajax=1&gamepad=0",
224            urlencoding::encode(&issue.s),
225            account,
226            urlencoding::encode(&issue.reset),
227            urlencoding::encode(&issue.lost),
228            urlencoding::encode(&issue.issueid),
229        );
230        let _ = self.get_path_on(Host::Help, &enter_code_path).send().await;
231
232        // Step 3: Send account recovery code
233        if !self.send_email_recovery_code(issue, default_params, &help_link).await? {
234            return Ok(ChangeEmailResult::Error("Can't send app recovery code".into()));
235        }
236
237        // Step 4: Accept mobile confirmations
238        tokio::time::sleep(Duration::from_millis(1000)).await;
239
240        let confirmations = self.get_confirmations(identity_secret, None).await;
241        let confirmations = match confirmations {
242            Ok(c) => c,
243            Err(e) => {
244                tracing::warn!(error = %e, "change_email: first get_confirmations failed; retrying after 2s");
245                tokio::time::sleep(Duration::from_millis(2000)).await;
246                self.get_confirmations(identity_secret, None).await?
247            }
248        };
249
250        if confirmations.is_empty() {
251            return Ok(ChangeEmailResult::Error("Can't get app recovery code".into()));
252        }
253
254        for confirmation in &confirmations {
255            let creator_id = confirmation.creator.parse::<u64>().map_err(|_| SteamUserError::InvalidInput(format!("Invalid confirmation creator ID: {:?}", confirmation.creator)))?;
256            self.accept_confirmation_for_object(identity_secret, creator_id).await?;
257        }
258
259        // Step 5: Poll for confirmation completion
260        let mut checking_ok = AccountRecoveryStatus { r#continue: true, success: false, error: None };
261
262        for _ in 0..10 {
263            checking_ok = self.poll_account_recovery_confirmation(issue, default_params, &help_link).await?;
264
265            if checking_ok.r#continue {
266                tokio::time::sleep(Duration::from_millis(5000)).await;
267            } else {
268                break;
269            }
270        }
271
272        if !checking_ok.success {
273            return Ok(ChangeEmailResult::Error("Can't confirm app recovery code".into()));
274        }
275
276        // Navigate to reset page
277        let reset_path = format!(
278            "/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
279            urlencoding::encode(&issue.s),
280            account,
281            urlencoding::encode(&issue.reset),
282            urlencoding::encode(&issue.issueid),
283        );
284        let _ = self.get_path_on(Host::Help, &reset_path).send().await;
285
286        // Step 6: Submit new email
287        let submit_result = self.submit_new_email(issue, default_params, account, new_email).await?;
288
289        if !submit_result.error_msg.is_empty() {
290            return Ok(ChangeEmailResult::Error(format!("submitNewEmail Failed: {}", submit_result.error_msg)));
291        }
292
293        // Step 7: Confirm with OTP from new email
294        for _ in 0..5 {
295            if let Some(codes) = get_email_otp().await {
296                for code in codes {
297                    let confirm_result = self.confirm_new_email(issue, default_params, account, new_email, &code).await?;
298
299                    if confirm_result.hash.contains("HelpWithLoginInfoComplete") {
300                        return Ok(ChangeEmailResult::Success);
301                    }
302
303                    tokio::time::sleep(Duration::from_millis(1000)).await;
304                }
305            } else {
306                tokio::time::sleep(Duration::from_millis(5000)).await;
307            }
308        }
309
310        Ok(ChangeEmailResult::Error("Can't confirm new email code".into()))
311    }
312
313    /// Gets the help wizard link for email change.
314    #[steam_endpoint(GET, host = Help, path = "/en/wizard/HelpChangeEmail", kind = Recovery)]
315    async fn get_email_help_link(&self) -> Result<Option<String>, SteamUserError> {
316        let response = self.get_path("/en/wizard/HelpChangeEmail?redir=store/account/").send().await?.text().await?;
317
318        let document = Html::parse_document(&response);
319
320        for button in document.select(sel_help_wizard_button()) {
321            let text = button.text().collect::<String>();
322            if text.trim() == "Send a confirmation to my Steam Mobile app" {
323                return Ok(button.value().attr("href").map(|s| s.to_string()));
324            }
325        }
326
327        Ok(None)
328    }
329
330    /// Sends app confirmation and parses wizard page params.
331    // dynamic URL from help_link — no #[steam_endpoint]
332    #[tracing::instrument(skip(self, help_link))]
333    async fn send_email_app_confirmation(&self, help_link: &str) -> Result<Option<WizardPageParams>, SteamUserError> {
334        // `help_link` comes from a button `href` on the Help host and may be
335        // either an absolute URL (`https://help.steampowered.com/...`) or a
336        // relative path. Normalise to a path on Host::Help.
337        let help_path = help_link.strip_prefix("https://help.steampowered.com").or_else(|| help_link.strip_prefix("http://help.steampowered.com")).unwrap_or(help_link);
338        let response = self.get_path_on(Host::Help, help_path).send().await?.text().await?;
339
340        // Check for expected content
341        if !response.contains("For security, verify that the code in the box below matches the code we display on the confirmations page.") {
342            return Ok(None);
343        }
344
345        Ok(Self::parse_wizard_page_params(&response))
346    }
347
348    /// Parses wizard page parameters from HTML.
349    fn parse_wizard_page_params(html: &str) -> Option<WizardPageParams> {
350        let document = Html::parse_document(html);
351
352        let form = document.select(sel_forgot_login_form()).next()?;
353
354        let get_input_value = |name: &str| -> String {
355            let selector = Selector::parse(&format!("input[name=\"{}\"]", name)).expect("valid CSS selector");
356            form.select(&selector).next().and_then(|el| el.value().attr("value")).unwrap_or("").to_string()
357        };
358
359        let issue = WizardIssue {
360            s: get_input_value("s"),
361            reset: get_input_value("reset"),
362            lost: get_input_value("lost"),
363            method: get_input_value("method"),
364            issueid: get_input_value("issueid"),
365        };
366
367        // Extract g_sessionID from JavaScript
368        let session_id = re_session_id().captures(html).map(|c| c[1].to_string()).unwrap_or_default();
369
370        // Extract g_rgDefaultWizardPageParams
371        let default_params = re_wizard_params().captures(html).and_then(|c| serde_json::from_str::<WizardDefaultParams>(&c[1]).ok()).unwrap_or_default();
372
373        Some(WizardPageParams { session_id, issue, default_params })
374    }
375
376    /// Sends account recovery code request.
377    #[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxSendAccountRecoveryCode", kind = Recovery)]
378    async fn send_email_recovery_code(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, help_link: &str) -> Result<bool, SteamUserError> {
379        let params = Self::merge_params(default_params, &[("s", &issue.s), ("method", &issue.method), ("link", "")]);
380
381        let response: SendRecoveryCodeResponse = self.post_path("/en/wizard/AjaxSendAccountRecoveryCode").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", help_link).form(&params).send().await?.json().await?;
382
383        Ok(response.success)
384    }
385
386    /// Polls for account recovery confirmation status.
387    #[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxPollAccountRecoveryConfirmation", kind = Recovery)]
388    async fn poll_account_recovery_confirmation(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, help_link: &str) -> Result<AccountRecoveryStatus, SteamUserError> {
389        let params = Self::merge_params(default_params, &[("s", &issue.s), ("reset", &issue.reset), ("lost", &issue.lost), ("method", &issue.method), ("issueid", &issue.issueid)]);
390
391        let response: AccountRecoveryStatus = self.post_path("/en/wizard/AjaxPollAccountRecoveryConfirmation").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", help_link).form(&params).send().await?.json().await?;
392
393        Ok(response)
394    }
395
396    /// Submits the new email address.
397    #[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxAccountRecoveryChangeEmail/", kind = Recovery)]
398    async fn submit_new_email(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, account: u32, new_email: &str) -> Result<SubmitEmailResponse, SteamUserError> {
399        let referer = format!(
400            "https://help.steampowered.com/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
401            urlencoding::encode(&issue.s),
402            account,
403            urlencoding::encode(&issue.reset),
404            urlencoding::encode(&issue.issueid),
405        );
406
407        let account_str = account.to_string();
408        let params = Self::merge_params(default_params, &[("s", issue.s.as_str()), ("account", &account_str), ("email", new_email)]);
409
410        let response: SubmitEmailResponse = self.post_path("/en/wizard/AjaxAccountRecoveryChangeEmail/").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", &referer).form(&params).send().await?.json().await?;
411
412        Ok(response)
413    }
414
415    /// Confirms the new email with OTP code.
416    #[steam_endpoint(POST, host = Help, path = "/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/", kind = Recovery)]
417    async fn confirm_new_email(&self, issue: &WizardIssue, default_params: &WizardDefaultParams, account: u32, new_email: &str, code: &str) -> Result<ConfirmEmailResponse, SteamUserError> {
418        let referer = format!(
419            "https://help.steampowered.com/en/wizard/HelpWithLoginInfoReset/?s={}&account={}&reset={}&issueid={}",
420            urlencoding::encode(&issue.s),
421            account,
422            urlencoding::encode(&issue.reset),
423            urlencoding::encode(&issue.issueid),
424        );
425
426        let account_str = account.to_string();
427        let params = Self::merge_params(default_params, &[("s", issue.s.as_str()), ("account", &account_str), ("email", new_email), ("email_change_code", code)]);
428
429        let response: ConfirmEmailResponse = self.post_path("/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/").header("content-type", "application/x-www-form-urlencoded").header("x-requested-with", "XMLHttpRequest").header("referer", &referer).form(&params).send().await?.json().await?;
430
431        Ok(response)
432    }
433
434    /// Merges default wizard parameters with specific request parameters.
435    fn merge_params(default_params: &WizardDefaultParams, specific_params: &[(&str, &str)]) -> std::collections::HashMap<String, String> {
436        let mut map = default_params.extra.clone();
437        if let Some(acc) = default_params.account {
438            map.insert("account".to_string(), acc.to_string());
439        }
440        if let Some(wiz) = &default_params.wizard {
441            map.insert("wizard".to_string(), wiz.clone());
442        }
443        for (k, v) in specific_params {
444            map.insert(k.to_string(), v.to_string());
445        }
446        map
447    }
448
449    /// Gets the miniprofile ID (account ID) from the SteamID.
450    fn get_miniprofile_id(&self) -> u32 {
451        self.steam_id().map(|id| id.account_id).unwrap_or(0)
452    }
453}