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(¶ms)
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(¶ms)
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(¶ms)
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(¶ms)
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}