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