use crate::{Attachment, Error, Message, Result};
use regex::Regex;
use reqwest::{
header::{
ACCEPT, ACCEPT_LANGUAGE, CONTENT_TYPE, HOST, HeaderMap, HeaderValue, ORIGIN, REFERER,
USER_AGENT,
},
Url,
};
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
#[derive(Clone)]
pub struct Client {
http: reqwest::Client,
#[allow(dead_code)]
api_token_header: HeaderValue,
proxy: Option<String>,
user_agent: String,
ajax_url: Url,
base_url: Url,
ajax_headers: HeaderMap,
ajax_headers_no_ct: HeaderMap,
base_headers: HeaderMap,
}
impl fmt::Debug for Client {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Client")
.field("http", &"<reqwest::Client>")
.field("api_token_header", &"<redacted>")
.field("proxy", &self.proxy)
.field("user_agent", &self.user_agent)
.field("ajax_url", &self.ajax_url)
.field("base_url", &self.base_url)
.finish()
}
}
impl Client {
pub fn builder() -> ClientBuilder {
ClientBuilder::new()
}
pub async fn new() -> Result<Self> {
ClientBuilder::new().build().await
}
pub fn proxy(&self) -> Option<&str> {
self.proxy.as_deref()
}
pub async fn create_email(&self, alias: &str) -> Result<String> {
let params = [("f", "set_email_user")];
let form = [
("email_user", alias),
("lang", "en"),
("site", "guerrillamail.com"),
("in", " Set cancel"),
];
let response: serde_json::Value = self
.http
.post(self.ajax_url.as_str())
.query(¶ms)
.form(&form)
.headers(self.ajax_headers())
.send()
.await?
.error_for_status()?
.json()
.await?;
let email_addr = response
.get("email_addr")
.and_then(|v| v.as_str())
.ok_or(Error::ResponseParse("missing or non-string `email_addr`"))?;
Ok(email_addr.to_string())
}
pub async fn get_messages(&self, email: &str) -> Result<Vec<Message>> {
let response = self.get_api("check_email", email, None).await?;
let list = response
.get("list")
.and_then(|v| v.as_array())
.ok_or(Error::ResponseParse("missing or non-array `list`"))?;
let messages = list
.iter()
.map(|v| serde_json::from_value::<Message>(v.clone()).map_err(Into::into))
.collect::<Result<Vec<_>>>()?;
Ok(messages)
}
pub async fn fetch_email(&self, email: &str, mail_id: &str) -> Result<crate::EmailDetails> {
let raw = self.get_api_text("fetch_email", email, Some(mail_id)).await?;
let details = serde_json::from_str::<crate::EmailDetails>(&raw)?;
Ok(details)
}
pub async fn list_attachments(
&self,
email: &str,
mail_id: &str,
) -> Result<Vec<Attachment>> {
let details = self.fetch_email(email, mail_id).await?;
Ok(details.attachments)
}
pub async fn fetch_attachment(
&self,
email: &str,
mail_id: &str,
attachment: &Attachment,
) -> Result<Vec<u8>> {
if attachment.part_id.trim().is_empty() {
return Err(Error::ResponseParse("attachment missing part_id"));
}
let details = self.fetch_email(email, mail_id).await?;
let inbox_url = self.inbox_url();
let mut query = vec![
("get_att", "".to_string()),
("lang", "en".to_string()),
("email_id", mail_id.to_string()),
("part_id", attachment.part_id.clone()),
];
if let Some(token) = details.sid_token.as_deref() {
if !token.is_empty() {
query.push(("sid_token", token.to_string()));
}
}
let response = self
.http
.get(&inbox_url)
.query(&query)
.headers(self.base_headers())
.send()
.await?
.error_for_status()?;
let bytes = response.bytes().await?;
Ok(bytes.to_vec())
}
pub async fn delete_email(&self, email: &str) -> Result<bool> {
let alias = Self::extract_alias(email);
let params = [("f", "forget_me")];
let form = [("site", "guerrillamail.com"), ("in", alias)];
let response = self
.http
.post(self.ajax_url.as_str())
.query(¶ms)
.form(&form)
.headers(self.ajax_headers())
.send()
.await?
.error_for_status()?;
Ok(response.status().is_success())
}
async fn get_api(
&self,
function: &str,
email: &str,
email_id: Option<&str>,
) -> Result<serde_json::Value> {
let params = self.api_params(function, email, email_id);
let headers = self.ajax_headers_no_ct();
let response: serde_json::Value = self
.http
.get(self.ajax_url.as_str())
.query(¶ms)
.headers(headers)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(response)
}
async fn get_api_text(
&self,
function: &str,
email: &str,
email_id: Option<&str>,
) -> Result<String> {
let params = self.api_params(function, email, email_id);
let headers = self.ajax_headers_no_ct();
let response = self
.http
.get(self.ajax_url.as_str())
.query(¶ms)
.headers(headers)
.send()
.await?
.error_for_status()?
.text()
.await?;
Ok(response)
}
fn extract_alias(email: &str) -> &str {
email.split('@').next().unwrap_or(email)
}
fn api_params(
&self,
function: &str,
email: &str,
email_id: Option<&str>,
) -> Vec<(&str, String)> {
let alias = Self::extract_alias(email);
let timestamp = Self::timestamp();
let mut params = vec![
("f", function.to_string()),
("site", "guerrillamail.com".to_string()),
("in", alias.to_string()),
("_", timestamp),
];
if let Some(id) = email_id {
params.insert(1, ("email_id", id.to_string()));
}
if function == "check_email" {
params.insert(1, ("seq", "1".to_string()));
}
params
}
fn inbox_url(&self) -> String {
self.base_url
.join("inbox")
.expect("constructing inbox URL should not fail")
.into()
}
fn timestamp() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is before UNIX_EPOCH")
.as_millis()
.to_string()
}
fn ajax_headers(&self) -> HeaderMap {
self.ajax_headers.clone()
}
fn ajax_headers_no_ct(&self) -> HeaderMap {
self.ajax_headers_no_ct.clone()
}
fn base_headers(&self) -> HeaderMap {
self.base_headers.clone()
}
}
fn build_headers(
url: &Url,
user_agent: &str,
api_token_header: &HeaderValue,
include_content_type: bool,
) -> Result<HeaderMap> {
let host = url.host_str().expect("validated url missing host");
let host_port = match url.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_string(),
};
let origin = format!("{}://{}", url.scheme(), host_port);
let referer = format!("{origin}/");
let mut headers = HeaderMap::new();
headers.insert(
HOST,
HeaderValue::from_str(&host_port).map_err(Error::HeaderValue)?,
);
let user_agent = HeaderValue::from_str(user_agent).map_err(Error::HeaderValue)?;
headers.insert(USER_AGENT, user_agent);
headers.insert(
ACCEPT,
HeaderValue::from_static("application/json, text/javascript, */*; q=0.01"),
);
headers.insert(ACCEPT_LANGUAGE, HeaderValue::from_static("en-US,en;q=0.5"));
if include_content_type {
headers.insert(
CONTENT_TYPE,
HeaderValue::from_static("application/x-www-form-urlencoded; charset=UTF-8"),
);
}
headers.insert("Authorization", api_token_header.clone());
headers.insert(
"X-Requested-With",
HeaderValue::from_static("XMLHttpRequest"),
);
headers.insert(ORIGIN, HeaderValue::from_str(&origin).map_err(Error::HeaderValue)?);
headers.insert(REFERER, HeaderValue::from_str(&referer).map_err(Error::HeaderValue)?);
headers.insert("Sec-Fetch-Dest", HeaderValue::from_static("empty"));
headers.insert("Sec-Fetch-Mode", HeaderValue::from_static("cors"));
headers.insert("Sec-Fetch-Site", HeaderValue::from_static("same-origin"));
headers.insert("Priority", HeaderValue::from_static("u=0"));
Ok(headers)
}
const BASE_URL: &str = "https://www.guerrillamail.com";
const AJAX_URL: &str = "https://www.guerrillamail.com/ajax.php";
const USER_AGENT_VALUE: &str =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0";
#[derive(Debug, Clone)]
pub struct ClientBuilder {
proxy: Option<String>,
danger_accept_invalid_certs: bool,
user_agent: String,
ajax_url: Url,
base_url: Url,
timeout: std::time::Duration,
}
impl Default for ClientBuilder {
fn default() -> Self {
Self::new()
}
}
impl ClientBuilder {
pub fn new() -> Self {
Self {
proxy: None,
danger_accept_invalid_certs: true,
user_agent: USER_AGENT_VALUE.to_string(),
ajax_url: Url::parse(AJAX_URL).expect("default ajax url must be valid"),
base_url: Url::parse(BASE_URL).expect("default base url must be valid"),
timeout: std::time::Duration::from_secs(30),
}
}
pub fn proxy(mut self, proxy: impl Into<String>) -> Self {
self.proxy = Some(proxy.into());
self
}
pub fn danger_accept_invalid_certs(mut self, value: bool) -> Self {
self.danger_accept_invalid_certs = value;
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn ajax_url(mut self, ajax_url: impl Into<String>) -> Self {
let parsed = Url::parse(&ajax_url.into()).expect("invalid ajax_url");
if parsed.host_str().is_none() {
panic!("invalid ajax_url: missing host");
}
self.ajax_url = parsed;
self
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
let parsed = Url::parse(&base_url.into()).expect("invalid base_url");
if parsed.host_str().is_none() {
panic!("invalid base_url: missing host");
}
self.base_url = parsed;
self
}
pub fn timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
pub async fn build(self) -> Result<Client> {
let mut builder = reqwest::Client::builder()
.danger_accept_invalid_certs(self.danger_accept_invalid_certs)
.timeout(self.timeout);
if let Some(proxy_url) = &self.proxy {
builder = builder.proxy(reqwest::Proxy::all(proxy_url)?);
}
let base_url = self.base_url;
let ajax_url = self.ajax_url;
let http = builder.cookie_store(true).build()?;
let response = http.get(base_url.as_str()).send().await?.text().await?;
let token_re = Regex::new(r"api_token\s*:\s*'([^']+)'")?;
let api_token = token_re
.captures(&response)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
.ok_or(Error::TokenParse)?;
let api_token_header = HeaderValue::from_str(&format!("ApiToken {}", api_token))?;
let ajax_headers =
build_headers(&ajax_url, &self.user_agent, &api_token_header, true)?;
let ajax_headers_no_ct =
build_headers(&ajax_url, &self.user_agent, &api_token_header, false)?;
let base_headers =
build_headers(&base_url, &self.user_agent, &api_token_header, true)?;
Ok(Client {
http,
api_token_header,
proxy: self.proxy,
user_agent: self.user_agent,
ajax_url,
base_url,
ajax_headers,
ajax_headers_no_ct,
base_headers,
})
}
}
#[cfg(test)]
impl Client {
fn new_for_tests(base_url: String, ajax_url: String) -> Self {
let http = reqwest::Client::builder()
.cookie_store(true)
.build()
.expect("test client build failed");
let api_token_header = HeaderValue::from_static("ApiToken test");
let base_url = Url::parse(&base_url).expect("invalid base_url in test");
let ajax_url = Url::parse(&ajax_url).expect("invalid ajax_url in test");
let ajax_headers =
build_headers(&ajax_url, USER_AGENT_VALUE, &api_token_header, true).expect("ajax headers");
let ajax_headers_no_ct =
build_headers(&ajax_url, USER_AGENT_VALUE, &api_token_header, false).expect("ajax headers no ct");
let base_headers =
build_headers(&base_url, USER_AGENT_VALUE, &api_token_header, true).expect("base headers");
Self {
http,
api_token_header,
proxy: None,
user_agent: USER_AGENT_VALUE.to_string(),
ajax_url,
base_url,
ajax_headers,
ajax_headers_no_ct,
base_headers,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use httpmock::Method::{GET, POST};
use httpmock::MockServer;
use serde_json::json;
#[tokio::test]
async fn fetch_attachment_builds_request_and_returns_bytes() {
let server = MockServer::start();
let base_url = server.base_url();
let fetch_email_mock = server.mock(|when, then| {
when.method(GET)
.path("/ajax.php")
.query_param("f", "fetch_email")
.query_param("email_id", "123");
then.status(200).json_body(json!({
"mail_id": "123",
"mail_from": "sender@example.com",
"mail_subject": "Subject",
"mail_body": "<p>Body</p>",
"mail_timestamp": "1700000000",
"att": 1,
"att_info": [{ "f": "file.txt", "t": "text/plain", "p": "99" }],
"sid_token": "sid123"
}));
});
let attachment_mock = server.mock(|when, then| {
when.method(GET)
.path("/inbox")
.query_param("get_att", "")
.query_param("lang", "en")
.query_param("email_id", "123")
.query_param("part_id", "99")
.query_param("sid_token", "sid123");
then.status(200).body("hello");
});
let client = Client::new_for_tests(
base_url.clone(),
format!("{base_url}/ajax.php"),
);
let attachment = Attachment {
filename: "file.txt".to_string(),
content_type_or_hint: Some("text/plain".to_string()),
part_id: "99".to_string(),
};
let bytes = client
.fetch_attachment("alias@example.com", "123", &attachment)
.await
.unwrap();
assert_eq!(bytes, b"hello");
fetch_email_mock.assert();
attachment_mock.assert();
}
#[tokio::test]
async fn delete_email_returns_true_on_success() {
let server = MockServer::start();
let base_url = server.base_url();
let delete_mock = server.mock(|when, then| {
when.method(POST)
.path("/ajax.php")
.query_param("f", "forget_me");
then.status(204);
});
let client = Client::new_for_tests(
base_url.clone(),
format!("{base_url}/ajax.php"),
);
let ok = client.delete_email("alias@example.com").await.unwrap();
assert!(ok);
delete_mock.assert();
}
#[tokio::test]
async fn delete_email_errors_on_non_success_status() {
let server = MockServer::start();
let base_url = server.base_url();
let delete_mock = server.mock(|when, then| {
when.method(POST)
.path("/ajax.php")
.query_param("f", "forget_me");
then.status(500);
});
let client = Client::new_for_tests(
base_url.clone(),
format!("{base_url}/ajax.php"),
);
let err = client.delete_email("alias@example.com").await.unwrap_err();
assert!(matches!(err, Error::Request(_)));
delete_mock.assert();
}
#[test]
fn client_is_clone() {
let base_url = "https://example.com";
let client = Client::new_for_tests(
base_url.to_string(),
format!("{base_url}/ajax.php"),
);
let cloned = client.clone();
assert_eq!(client.proxy, cloned.proxy);
assert_eq!(client.user_agent, cloned.user_agent);
assert_eq!(client.ajax_url, cloned.ajax_url);
assert_eq!(client.base_url, cloned.base_url);
}
#[test]
fn token_regex_accepts_broad_characters() {
let token_re = Regex::new(r"api_token\s*:\s*'([^']+)'").unwrap();
let sample = "const data = { api_token : 'abc-123.def:ghi' };";
let caps = token_re.captures(sample).expect("should match");
assert_eq!(caps.get(1).unwrap().as_str(), "abc-123.def:ghi");
}
}