Skip to main content

guerrillamail_client/
client.rs

1//! GuerrillaMail async client implementation.
2//!
3//! This module provides an async [`Client`] and [`ClientBuilder`] for interacting with
4//! the GuerrillaMail temporary email service.
5//!
6//! Typical flow:
7//! 1) Build a client (`Client::new` or `Client::builder().build()`)
8//! 2) Create an address via [`Client::create_email`]
9//! 3) Poll the inbox via [`Client::get_messages`]
10//! 4) Fetch full message content via [`Client::fetch_email`]
11//! 5) Optionally forget the address via [`Client::delete_email`]
12
13use crate::{Attachment, Error, Message, Result};
14use regex::Regex;
15use reqwest::{
16    header::{
17        ACCEPT, ACCEPT_LANGUAGE, CONTENT_TYPE, HOST, HeaderMap, HeaderValue, ORIGIN, REFERER,
18        USER_AGENT,
19    },
20    Url,
21};
22use std::fmt;
23use std::time::{SystemTime, UNIX_EPOCH};
24
25/// High-level async handle to a single GuerrillaMail session.
26///
27/// Conceptually, a [`Client`] owns the session state needed to talk to the public GuerrillaMail
28/// AJAX API: a cookie jar plus the `ApiToken …` header parsed from an initial bootstrap request.
29/// Every outbound request reuses prebuilt header maps that always include that token, a
30/// browser-like user agent, and the correct host/origin metadata.
31///
32/// Invariants/internal behavior:
33/// - The API token is fetched once during construction and stored as a header; it is never
34///   refreshed automatically. Rebuild the client if the token expires.
35/// - Addresses are treated as `alias@domain`; when the API only cares about the alias,
36///   the client extracts it for you.
37/// - The underlying `reqwest::Client` has cookies enabled so successive calls share the same
38///   GuerrillaMail session.
39///
40/// Typical lifecycle: create a client (`Client::new` or `Client::builder().build()`), allocate an
41/// address, poll messages, fetch message details/attachments (via [`Message`] and
42/// [`crate::EmailDetails`]), then optionally forget the address.
43///
44/// Concurrency: [`Client`] is `Clone` and cheap to duplicate; clones share the HTTP connection
45/// pool, cookies, and token header, making it safe to pass into multiple async tasks.
46///
47/// # Example
48/// ```rust,no_run
49/// # use guerrillamail_client::Client;
50/// # #[tokio::main]
51/// # async fn main() -> Result<(), guerrillamail_client::Error> {
52/// let client = Client::new().await?;
53/// let email = client.create_email("demo").await?;
54/// let messages = client.get_messages(&email).await?;
55/// println!("Inbox size: {}", messages.len());
56/// client.delete_email(&email).await?;
57/// # Ok(())
58/// # }
59/// ```
60#[derive(Clone)]
61pub struct Client {
62    http: reqwest::Client,
63    #[allow(dead_code)]
64    api_token_header: HeaderValue,
65    proxy: Option<String>,
66    user_agent: String,
67    ajax_url: Url,
68    base_url: Url,
69    ajax_headers: HeaderMap,
70    ajax_headers_no_ct: HeaderMap,
71    base_headers: HeaderMap,
72}
73
74impl fmt::Debug for Client {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.debug_struct("Client")
77            .field("http", &"<reqwest::Client>")
78            .field("api_token_header", &"<redacted>")
79            .field("proxy", &self.proxy)
80            .field("user_agent", &self.user_agent)
81            .field("ajax_url", &self.ajax_url)
82            .field("base_url", &self.base_url)
83            .finish()
84    }
85}
86
87impl Client {
88    /// Create a [`ClientBuilder`] for configuring a new client.
89    ///
90    /// Use this when you need to set a proxy, change TLS behavior, or override the user agent.
91    ///
92    /// # Examples
93    /// ```no_run
94    /// # use guerrillamail_client::Client;
95    /// # #[tokio::main]
96    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
97    /// let client = Client::builder()
98    ///     .user_agent("my-app/1.0")
99    ///     .build()
100    ///     .await?;
101    /// # Ok(())
102    /// # }
103    /// ```
104    pub fn builder() -> ClientBuilder {
105        ClientBuilder::new()
106    }
107
108    /// Build a default GuerrillaMail client.
109    ///
110    /// Performs a single bootstrap GET to the GuerrillaMail homepage, extracts the `ApiToken …`
111    /// header, and constructs a session-aware client using default headers and timeouts. The
112    /// token is not refreshed automatically; rebuild the client if it expires. Use
113    /// [`Client::builder`] when you need proxy/TLS overrides.
114    ///
115    /// # Errors
116    /// - Returns `Error::Request` on bootstrap network failures or any non-2xx response (via `error_for_status`).
117    /// - Returns `Error::TokenParse` when the API token cannot be extracted from the homepage HTML.
118    /// - Returns `Error::HeaderValue` if the parsed token cannot be encoded into a header.
119    ///
120    /// # Examples
121    /// ```no_run
122    /// # use guerrillamail_client::Client;
123    /// # #[tokio::main]
124    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
125    /// let client = Client::new().await?;
126    /// # Ok(())
127    /// # }
128    /// ```
129    pub async fn new() -> Result<Self> {
130        ClientBuilder::new().build().await
131    }
132
133    /// Get the proxy URL configured for this client (if any).
134    ///
135    /// Returns `None` when no proxy was set on the builder.
136    pub fn proxy(&self) -> Option<&str> {
137        self.proxy.as_deref()
138    }
139
140    /// Request a new temporary address for the given alias.
141    ///
142    /// Sends a POST to the GuerrillaMail AJAX endpoint, asking the service to reserve the supplied
143    /// alias and return the full `alias@domain` address. Builds required headers and includes the
144    /// session token automatically.
145    ///
146    /// # Arguments
147    /// - `alias`: Desired local-part before `@`.
148    ///
149    /// # Returns
150    /// The full email address assigned by GuerrillaMail (e.g., `myalias@sharklasers.com`).
151    ///
152    /// # Errors
153    /// - Returns `Error::Request` for network failures or non-2xx responses.
154    /// - Returns `Error::ResponseParse` if the JSON body lacks a string `email_addr` field.
155    /// Network failures are typically transient; parse errors usually indicate an API schema change.
156    ///
157    /// # Network
158    /// Issues one POST request to `ajax.php`.
159    ///
160    /// # Examples
161    /// ```no_run
162    /// # use guerrillamail_client::Client;
163    /// # #[tokio::main]
164    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
165    /// let client = Client::new().await?;
166    /// let email = client.create_email("myalias").await?;
167    /// println!("{email}");
168    /// # Ok(())
169    /// # }
170    /// ```
171    pub async fn create_email(&self, alias: &str) -> Result<String> {
172        let params = [("f", "set_email_user")];
173        let form = [
174            ("email_user", alias),
175            ("lang", "en"),
176            ("site", "guerrillamail.com"),
177            ("in", " Set cancel"),
178        ];
179
180        let response: serde_json::Value = self
181            .http
182            .post(self.ajax_url.as_str())
183            .query(&params)
184            .form(&form)
185            .headers(self.ajax_headers())
186            .send()
187            .await?
188            .error_for_status()?
189            .json()
190            .await?;
191
192        let email_addr = response
193            .get("email_addr")
194            .and_then(|v| v.as_str())
195            .ok_or(Error::ResponseParse("missing or non-string `email_addr`"))?;
196
197        Ok(email_addr.to_string())
198    }
199
200    /// Fetch the current inbox listing for an address.
201    ///
202    /// Calls the `check_email` AJAX function using only the alias portion of the provided address.
203    /// Includes cache-busting timestamp and required headers; parses the `list` array into
204    /// [`Message`] structs.
205    ///
206    /// # Arguments
207    /// - `email`: Full address (alias is extracted automatically).
208    ///
209    /// # Returns
210    /// Vector of message headers/summaries currently in the inbox.
211    ///
212    /// # Errors
213    /// - Returns `Error::Request` for network failures or non-2xx responses.
214    /// - Returns `Error::ResponseParse` when the JSON body is missing a `list` array.
215    /// - Returns `Error::Json` if individual messages fail to deserialize.
216    /// Network issues are transient; parse/deserialize errors generally indicate a schema change.
217    ///
218    /// # Network
219    /// Issues one GET request to `ajax.php` with query parameters.
220    ///
221    /// # Examples
222    /// ```no_run
223    /// # use guerrillamail_client::Client;
224    /// # #[tokio::main]
225    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
226    /// let client = Client::new().await?;
227    /// let email = client.create_email("myalias").await?;
228    /// let messages = client.get_messages(&email).await?;
229    /// for msg in messages {
230    ///     println!("{}: {}", msg.mail_from, msg.mail_subject);
231    /// }
232    /// # Ok(())
233    /// # }
234    /// ```
235    pub async fn get_messages(&self, email: &str) -> Result<Vec<Message>> {
236        let response = self.get_api("check_email", email, None).await?;
237
238        let list = response
239            .get("list")
240            .and_then(|v| v.as_array())
241            .ok_or(Error::ResponseParse("missing or non-array `list`"))?;
242
243        let messages = list
244            .iter()
245            .map(|v| serde_json::from_value::<Message>(v.clone()).map_err(Into::into))
246            .collect::<Result<Vec<_>>>()?;
247
248        Ok(messages)
249    }
250
251    /// Fetch full contents for a message.
252    ///
253    /// Calls the `fetch_email` AJAX function using the alias derived from the address and the
254    /// provided `mail_id`, then deserializes the full message metadata and body.
255    ///
256    /// # Arguments
257    /// - `email`: Full address associated with the message.
258    /// - `mail_id`: Identifier obtained from [`get_messages`](Client::get_messages).
259    ///
260    /// # Returns
261    /// [`crate::EmailDetails`] containing body, metadata, attachments, and optional `sid_token`.
262    ///
263    /// # Errors
264    /// - Returns `Error::Request` for network failures or non-2xx responses.
265    /// - Returns `Error::Json` if the response body cannot be deserialized into `EmailDetails`.
266    /// Network issues are transient; deserialization errors suggest a changed API response.
267    ///
268    /// # Network
269    /// Issues one GET request to `ajax.php`.
270    ///
271    /// # Examples
272    /// ```no_run
273    /// # use guerrillamail_client::Client;
274    /// # #[tokio::main]
275    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
276    /// let client = Client::new().await?;
277    /// let email = client.create_email("myalias").await?;
278    /// let messages = client.get_messages(&email).await?;
279    /// if let Some(msg) = messages.first() {
280    ///     let details = client.fetch_email(&email, &msg.mail_id).await?;
281    ///     println!("{}", details.mail_body);
282    /// }
283    /// # Ok(())
284    /// # }
285    /// ```
286    pub async fn fetch_email(&self, email: &str, mail_id: &str) -> Result<crate::EmailDetails> {
287        let raw = self.get_api_text("fetch_email", email, Some(mail_id)).await?;
288
289        let details = serde_json::from_str::<crate::EmailDetails>(&raw)?;
290        Ok(details)
291    }
292
293    /// List attachment metadata for a message.
294    ///
295    /// Convenience wrapper over [`fetch_email`](Client::fetch_email) that extracts the attachment
296    /// list from the returned details.
297    ///
298    /// # Errors
299    /// - Propagates any `Error::Request` or parsing errors from [`fetch_email`](Self::fetch_email).
300    /// Transient network issues bubble up unchanged; parse errors imply the upstream response shape shifted.
301    pub async fn list_attachments(
302        &self,
303        email: &str,
304        mail_id: &str,
305    ) -> Result<Vec<Attachment>> {
306        let details = self.fetch_email(email, mail_id).await?;
307        Ok(details.attachments)
308    }
309
310    /// Download an attachment for a message.
311    ///
312    /// Performs a GET to the inbox download endpoint, including any `sid_token` previously
313    /// returned by `fetch_email`. Requires a non-empty `part_id` on the attachment and the
314    /// originating `mail_id`.
315    ///
316    /// # Arguments
317    /// - `email`: Full address used to derive the alias for token-related calls.
318    /// - `mail_id`: Message id whose attachment is being fetched.
319    /// - `attachment`: Attachment metadata containing the part id to retrieve.
320    ///
321    /// # Returns
322    /// Raw bytes of the attachment body.
323    ///
324    /// # Errors
325    /// - Returns `Error::ResponseParse` if `part_id` or `mail_id` are empty.
326    /// - Returns `Error::Request` for network failures or non-2xx download responses (via `error_for_status`).
327    /// Empty identifiers are permanent until corrected; network and status errors are transient.
328    ///
329    /// # Network
330    /// Issues one GET request to the inbox download endpoint (typically `/inbox`).
331    ///
332    /// # Examples
333    /// ```no_run
334    /// # use guerrillamail_client::Client;
335    /// # #[tokio::main]
336    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
337    /// let client = Client::new().await?;
338    /// let email = client.create_email("myalias").await?;
339    /// let messages = client.get_messages(&email).await?;
340    /// if let Some(msg) = messages.first() {
341    ///     let attachments = client.list_attachments(&email, &msg.mail_id).await?;
342    ///     if let Some(attachment) = attachments.first() {
343    ///         let bytes = client.fetch_attachment(&email, &msg.mail_id, attachment).await?;
344    ///         println!("Downloaded {} bytes", bytes.len());
345    ///     }
346    /// }
347    /// # Ok(())
348    /// # }
349    /// ```
350    pub async fn fetch_attachment(
351        &self,
352        email: &str,
353        mail_id: &str,
354        attachment: &Attachment,
355    ) -> Result<Vec<u8>> {
356        if attachment.part_id.trim().is_empty() {
357            return Err(Error::ResponseParse("attachment missing part_id"));
358        }
359        if mail_id.trim().is_empty() {
360            return Err(Error::ResponseParse("missing mail_id for attachment download"));
361        }
362
363        let details = self.fetch_email(email, mail_id).await?;
364        let inbox_url = self.inbox_url();
365
366        let mut query = vec![
367            ("get_att", "".to_string()),
368            ("lang", "en".to_string()),
369            ("email_id", mail_id.to_string()),
370            ("part_id", attachment.part_id.clone()),
371        ];
372
373        if let Some(token) = details.sid_token.as_deref() {
374            if !token.is_empty() {
375                query.push(("sid_token", token.to_string()));
376            }
377        }
378
379        let response = self
380            .http
381            .get(&inbox_url)
382            .query(&query)
383            .headers(self.base_headers())
384            .send()
385            .await?
386            .error_for_status()?;
387
388        let bytes = response.bytes().await?;
389        Ok(bytes.to_vec())
390    }
391
392    /// Ask GuerrillaMail to forget an address for this session.
393    ///
394    /// Calls the `forget_me` AJAX function using the alias extracted from the provided address.
395    /// Only affects the current session; it does not guarantee global deletion of the address.
396    ///
397    /// # Arguments
398    /// - `email`: Full address to remove from the session.
399    ///
400    /// # Returns
401    /// `true` when the HTTP response status is 2xx.
402    ///
403    /// # Errors
404    /// - Returns `Error::Request` for network failures or non-2xx responses from the `forget_me` call.
405    /// Network/non-2xx failures are transient; repeated failures may indicate the service endpoint changed.
406    ///
407    /// # Network
408    /// Issues one POST request to `ajax.php`.
409    ///
410    /// # Examples
411    /// ```no_run
412    /// # use guerrillamail_client::Client;
413    /// # #[tokio::main]
414    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
415    /// let client = Client::new().await?;
416    /// let email = client.create_email("myalias").await?;
417    /// let ok = client.delete_email(&email).await?;
418    /// println!("{ok}");
419    /// # Ok(())
420    /// # }
421    /// ```
422    pub async fn delete_email(&self, email: &str) -> Result<bool> {
423        let alias = Self::extract_alias(email);
424        let params = [("f", "forget_me")];
425        let form = [("site", "guerrillamail.com"), ("in", alias)];
426
427        let response = self
428            .http
429            .post(self.ajax_url.as_str())
430            .query(&params)
431            .form(&form)
432            .headers(self.ajax_headers())
433            .send()
434            .await?
435            .error_for_status()?;
436
437        Ok(response.status().is_success())
438    }
439
440    /// Perform a common GuerrillaMail AJAX API call and return the raw JSON value.
441    ///
442    /// This helper centralizes request construction for endpoints such as `check_email` and
443    /// `fetch_email`. It injects a cache-busting timestamp parameter and ensures the correct
444    /// authorization header is set.
445    ///
446    /// # Arguments
447    /// * `function` - The GuerrillaMail function name (e.g. `"check_email"`).
448    /// * `email` - Full email address (alias will be extracted).
449    /// * `email_id` - Optional message id parameter for endpoints that require it.
450    ///
451    /// # Errors
452    /// Returns an error if the request fails, the server returns a non-success status,
453    /// or the body cannot be parsed as JSON.
454    async fn get_api(
455        &self,
456        function: &str,
457        email: &str,
458        email_id: Option<&str>,
459    ) -> Result<serde_json::Value> {
460        let params = self.api_params(function, email, email_id);
461
462        let headers = self.ajax_headers_no_ct();
463
464        let response: serde_json::Value = self
465            .http
466            .get(self.ajax_url.as_str())
467            .query(&params)
468            .headers(headers)
469            .send()
470            .await?
471            .error_for_status()?
472            .json()
473            .await?;
474
475        Ok(response)
476    }
477
478    async fn get_api_text(
479        &self,
480        function: &str,
481        email: &str,
482        email_id: Option<&str>,
483    ) -> Result<String> {
484        let params = self.api_params(function, email, email_id);
485
486        let headers = self.ajax_headers_no_ct();
487
488        let response = self
489            .http
490            .get(self.ajax_url.as_str())
491            .query(&params)
492            .headers(headers)
493            .send()
494            .await?
495            .error_for_status()?
496            .text()
497            .await?;
498
499        Ok(response)
500    }
501
502    /// Extract the alias (local-part) from a full email address.
503    ///
504    /// If the string does not contain `@`, the full input is returned unchanged.
505    fn extract_alias(email: &str) -> &str {
506        email.split('@').next().unwrap_or(email)
507    }
508
509    fn api_params(
510        &self,
511        function: &str,
512        email: &str,
513        email_id: Option<&str>,
514    ) -> Vec<(&str, String)> {
515        let alias = Self::extract_alias(email);
516        let timestamp = Self::timestamp();
517
518        let mut params = vec![
519            ("f", function.to_string()),
520            ("site", "guerrillamail.com".to_string()),
521            ("in", alias.to_string()),
522            ("_", timestamp),
523        ];
524
525        if let Some(id) = email_id {
526            params.insert(1, ("email_id", id.to_string()));
527        }
528
529        if function == "check_email" {
530            params.insert(1, ("seq", "1".to_string()));
531        }
532
533        params
534    }
535
536    fn inbox_url(&self) -> String {
537        self.base_url
538            .join("inbox")
539            .expect("constructing inbox URL should not fail")
540            .into()
541    }
542
543    /// Generate a millisecond timestamp suitable for cache-busting query parameters.
544    ///
545    /// # Panics
546    ///
547    /// Panics if the system clock is before the Unix epoch. This indicates a
548    /// misconfigured or broken system clock and is treated as a fatal error.
549    fn timestamp() -> String {
550        SystemTime::now()
551            .duration_since(UNIX_EPOCH)
552            .expect("system clock is before UNIX_EPOCH")
553            .as_millis()
554            .to_string()
555    }
556
557    fn ajax_headers(&self) -> HeaderMap {
558        self.ajax_headers.clone()
559    }
560
561    fn ajax_headers_no_ct(&self) -> HeaderMap {
562        self.ajax_headers_no_ct.clone()
563    }
564
565    fn base_headers(&self) -> HeaderMap {
566        self.base_headers.clone()
567    }
568}
569
570fn build_headers(
571    url: &Url,
572    user_agent: &str,
573    api_token_header: &HeaderValue,
574    include_content_type: bool,
575) -> Result<HeaderMap> {
576    let host = url
577        .host_str()
578        .ok_or(Error::ResponseParse("missing host in URL"))?;
579    let host_port = match url.port() {
580        Some(port) => format!("{host}:{port}"),
581        None => host.to_string(),
582    };
583    let origin = format!("{}://{}", url.scheme(), host_port);
584    let referer = format!("{origin}/");
585
586    let mut headers = HeaderMap::new();
587    headers.insert(
588        HOST,
589        HeaderValue::from_str(&host_port).map_err(Error::HeaderValue)?,
590    );
591    let user_agent = HeaderValue::from_str(user_agent).map_err(Error::HeaderValue)?;
592    headers.insert(USER_AGENT, user_agent);
593    headers.insert(
594        ACCEPT,
595        HeaderValue::from_static("application/json, text/javascript, */*; q=0.01"),
596    );
597    headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.5"));
598    if include_content_type {
599        headers.insert(
600            CONTENT_TYPE,
601            HeaderValue::from_static("application/x-www-form-urlencoded; charset=UTF-8"),
602        );
603    }
604    headers.insert("Authorization", api_token_header.clone());
605    headers.insert(
606        "X-Requested-With",
607        HeaderValue::from_static("XMLHttpRequest"),
608    );
609    headers.insert(ORIGIN, HeaderValue::from_str(&origin).map_err(Error::HeaderValue)?);
610    headers.insert(REFERER, HeaderValue::from_str(&referer).map_err(Error::HeaderValue)?);
611    headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("empty"));
612    headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("cors"));
613    headers.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));
614    headers.insert("Priority", HeaderValue::from_static("u=0"));
615    Ok(headers)
616}
617
618const BASE_URL: &str = "https://www.guerrillamail.com";
619const AJAX_URL: &str = "https://www.guerrillamail.com/ajax.php";
620const USER_AGENT_VALUE: &str =
621    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0";
622
623/// Configures and bootstraps a GuerrillaMail [`Client`].
624///
625/// Conceptually, [`ClientBuilder`] holds request-layer options (proxy, TLS leniency, user agent,
626/// endpoints, timeout). Calling [`build`](ClientBuilder::build) creates a `reqwest::Client` with
627/// cookie storage enabled, fetches the GuerrillaMail homepage once, and captures the `ApiToken …`
628/// header needed for all later AJAX calls.
629///
630/// Invariants/internal behavior:
631/// - The bootstrap fetch happens exactly once during `build`; the resulting token is baked into the
632///   constructed [`Client`].
633/// - Defaults favor easy testing: no proxy, `danger_accept_invalid_certs = true`, browser-like
634///   user agent, 30s timeout, and the public GuerrillaMail endpoints.
635/// - `Clone` is cheap and copies configuration only; it does not perform additional network I/O.
636///
637/// Typical lifecycle: start with [`Client::builder`], adjust options, call `build`, then discard
638/// the builder. Reuse the built [`Client`] (or its cheap clones) across tasks.
639///
640/// # Example
641/// ```rust,no_run
642/// # use guerrillamail_client::Client;
643/// # #[tokio::main]
644/// # async fn main() -> Result<(), guerrillamail_client::Error> {
645/// let client = Client::builder()
646///     .proxy("http://127.0.0.1:8080")
647///     .danger_accept_invalid_certs(false)
648///     .user_agent("my-app/2.0")
649///     .build()
650///     .await?;
651/// # Ok(())
652/// # }
653/// ```
654#[derive(Debug, Clone)]
655pub struct ClientBuilder {
656    proxy: Option<String>,
657    danger_accept_invalid_certs: bool,
658    user_agent: String,
659    ajax_url: String,
660    base_url: String,
661    timeout: std::time::Duration,
662}
663
664impl Default for ClientBuilder {
665    fn default() -> Self {
666        Self::new()
667    }
668}
669
670impl ClientBuilder {
671    /// Create a new builder with default settings.
672    ///
673    /// See [`ClientBuilder`] for the list of defaults.
674    pub fn new() -> Self {
675        Self {
676            proxy: None,
677            danger_accept_invalid_certs: true,
678            user_agent: USER_AGENT_VALUE.to_string(),
679            ajax_url: AJAX_URL.to_string(),
680            base_url: BASE_URL.to_string(),
681            // Keep requests from hanging indefinitely; 30s is a conservative, service-friendly default.
682            timeout: std::time::Duration::from_secs(30),
683        }
684    }
685
686    /// Set a proxy URL (e.g. `"http://127.0.0.1:8080"`).
687    ///
688    /// The proxy is applied to all requests performed by the underlying `reqwest::Client`.
689    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
690        self.proxy = Some(proxy.into());
691        self
692    }
693
694    /// Configure whether to accept invalid TLS certificates (default: `true`).
695    ///
696    /// Set this to `false` for stricter TLS verification.
697    ///
698    /// # Security
699    /// Accepting invalid certificates is unsafe on untrusted networks; it is primarily useful
700    /// for debugging or traffic inspection in controlled environments.
701    pub fn danger_accept_invalid_certs(mut self, value: bool) -> Self {
702        self.danger_accept_invalid_certs = value;
703        self
704    }
705
706    /// Override the default user agent string.
707    ///
708    /// GuerrillaMail may apply different behavior based on the UA; the default is a
709    /// browser-like value.
710    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
711        self.user_agent = user_agent.into();
712        self
713    }
714
715    /// Override the GuerrillaMail AJAX endpoint URL.
716    ///
717    /// This is primarily useful for testing or if GuerrillaMail changes its endpoint.
718    pub fn ajax_url(mut self, ajax_url: impl Into<String>) -> Self {
719        self.ajax_url = ajax_url.into();
720        self
721    }
722
723    /// Override the GuerrillaMail base URL.
724    ///
725    /// This is primarily useful for testing.
726    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
727        self.base_url = base_url.into();
728        self
729    }
730
731    /// Override the default request timeout.
732    ///
733    /// The timeout applies to the whole request (connect + read), matching
734    /// [`reqwest::ClientBuilder::timeout`]. Defaults to 30 seconds.
735    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
736        self.timeout = timeout;
737        self
738    }
739
740    /// Build the [`Client`] by performing the GuerrillaMail bootstrap request.
741    ///
742    /// Constructs a `reqwest::Client` with cookie storage, applies the configured proxy/TLS/user
743    /// agent/timeouts, sends one GET to the GuerrillaMail homepage, and extracts the `ApiToken …`
744    /// header required for later AJAX calls.
745    ///
746    /// # Errors
747    /// - Returns `Error::Request` for HTTP client build issues, bootstrap network failures, or non-2xx responses.
748    /// - Returns `Error::TokenParse` when the API token cannot be found in the bootstrap HTML.
749    /// - Returns `Error::HeaderValue` if the token cannot be encoded into the authorization header.
750    /// Network-related failures are transient; token/header errors likely indicate a page layout change.
751    ///
752    /// # Network
753    /// Issues one GET request to the configured `base_url`.
754    ///
755    /// # Examples
756    /// ```no_run
757    /// # use guerrillamail_client::Client;
758    /// # #[tokio::main]
759    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
760    /// let client = Client::builder()
761    ///     .user_agent("my-app/1.0")
762    ///     .build()
763    ///     .await?;
764    /// # Ok(())
765    /// # }
766    /// ```
767    pub async fn build(self) -> Result<Client> {
768        let mut builder = reqwest::Client::builder()
769            .danger_accept_invalid_certs(self.danger_accept_invalid_certs)
770            .timeout(self.timeout);
771
772        if let Some(proxy_url) = &self.proxy {
773            builder = builder.proxy(reqwest::Proxy::all(proxy_url)?);
774        }
775
776        // Parse configured URLs early to fail fast on invalid input.
777        let base_url = Url::parse(&self.base_url)
778            .map_err(|_| Error::ResponseParse("invalid base_url"))?;
779        let ajax_url = Url::parse(&self.ajax_url)
780            .map_err(|_| Error::ResponseParse("invalid ajax_url"))?;
781
782        // Enable cookie store to persist session between requests.
783        let http = builder.cookie_store(true).build()?;
784
785        // Fetch the main page to get API token.
786        let response = http.get(base_url.as_str()).send().await?.text().await?;
787
788        // Parse API token: api_token : 'xxxxxxxx'
789        let token_re = Regex::new(r"api_token\s*:\s*'([^']+)'")?;
790        let api_token = token_re
791            .captures(&response)
792            .and_then(|c| c.get(1))
793            .map(|m| m.as_str().to_string())
794            .ok_or(Error::TokenParse)?;
795        let api_token_header = HeaderValue::from_str(&format!("ApiToken {}", api_token))?;
796
797        let ajax_headers =
798            build_headers(&ajax_url, &self.user_agent, &api_token_header, true)?;
799        let ajax_headers_no_ct =
800            build_headers(&ajax_url, &self.user_agent, &api_token_header, false)?;
801        let base_headers =
802            build_headers(&base_url, &self.user_agent, &api_token_header, true)?;
803
804        Ok(Client {
805            http,
806            api_token_header,
807            proxy: self.proxy,
808            user_agent: self.user_agent,
809            ajax_url,
810            base_url,
811            ajax_headers,
812            ajax_headers_no_ct,
813            base_headers,
814        })
815    }
816}
817
818#[cfg(test)]
819impl Client {
820    fn new_for_tests(base_url: String, ajax_url: String) -> Self {
821        let http = reqwest::Client::builder()
822            .cookie_store(true)
823            .build()
824            .expect("test client build failed");
825        let api_token_header = HeaderValue::from_static("ApiToken test");
826        let base_url = Url::parse(&base_url).expect("invalid base_url in test");
827        let ajax_url = Url::parse(&ajax_url).expect("invalid ajax_url in test");
828        let ajax_headers =
829            build_headers(&ajax_url, USER_AGENT_VALUE, &api_token_header, true).expect("ajax headers");
830        let ajax_headers_no_ct =
831            build_headers(&ajax_url, USER_AGENT_VALUE, &api_token_header, false).expect("ajax headers no ct");
832        let base_headers =
833            build_headers(&base_url, USER_AGENT_VALUE, &api_token_header, true).expect("base headers");
834        Self {
835            http,
836            api_token_header,
837            proxy: None,
838            user_agent: USER_AGENT_VALUE.to_string(),
839            ajax_url,
840            base_url,
841            ajax_headers,
842            ajax_headers_no_ct,
843            base_headers,
844        }
845    }
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use httpmock::Method::{GET, POST};
852    use httpmock::MockServer;
853    use serde_json::json;
854
855    #[tokio::test]
856    async fn fetch_attachment_builds_request_and_returns_bytes() {
857        let server = MockServer::start();
858        let base_url = server.base_url();
859
860        let fetch_email_mock = server.mock(|when, then| {
861            when.method(GET)
862                .path("/ajax.php")
863                .query_param("f", "fetch_email")
864                .query_param("email_id", "123");
865            then.status(200).json_body(json!({
866                "mail_id": "123",
867                "mail_from": "sender@example.com",
868                "mail_subject": "Subject",
869                "mail_body": "<p>Body</p>",
870                "mail_timestamp": "1700000000",
871                "att": 1,
872                "att_info": [{ "f": "file.txt", "t": "text/plain", "p": "99" }],
873                "sid_token": "sid123"
874            }));
875        });
876
877        let attachment_mock = server.mock(|when, then| {
878            when.method(GET)
879                .path("/inbox")
880                .query_param("get_att", "")
881                .query_param("lang", "en")
882                .query_param("email_id", "123")
883                .query_param("part_id", "99")
884                .query_param("sid_token", "sid123");
885            then.status(200).body("hello");
886        });
887
888        let client = Client::new_for_tests(
889            base_url.clone(),
890            format!("{base_url}/ajax.php"),
891        );
892
893        let attachment = Attachment {
894            filename: "file.txt".to_string(),
895            content_type_or_hint: Some("text/plain".to_string()),
896            part_id: "99".to_string(),
897        };
898
899        let bytes = client
900            .fetch_attachment("alias@example.com", "123", &attachment)
901            .await
902            .unwrap();
903
904        assert_eq!(bytes, b"hello");
905        fetch_email_mock.assert();
906        attachment_mock.assert();
907    }
908
909    #[tokio::test]
910    async fn delete_email_returns_true_on_success() {
911        let server = MockServer::start();
912        let base_url = server.base_url();
913
914        let delete_mock = server.mock(|when, then| {
915            when.method(POST)
916                .path("/ajax.php")
917                .query_param("f", "forget_me");
918            then.status(204);
919        });
920
921        let client = Client::new_for_tests(
922            base_url.clone(),
923            format!("{base_url}/ajax.php"),
924        );
925
926        let ok = client.delete_email("alias@example.com").await.unwrap();
927
928        assert!(ok);
929        delete_mock.assert();
930    }
931
932    #[tokio::test]
933    async fn delete_email_errors_on_non_success_status() {
934        let server = MockServer::start();
935        let base_url = server.base_url();
936
937        let delete_mock = server.mock(|when, then| {
938            when.method(POST)
939                .path("/ajax.php")
940                .query_param("f", "forget_me");
941            then.status(500);
942        });
943
944        let client = Client::new_for_tests(
945            base_url.clone(),
946            format!("{base_url}/ajax.php"),
947        );
948
949        let err = client.delete_email("alias@example.com").await.unwrap_err();
950
951        assert!(matches!(err, Error::Request(_)));
952        delete_mock.assert();
953    }
954
955    #[test]
956    fn client_is_clone() {
957        let base_url = "https://example.com";
958        let client = Client::new_for_tests(
959            base_url.to_string(),
960            format!("{base_url}/ajax.php"),
961        );
962
963        let cloned = client.clone();
964
965        assert_eq!(client.proxy, cloned.proxy);
966        assert_eq!(client.user_agent, cloned.user_agent);
967        assert_eq!(client.ajax_url, cloned.ajax_url);
968        assert_eq!(client.base_url, cloned.base_url);
969    }
970
971    #[test]
972    fn token_regex_accepts_broad_characters() {
973        let token_re = Regex::new(r"api_token\s*:\s*'([^']+)'").unwrap();
974        let sample = "const data = { api_token : 'abc-123.def:ghi' };";
975        let caps = token_re.captures(sample).expect("should match");
976        assert_eq!(caps.get(1).unwrap().as_str(), "abc-123.def:ghi");
977    }
978}