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
360        let details = self.fetch_email(email, mail_id).await?;
361        let inbox_url = self.inbox_url();
362
363        let mut query = vec![
364            ("get_att", "".to_string()),
365            ("lang", "en".to_string()),
366            ("email_id", mail_id.to_string()),
367            ("part_id", attachment.part_id.clone()),
368        ];
369
370        if let Some(token) = details.sid_token.as_deref() {
371            if !token.is_empty() {
372                query.push(("sid_token", token.to_string()));
373            }
374        }
375
376        let response = self
377            .http
378            .get(&inbox_url)
379            .query(&query)
380            .headers(self.base_headers())
381            .send()
382            .await?
383            .error_for_status()?;
384
385        let bytes = response.bytes().await?;
386        Ok(bytes.to_vec())
387    }
388
389    /// Ask GuerrillaMail to forget an address for this session.
390    ///
391    /// Calls the `forget_me` AJAX function using the alias extracted from the provided address.
392    /// Only affects the current session; it does not guarantee global deletion of the address.
393    ///
394    /// # Arguments
395    /// - `email`: Full address to remove from the session.
396    ///
397    /// # Returns
398    /// `true` when the HTTP response status is 2xx.
399    ///
400    /// # Errors
401    /// - Returns `Error::Request` for network failures or non-2xx responses from the `forget_me` call.
402    /// Network/non-2xx failures are transient; repeated failures may indicate the service endpoint changed.
403    ///
404    /// # Network
405    /// Issues one POST request to `ajax.php`.
406    ///
407    /// # Examples
408    /// ```no_run
409    /// # use guerrillamail_client::Client;
410    /// # #[tokio::main]
411    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
412    /// let client = Client::new().await?;
413    /// let email = client.create_email("myalias").await?;
414    /// let ok = client.delete_email(&email).await?;
415    /// println!("{ok}");
416    /// # Ok(())
417    /// # }
418    /// ```
419    pub async fn delete_email(&self, email: &str) -> Result<bool> {
420        let alias = Self::extract_alias(email);
421        let params = [("f", "forget_me")];
422        let form = [("site", "guerrillamail.com"), ("in", alias)];
423
424        let response = self
425            .http
426            .post(self.ajax_url.as_str())
427            .query(&params)
428            .form(&form)
429            .headers(self.ajax_headers())
430            .send()
431            .await?
432            .error_for_status()?;
433
434        Ok(response.status().is_success())
435    }
436
437    /// Perform a common GuerrillaMail AJAX API call and return the raw JSON value.
438    ///
439    /// This helper centralizes request construction for endpoints such as `check_email` and
440    /// `fetch_email`. It injects a cache-busting timestamp parameter and ensures the correct
441    /// authorization header is set.
442    ///
443    /// # Arguments
444    /// * `function` - The GuerrillaMail function name (e.g. `"check_email"`).
445    /// * `email` - Full email address (alias will be extracted).
446    /// * `email_id` - Optional message id parameter for endpoints that require it.
447    ///
448    /// # Errors
449    /// Returns an error if the request fails, the server returns a non-success status,
450    /// or the body cannot be parsed as JSON.
451    async fn get_api(
452        &self,
453        function: &str,
454        email: &str,
455        email_id: Option<&str>,
456    ) -> Result<serde_json::Value> {
457        let params = self.api_params(function, email, email_id);
458
459        let headers = self.ajax_headers_no_ct();
460
461        let response: serde_json::Value = self
462            .http
463            .get(self.ajax_url.as_str())
464            .query(&params)
465            .headers(headers)
466            .send()
467            .await?
468            .error_for_status()?
469            .json()
470            .await?;
471
472        Ok(response)
473    }
474
475    async fn get_api_text(
476        &self,
477        function: &str,
478        email: &str,
479        email_id: Option<&str>,
480    ) -> Result<String> {
481        let params = self.api_params(function, email, email_id);
482
483        let headers = self.ajax_headers_no_ct();
484
485        let response = self
486            .http
487            .get(self.ajax_url.as_str())
488            .query(&params)
489            .headers(headers)
490            .send()
491            .await?
492            .error_for_status()?
493            .text()
494            .await?;
495
496        Ok(response)
497    }
498
499    /// Extract the alias (local-part) from a full email address.
500    ///
501    /// If the string does not contain `@`, the full input is returned unchanged.
502    fn extract_alias(email: &str) -> &str {
503        email.split('@').next().unwrap_or(email)
504    }
505
506    fn api_params(
507        &self,
508        function: &str,
509        email: &str,
510        email_id: Option<&str>,
511    ) -> Vec<(&str, String)> {
512        let alias = Self::extract_alias(email);
513        let timestamp = Self::timestamp();
514
515        let mut params = vec![
516            ("f", function.to_string()),
517            ("site", "guerrillamail.com".to_string()),
518            ("in", alias.to_string()),
519            ("_", timestamp),
520        ];
521
522        if let Some(id) = email_id {
523            params.insert(1, ("email_id", id.to_string()));
524        }
525
526        if function == "check_email" {
527            params.insert(1, ("seq", "1".to_string()));
528        }
529
530        params
531    }
532
533    fn inbox_url(&self) -> String {
534        self.base_url
535            .join("inbox")
536            .expect("constructing inbox URL should not fail")
537            .into()
538    }
539
540    /// Generate a millisecond timestamp suitable for cache-busting query parameters.
541    ///
542    /// # Panics
543    ///
544    /// Panics if the system clock is before the Unix epoch. This indicates a
545    /// misconfigured or broken system clock and is treated as a fatal error.
546    fn timestamp() -> String {
547        SystemTime::now()
548            .duration_since(UNIX_EPOCH)
549            .expect("system clock is before UNIX_EPOCH")
550            .as_millis()
551            .to_string()
552    }
553
554    fn ajax_headers(&self) -> HeaderMap {
555        self.ajax_headers.clone()
556    }
557
558    fn ajax_headers_no_ct(&self) -> HeaderMap {
559        self.ajax_headers_no_ct.clone()
560    }
561
562    fn base_headers(&self) -> HeaderMap {
563        self.base_headers.clone()
564    }
565}
566
567fn build_headers(
568    url: &Url,
569    user_agent: &str,
570    api_token_header: &HeaderValue,
571    include_content_type: bool,
572) -> Result<HeaderMap> {
573    let host = url.host_str().expect("validated url missing host");
574    let host_port = match url.port() {
575        Some(port) => format!("{host}:{port}"),
576        None => host.to_string(),
577    };
578    let origin = format!("{}://{}", url.scheme(), host_port);
579    let referer = format!("{origin}/");
580
581    let mut headers = HeaderMap::new();
582    headers.insert(
583        HOST,
584        HeaderValue::from_str(&host_port).map_err(Error::HeaderValue)?,
585    );
586    let user_agent = HeaderValue::from_str(user_agent).map_err(Error::HeaderValue)?;
587    headers.insert(USER_AGENT, user_agent);
588    headers.insert(
589        ACCEPT,
590        HeaderValue::from_static("application/json, text/javascript, */*; q=0.01"),
591    );
592    headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.5"));
593    if include_content_type {
594        headers.insert(
595            CONTENT_TYPE,
596            HeaderValue::from_static("application/x-www-form-urlencoded; charset=UTF-8"),
597        );
598    }
599    headers.insert("Authorization", api_token_header.clone());
600    headers.insert(
601        "X-Requested-With",
602        HeaderValue::from_static("XMLHttpRequest"),
603    );
604    headers.insert(ORIGIN, HeaderValue::from_str(&origin).map_err(Error::HeaderValue)?);
605    headers.insert(REFERER, HeaderValue::from_str(&referer).map_err(Error::HeaderValue)?);
606    headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("empty"));
607    headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("cors"));
608    headers.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));
609    headers.insert("Priority", HeaderValue::from_static("u=0"));
610    Ok(headers)
611}
612
613const BASE_URL: &str = "https://www.guerrillamail.com";
614const AJAX_URL: &str = "https://www.guerrillamail.com/ajax.php";
615const USER_AGENT_VALUE: &str =
616    "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0";
617
618/// Configures and bootstraps a GuerrillaMail [`Client`].
619///
620/// Conceptually, [`ClientBuilder`] holds request-layer options (proxy, TLS leniency, user agent,
621/// endpoints, timeout). Calling [`build`](ClientBuilder::build) creates a `reqwest::Client` with
622/// cookie storage enabled, fetches the GuerrillaMail homepage once, and captures the `ApiToken …`
623/// header needed for all later AJAX calls.
624///
625/// Invariants/internal behavior:
626/// - The bootstrap fetch happens exactly once during `build`; the resulting token is baked into the
627///   constructed [`Client`].
628/// - Defaults favor easy testing: no proxy, `danger_accept_invalid_certs = true`, browser-like
629///   user agent, 30s timeout, and the public GuerrillaMail endpoints.
630/// - `Clone` is cheap and copies configuration only; it does not perform additional network I/O.
631///
632/// Typical lifecycle: start with [`Client::builder`], adjust options, call `build`, then discard
633/// the builder. Reuse the built [`Client`] (or its cheap clones) across tasks.
634///
635/// # Example
636/// ```rust,no_run
637/// # use guerrillamail_client::Client;
638/// # #[tokio::main]
639/// # async fn main() -> Result<(), guerrillamail_client::Error> {
640/// let client = Client::builder()
641///     .proxy("http://127.0.0.1:8080")
642///     .danger_accept_invalid_certs(false)
643///     .user_agent("my-app/2.0")
644///     .build()
645///     .await?;
646/// # Ok(())
647/// # }
648/// ```
649#[derive(Debug, Clone)]
650pub struct ClientBuilder {
651    proxy: Option<String>,
652    danger_accept_invalid_certs: bool,
653    user_agent: String,
654    ajax_url: Url,
655    base_url: Url,
656    timeout: std::time::Duration,
657}
658
659impl Default for ClientBuilder {
660    fn default() -> Self {
661        Self::new()
662    }
663}
664
665impl ClientBuilder {
666    /// Create a new builder with default settings.
667    ///
668    /// See [`ClientBuilder`] for the list of defaults.
669    pub fn new() -> Self {
670        Self {
671            proxy: None,
672            danger_accept_invalid_certs: true,
673            user_agent: USER_AGENT_VALUE.to_string(),
674            ajax_url: Url::parse(AJAX_URL).expect("default ajax url must be valid"),
675            base_url: Url::parse(BASE_URL).expect("default base url must be valid"),
676            // Keep requests from hanging indefinitely; 30s is a conservative, service-friendly default.
677            timeout: std::time::Duration::from_secs(30),
678        }
679    }
680
681    /// Set a proxy URL (e.g. `"http://127.0.0.1:8080"`).
682    ///
683    /// The proxy is applied to all requests performed by the underlying `reqwest::Client`.
684    pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
685        self.proxy = Some(proxy.into());
686        self
687    }
688
689    /// Configure whether to accept invalid TLS certificates (default: `true`).
690    ///
691    /// Set this to `false` for stricter TLS verification.
692    ///
693    /// # Security
694    /// Accepting invalid certificates is unsafe on untrusted networks; it is primarily useful
695    /// for debugging or traffic inspection in controlled environments.
696    pub fn danger_accept_invalid_certs(mut self, value: bool) -> Self {
697        self.danger_accept_invalid_certs = value;
698        self
699    }
700
701    /// Override the default user agent string.
702    ///
703    /// GuerrillaMail may apply different behavior based on the UA; the default is a
704    /// browser-like value.
705    pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
706        self.user_agent = user_agent.into();
707        self
708    }
709
710    /// Override the GuerrillaMail AJAX endpoint URL.
711    ///
712    /// This is primarily useful for testing or if GuerrillaMail changes its endpoint.
713    pub fn ajax_url(mut self, ajax_url: impl Into<String>) -> Self {
714        let parsed = Url::parse(&ajax_url.into()).expect("invalid ajax_url");
715        if parsed.host_str().is_none() {
716            panic!("invalid ajax_url: missing host");
717        }
718        self.ajax_url = parsed;
719        self
720    }
721
722    /// Override the GuerrillaMail base URL.
723    ///
724    /// This is primarily useful for testing.
725    pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
726        let parsed = Url::parse(&base_url.into()).expect("invalid base_url");
727        if parsed.host_str().is_none() {
728            panic!("invalid base_url: missing host");
729        }
730        self.base_url = parsed;
731        self
732    }
733
734    /// Override the default request timeout.
735    ///
736    /// The timeout applies to the whole request (connect + read), matching
737    /// [`reqwest::ClientBuilder::timeout`]. Defaults to 30 seconds.
738    pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
739        self.timeout = timeout;
740        self
741    }
742
743    /// Build the [`Client`] by performing the GuerrillaMail bootstrap request.
744    ///
745    /// Constructs a `reqwest::Client` with cookie storage, applies the configured proxy/TLS/user
746    /// agent/timeouts, sends one GET to the GuerrillaMail homepage, and extracts the `ApiToken …`
747    /// header required for later AJAX calls.
748    ///
749    /// # Errors
750    /// - Returns `Error::Request` for HTTP client build issues, bootstrap network failures, or non-2xx responses.
751    /// - Returns `Error::TokenParse` when the API token cannot be found in the bootstrap HTML.
752    /// - Returns `Error::HeaderValue` if the token cannot be encoded into the authorization header.
753    /// Network-related failures are transient; token/header errors likely indicate a page layout change.
754    ///
755    /// # Network
756    /// Issues one GET request to the configured `base_url`.
757    ///
758    /// # Examples
759    /// ```no_run
760    /// # use guerrillamail_client::Client;
761    /// # #[tokio::main]
762    /// # async fn main() -> Result<(), guerrillamail_client::Error> {
763    /// let client = Client::builder()
764    ///     .user_agent("my-app/1.0")
765    ///     .build()
766    ///     .await?;
767    /// # Ok(())
768    /// # }
769    /// ```
770    pub async fn build(self) -> Result<Client> {
771        let mut builder = reqwest::Client::builder()
772            .danger_accept_invalid_certs(self.danger_accept_invalid_certs)
773            .timeout(self.timeout);
774
775        if let Some(proxy_url) = &self.proxy {
776            builder = builder.proxy(reqwest::Proxy::all(proxy_url)?);
777        }
778
779        // URLs are validated when set on the builder.
780        let base_url = self.base_url;
781        let ajax_url = self.ajax_url;
782
783        // Enable cookie store to persist session between requests.
784        let http = builder.cookie_store(true).build()?;
785
786        // Fetch the main page to get API token.
787        let response = http.get(base_url.as_str()).send().await?.text().await?;
788
789        // Parse API token: api_token : 'xxxxxxxx'
790        let token_re = Regex::new(r"api_token\s*:\s*'([^']+)'")?;
791        let api_token = token_re
792            .captures(&response)
793            .and_then(|c| c.get(1))
794            .map(|m| m.as_str().to_string())
795            .ok_or(Error::TokenParse)?;
796        let api_token_header = HeaderValue::from_str(&format!("ApiToken {}", api_token))?;
797
798        let ajax_headers =
799            build_headers(&ajax_url, &self.user_agent, &api_token_header, true)?;
800        let ajax_headers_no_ct =
801            build_headers(&ajax_url, &self.user_agent, &api_token_header, false)?;
802        let base_headers =
803            build_headers(&base_url, &self.user_agent, &api_token_header, true)?;
804
805        Ok(Client {
806            http,
807            api_token_header,
808            proxy: self.proxy,
809            user_agent: self.user_agent,
810            ajax_url,
811            base_url,
812            ajax_headers,
813            ajax_headers_no_ct,
814            base_headers,
815        })
816    }
817}
818
819#[cfg(test)]
820impl Client {
821    fn new_for_tests(base_url: String, ajax_url: String) -> Self {
822        let http = reqwest::Client::builder()
823            .cookie_store(true)
824            .build()
825            .expect("test client build failed");
826        let api_token_header = HeaderValue::from_static("ApiToken test");
827        let base_url = Url::parse(&base_url).expect("invalid base_url in test");
828        let ajax_url = Url::parse(&ajax_url).expect("invalid ajax_url in test");
829        let ajax_headers =
830            build_headers(&ajax_url, USER_AGENT_VALUE, &api_token_header, true).expect("ajax headers");
831        let ajax_headers_no_ct =
832            build_headers(&ajax_url, USER_AGENT_VALUE, &api_token_header, false).expect("ajax headers no ct");
833        let base_headers =
834            build_headers(&base_url, USER_AGENT_VALUE, &api_token_header, true).expect("base headers");
835        Self {
836            http,
837            api_token_header,
838            proxy: None,
839            user_agent: USER_AGENT_VALUE.to_string(),
840            ajax_url,
841            base_url,
842            ajax_headers,
843            ajax_headers_no_ct,
844            base_headers,
845        }
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use httpmock::Method::{GET, POST};
853    use httpmock::MockServer;
854    use serde_json::json;
855
856    #[tokio::test]
857    async fn fetch_attachment_builds_request_and_returns_bytes() {
858        let server = MockServer::start();
859        let base_url = server.base_url();
860
861        let fetch_email_mock = server.mock(|when, then| {
862            when.method(GET)
863                .path("/ajax.php")
864                .query_param("f", "fetch_email")
865                .query_param("email_id", "123");
866            then.status(200).json_body(json!({
867                "mail_id": "123",
868                "mail_from": "sender@example.com",
869                "mail_subject": "Subject",
870                "mail_body": "<p>Body</p>",
871                "mail_timestamp": "1700000000",
872                "att": 1,
873                "att_info": [{ "f": "file.txt", "t": "text/plain", "p": "99" }],
874                "sid_token": "sid123"
875            }));
876        });
877
878        let attachment_mock = server.mock(|when, then| {
879            when.method(GET)
880                .path("/inbox")
881                .query_param("get_att", "")
882                .query_param("lang", "en")
883                .query_param("email_id", "123")
884                .query_param("part_id", "99")
885                .query_param("sid_token", "sid123");
886            then.status(200).body("hello");
887        });
888
889        let client = Client::new_for_tests(
890            base_url.clone(),
891            format!("{base_url}/ajax.php"),
892        );
893
894        let attachment = Attachment {
895            filename: "file.txt".to_string(),
896            content_type_or_hint: Some("text/plain".to_string()),
897            part_id: "99".to_string(),
898        };
899
900        let bytes = client
901            .fetch_attachment("alias@example.com", "123", &attachment)
902            .await
903            .unwrap();
904
905        assert_eq!(bytes, b"hello");
906        fetch_email_mock.assert();
907        attachment_mock.assert();
908    }
909
910    #[tokio::test]
911    async fn delete_email_returns_true_on_success() {
912        let server = MockServer::start();
913        let base_url = server.base_url();
914
915        let delete_mock = server.mock(|when, then| {
916            when.method(POST)
917                .path("/ajax.php")
918                .query_param("f", "forget_me");
919            then.status(204);
920        });
921
922        let client = Client::new_for_tests(
923            base_url.clone(),
924            format!("{base_url}/ajax.php"),
925        );
926
927        let ok = client.delete_email("alias@example.com").await.unwrap();
928
929        assert!(ok);
930        delete_mock.assert();
931    }
932
933    #[tokio::test]
934    async fn delete_email_errors_on_non_success_status() {
935        let server = MockServer::start();
936        let base_url = server.base_url();
937
938        let delete_mock = server.mock(|when, then| {
939            when.method(POST)
940                .path("/ajax.php")
941                .query_param("f", "forget_me");
942            then.status(500);
943        });
944
945        let client = Client::new_for_tests(
946            base_url.clone(),
947            format!("{base_url}/ajax.php"),
948        );
949
950        let err = client.delete_email("alias@example.com").await.unwrap_err();
951
952        assert!(matches!(err, Error::Request(_)));
953        delete_mock.assert();
954    }
955
956    #[test]
957    fn client_is_clone() {
958        let base_url = "https://example.com";
959        let client = Client::new_for_tests(
960            base_url.to_string(),
961            format!("{base_url}/ajax.php"),
962        );
963
964        let cloned = client.clone();
965
966        assert_eq!(client.proxy, cloned.proxy);
967        assert_eq!(client.user_agent, cloned.user_agent);
968        assert_eq!(client.ajax_url, cloned.ajax_url);
969        assert_eq!(client.base_url, cloned.base_url);
970    }
971
972    #[test]
973    fn token_regex_accepts_broad_characters() {
974        let token_re = Regex::new(r"api_token\s*:\s*'([^']+)'").unwrap();
975        let sample = "const data = { api_token : 'abc-123.def:ghi' };";
976        let caps = token_re.captures(sample).expect("should match");
977        assert_eq!(caps.get(1).unwrap().as_str(), "abc-123.def:ghi");
978    }
979}