use crate::constants::*;
use crate::error::{Error, Result};
use crate::models::{Attachment, Message, MessageContent};
use rand::{Rng, distributions::Alphanumeric};
use regex::Regex;
use reqwest::{
Client, ClientBuilder, StatusCode,
cookie::Jar,
header::{HeaderMap, HeaderName, HeaderValue},
};
use scraper::{ElementRef, Html, Selector};
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
fn normalize_mailbox(mailbox: &str) -> Result<String> {
if mailbox.contains('@') {
return Err(Error::InvalidRecipient);
}
Ok(mailbox.trim().to_lowercase())
}
fn build_headers(base: &[(&str, &str)], extras: &[(&str, &str)]) -> HeaderMap {
let mut headers = HeaderMap::new();
for (k, v) in base.iter().chain(extras.iter()) {
if let Ok(name) = HeaderName::from_bytes(k.as_bytes()) {
if let Ok(val) = HeaderValue::from_str(v) {
headers.insert(name, val);
}
}
}
headers
}
pub struct YopmailClient {
mailbox: String,
base_url: String,
jar: Arc<Jar>,
client: Client,
yp_token: Option<String>,
yj_token: Option<String>,
version: Option<String>,
}
pub struct YopmailClientBuilder {
mailbox: String,
base_url: String,
timeout: Duration,
proxy_url: Option<String>,
}
impl YopmailClientBuilder {
pub fn new(mailbox: impl AsRef<str>) -> Self {
Self {
mailbox: mailbox.as_ref().to_string(),
base_url: BASE_URL.to_string(),
timeout: default_timeout(),
proxy_url: None,
}
}
pub fn base_url(mut self, base_url: impl Into<String>) -> Self {
self.base_url = base_url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn proxy_url(mut self, proxy_url: impl Into<String>) -> Self {
self.proxy_url = Some(proxy_url.into());
self
}
pub fn build(self) -> Result<YopmailClient> {
let mailbox = normalize_mailbox(&self.mailbox)?;
let jar = Arc::new(Jar::default());
let mut builder = ClientBuilder::new()
.cookie_provider(jar.clone())
.timeout(self.timeout)
.default_headers(default_headers());
if let Some(proxy) = &self.proxy_url {
builder = builder.proxy(reqwest::Proxy::all(proxy).map_err(Error::Http)?);
}
let client = builder.build().map_err(Error::Http)?;
Ok(YopmailClient {
mailbox,
base_url: self.base_url,
jar,
client,
yp_token: None,
yj_token: None,
version: None,
})
}
}
impl YopmailClient {
pub fn builder(mailbox: impl AsRef<str>) -> YopmailClientBuilder {
YopmailClientBuilder::new(mailbox)
}
pub fn new(mailbox: impl AsRef<str>) -> Result<Self> {
YopmailClientBuilder::new(mailbox).build()
}
pub async fn open_inbox(&mut self) -> Result<()> {
self.set_default_cookies();
let login_url = format!("{}/en/?login={}", self.base_url, self.mailbox);
let resp = self.client.get(&login_url).send().await?;
let body = resp.text().await?;
self.yp_token = extract_yp_token(&body);
if self.yp_token.is_none() {
self.yp_token = Some(FALLBACK_YP_TOKEN.to_string());
}
if let Some(ref yp) = self.yp_token {
let form = [
("login", self.mailbox.clone()),
("id", String::new()),
("yp", yp.clone()),
];
let resp = self
.client
.post(format!("{}/en/", self.base_url))
.headers(default_headers())
.form(&form)
.send()
.await?;
let body = resp.text().await?;
self.version = extract_webmail_version(&body);
self.yj_token = Some(self.fetch_yj_token(&body).await?);
}
Ok(())
}
pub async fn list_messages(&mut self, page: i32) -> Result<Vec<Message>> {
if self.yp_token.is_none() || self.yj_token.is_none() {
self.open_inbox().await?;
}
let yp = self
.yp_token
.clone()
.unwrap_or_else(|| FALLBACK_YP_TOKEN.to_string());
let yj = self
.yj_token
.clone()
.ok_or_else(|| Error::Parse("missing yj token".into()))?;
let version = self.version.clone().unwrap_or_else(|| VERSION.to_string());
let params = [
("login", self.mailbox.as_str()),
("p", &page.to_string()),
("d", ""),
("ctrl", ""),
("yp", yp.as_str()),
("yj", yj.as_str()),
("v", version.as_str()),
("r_c", ""),
("id", ""),
("ad", &AD_PARAM.to_string()),
];
let headers = build_headers(DEFAULT_HEADERS, INBOX_HEADERS);
let url = format!("{}/en/inbox", self.base_url);
let resp = self
.client
.get(&url)
.headers(headers)
.query(¶ms)
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
return Err(Error::Status { status, body });
}
let messages = parse_messages(&body);
Ok(messages)
}
pub async fn fetch_message(&mut self, message_id: &str) -> Result<String> {
let content = self.fetch_message_full(message_id).await?;
Ok(content.text)
}
pub async fn fetch_message_full(&mut self, message_id: &str) -> Result<MessageContent> {
if self.yp_token.is_none() {
self.open_inbox().await?;
}
self.set_default_cookies();
let headers = build_headers(DEFAULT_HEADERS, MAIL_HEADERS);
let mail_url = format!("{}/en/mail", self.base_url);
let raw_id = message_id.trim();
let main_id = if raw_id.starts_with('m') {
raw_id.to_string()
} else if raw_id.starts_with("e_") {
format!("m{}", raw_id)
} else {
format!("m_{}", raw_id.trim_start_matches("m_"))
};
let alt_id =
if raw_id.starts_with("e_") || raw_id.starts_with("me_") || raw_id.starts_with("m_") {
raw_id.to_string()
} else {
format!("e_{raw_id}")
};
let variants = [main_id, alt_id, raw_id.to_string()];
let mut last_status = None;
let mut last_body = None;
for id in variants {
let params = [("b", self.mailbox.as_str()), ("id", id.as_str())];
let resp = self
.client
.get(&mail_url)
.headers(headers.clone())
.query(¶ms)
.send()
.await?;
let status = resp.status();
let body = resp.text().await?;
if status.is_success() {
let attachments = extract_attachments(&body, &self.base_url);
let html = extract_message_html(&body);
return Ok(MessageContent {
text: extract_message_body(&body),
html,
raw: body,
attachments,
});
}
last_status = Some(status);
last_body = Some(body);
if status != StatusCode::BAD_REQUEST {
break;
}
}
Err(Error::Status {
status: last_status.unwrap_or(StatusCode::BAD_REQUEST),
body: last_body.unwrap_or_else(|| "mail fetch failed".into()),
})
}
pub async fn send_message(&mut self, to: &str, subject: &str, body: &str) -> Result<()> {
if !to.ends_with("@yopmail.com") {
return Err(Error::InvalidRecipient);
}
if self.yp_token.is_none() {
self.open_inbox().await?;
}
let form = [
("msgfrom", format!("{}@{}", self.mailbox, DEFAULT_DOMAIN)),
("msgto", to.to_string()),
("msgsubject", subject.to_string()),
("msgbody", body.to_string()),
];
let headers = build_headers(DEFAULT_HEADERS, SEND_HEADERS);
let url = format!("{}/writepost", self.base_url);
let resp = self
.client
.post(&url)
.headers(headers)
.form(&form)
.send()
.await?;
let status = resp.status();
let text = resp.text().await?;
if !status.is_success() {
return Err(Error::Status {
status,
body: text.clone(),
});
}
let lower = text.to_lowercase();
let success = ["msgto|", "sent successfully", "message sent", "ok|"]
.iter()
.any(|needle| lower.contains(needle));
if success {
Ok(())
} else {
Err(Error::Auth(format!("send failed: {}", text)))
}
}
pub async fn download_attachment(&mut self, attachment: &Attachment) -> Result<Vec<u8>> {
if self.yp_token.is_none() {
self.open_inbox().await?;
}
self.set_default_cookies();
let headers = build_headers(DEFAULT_HEADERS, MAIL_HEADERS);
let url = normalize_url(&attachment.url, &self.base_url);
let resp = self.client.get(url).headers(headers).send().await?;
let status = resp.status();
let bytes = resp.bytes().await?;
if !status.is_success() {
return Err(Error::Status {
status,
body: format!("failed to download attachment: {}", status),
});
}
Ok(bytes.to_vec())
}
fn set_default_cookies(&self) {
let base: reqwest::Url = self.base_url.parse().expect("base URL should be valid");
let time_now = current_time_cookie();
self.jar.add_cookie_str(
&format!("ytime={}; Domain=.yopmail.com; Path=/", time_now),
&base,
);
self.jar.add_cookie_str(
&format!("ywm={}; Domain=.yopmail.com; Path=/", self.mailbox),
&base,
);
}
async fn fetch_yj_token(&self, webmail_body: &str) -> Result<String> {
let script_url = extract_webmail_script_url(webmail_body, &self.base_url)
.unwrap_or_else(|| format!("{}/ver/{}/webmail.js", self.base_url, VERSION));
let resp = self.client.get(script_url).send().await?;
let status = resp.status();
let body = resp.text().await?;
if !status.is_success() {
return Err(Error::Status { status, body });
}
extract_yj_token(&body).ok_or_else(|| Error::Parse("missing yj token".into()))
}
}
fn current_time_cookie() -> String {
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let minutes = (secs / 60) % (24 * 60);
format!("{:02}:{:02}", minutes / 60, minutes % 60)
}
fn extract_yp_token(body: &str) -> Option<String> {
let doc = Html::parse_document(body);
let selector = Selector::parse("input#yp").ok()?;
for node in doc.select(&selector) {
if let Some(value) = node.value().attr("value") {
return Some(value.to_string());
}
}
None
}
fn extract_webmail_script_url(body: &str, base: &str) -> Option<String> {
let re = Regex::new(r#"<script[^>]+src=["']([^"']*webmail\.js)["']"#).ok()?;
let path = re.captures(body)?.get(1)?.as_str();
Some(normalize_url(path, base))
}
fn extract_webmail_version(body: &str) -> Option<String> {
let re = Regex::new(r#"var\s+ver=['"]([^'"]+)['"]"#).ok()?;
re.captures(body)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
}
fn extract_yj_token(script: &str) -> Option<String> {
let re = Regex::new(r#"[?&]yj=([A-Za-z0-9]+)&v="#).ok()?;
re.captures(script)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
}
fn parse_messages(body: &str) -> Vec<Message> {
let doc = Html::parse_document(body);
let message_sel = Selector::parse(".m").ok();
let subject_sel = Selector::parse(".lsub, .lms").ok();
let sender_sel = Selector::parse(".lmf").ok();
let time_sel = Selector::parse(".lmh").ok();
let mut messages = Vec::new();
if let Some(msg_sel) = message_sel {
for el in doc.select(&msg_sel) {
let id = el
.value()
.id()
.map(|s| s.to_string())
.unwrap_or_else(|| "".into());
if id.is_empty() {
continue;
}
let subject = subject_sel
.as_ref()
.and_then(|sel| el.select(sel).next())
.map(|n| n.text().collect::<String>().trim().to_string())
.unwrap_or_default();
let sender = sender_sel
.as_ref()
.and_then(|sel| el.select(sel).next())
.map(|n| n.text().collect::<String>().trim().to_string());
let time = time_sel
.as_ref()
.and_then(|sel| el.select(sel).next())
.map(|n| n.text().collect::<String>().trim().to_string());
messages.push(Message {
id,
subject,
sender,
time,
});
}
}
messages
}
const MESSAGE_CONTAINER_SELECTORS: &[&str] = &[
"#mailctn #mail",
"#mailctn",
"#mail",
"div.mail-body",
"div.mail",
"div.message",
"div.content",
"div.body",
];
fn extract_from_message_container(
body: &str,
mut extract: impl for<'a> FnMut(ElementRef<'a>) -> Option<String>,
) -> Option<String> {
let doc = Html::parse_document(body);
for sel in MESSAGE_CONTAINER_SELECTORS {
if let Ok(selector) = Selector::parse(sel) {
if let Some(node) = doc.select(&selector).next() {
if let Some(value) = extract(node) {
return Some(value);
}
}
}
}
None
}
fn extract_message_body(body: &str) -> String {
extract_from_message_container(body, |node| {
let text = node.text().collect::<String>();
(text.trim().len() > 5).then(|| clean_text(&text))
})
.unwrap_or_else(|| clean_text(body))
}
fn extract_message_html(body: &str) -> String {
extract_from_message_container(body, |node| {
let html = node.inner_html();
(html.trim().len() > 5).then_some(html)
})
.unwrap_or_else(|| body.to_string())
}
fn extract_attachments(body: &str, base: &str) -> Vec<Attachment> {
let doc = Html::parse_document(body);
let mut seen = HashSet::new();
let mut attachments = Vec::new();
if let Ok(sel) = Selector::parse("a.pj") {
for node in doc.select(&sel) {
if let Some(href) = node.value().attr("href") {
let url = normalize_url(href, base);
if seen.insert(url.clone()) {
let name = node
.value()
.attr("title")
.map(|s| s.to_string())
.or_else(|| {
let txt = node.text().collect::<String>().trim().to_string();
if txt.is_empty() { None } else { Some(txt) }
});
attachments.push(Attachment { name, url });
}
}
}
}
if let Ok(re) = Regex::new(r#"(/downmail\?[^"' ]+)"#) {
for cap in re.captures_iter(body) {
if let Some(m) = cap.get(1) {
let url = normalize_url(m.as_str(), base);
if seen.insert(url.clone()) {
attachments.push(Attachment { name: None, url });
}
}
}
}
attachments
}
fn normalize_url(href: &str, base: &str) -> String {
let base = format!("{}/", base.trim_end_matches('/'));
reqwest::Url::parse(&base)
.and_then(|base| base.join(href))
.map(|url| url.to_string())
.unwrap_or_else(|_| href.to_string())
}
fn clean_text(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut last_ws = false;
for c in input.chars() {
if c.is_whitespace() {
if !last_ws {
out.push(' ');
}
last_ws = true;
} else {
out.push(c);
last_ws = false;
}
}
out.trim().to_string()
}
pub fn generate_random_mailbox(len: usize) -> String {
let length = len.clamp(6, 32);
let mut rng = rand::thread_rng();
let raw: String = (0..length)
.map(|_| rng.sample(Alphanumeric) as char)
.collect();
raw.to_lowercase()
}