#![forbid(unsafe_code)]
#![warn(missing_docs)]
use std::collections::HashSet;
use std::time::{Duration, Instant};
use reqwest::{header, Client as Http, Method, Response, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use thiserror::Error;
use tokio::time::sleep;
pub const DEFAULT_BASE_URL: &str = "https://api.cybertemp.xyz";
#[derive(Debug, Clone, Deserialize)]
pub struct Inbox {
pub id: String,
pub address: String,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default)]
pub expires_at: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Message {
pub id: String,
#[serde(alias = "from_addr")]
pub from: String,
pub subject: String,
#[serde(default, alias = "text")]
pub body_text: Option<String>,
#[serde(default, alias = "html")]
pub body_html: Option<String>,
#[serde(default, alias = "timestamp")]
pub received_at: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct MessagesList {
#[serde(default)]
messages: Vec<Message>,
}
#[derive(Debug, Clone, Deserialize)]
struct InboxesList {
#[serde(default)]
inboxes: Vec<Inbox>,
}
#[derive(Debug, Default, Clone, Serialize)]
pub struct CreateInboxOptions<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub domain: Option<&'a str>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Usage {
pub used: i64,
pub limit: i64,
}
#[derive(Debug, Error)]
pub enum Error {
#[error("upgrade required ({status}): {message}")]
Upgrade {
status: u16,
message: String,
trigger: Option<String>,
required_tier: Option<String>,
upgrade_url: Option<String>,
contact_url: Option<String>,
cta_label: Option<String>,
current_usage: Option<Usage>,
payload: Value,
},
#[error("rate limited: retry in {retry_after:?}")]
RateLimited {
retry_after: Duration,
payload: Value,
},
#[error("timed out after {0:?}")]
Timeout(Duration),
#[error("no OTP found in the latest message ({message_id})")]
NoOtpFound {
message_id: String,
},
#[error("API error ({status}): {message}")]
Api {
status: u16,
message: String,
payload: Value,
},
#[error(transparent)]
Transport(#[from] reqwest::Error),
}
type Result<T> = std::result::Result<T, Error>;
#[derive(Debug, Clone)]
pub struct Client {
http: Http,
base_url: String,
api_key: String,
}
impl Client {
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_base_url(api_key, DEFAULT_BASE_URL)
}
pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
let api_key = api_key.into();
let mut headers = header::HeaderMap::new();
headers.insert(
header::USER_AGENT,
header::HeaderValue::from_static("cybertemp-rust/0.1.0"),
);
let http = Http::builder()
.default_headers(headers)
.timeout(Duration::from_secs(30))
.build()
.expect("reqwest client build");
Self {
http,
base_url: base_url.into().trim_end_matches('/').to_string(),
api_key,
}
}
async fn send(
&self,
method: Method,
path: &str,
query: &[(&str, String)],
body: Option<Value>,
) -> Result<Option<Value>> {
let url = format!("{}{}", self.base_url, path);
let mut req = self
.http
.request(method, &url)
.header("X-API-KEY", &self.api_key)
.header(header::AUTHORIZATION, format!("Bearer {}", self.api_key));
if !query.is_empty() {
req = req.query(query);
}
if let Some(b) = body {
req = req.json(&b);
}
let resp = req.send().await?;
handle_response(resp).await
}
pub async fn create_inbox(&self) -> Result<Inbox> {
self.create_inbox_with(&CreateInboxOptions::default()).await
}
pub async fn create_inbox_with(&self, opts: &CreateInboxOptions<'_>) -> Result<Inbox> {
let domain = match opts.domain {
Some(d) => d.to_string(),
None => {
let raw = self.send(Method::GET, "/getDomains", &[], None).await?;
let wrap: serde_json::Value = raw.unwrap_or(json!({}));
wrap.get("domains")
.and_then(|v| v.as_array())
.and_then(|arr| arr.first())
.and_then(|v| v.as_str())
.unwrap_or("cybertemp.xyz")
.to_string()
}
};
let username = match opts.username {
Some(u) => u.to_string(),
None => random_local_part(),
};
let address = format!("{username}@{domain}");
let _ = self.read_messages(&address, 1, 0).await?;
Ok(Inbox {
id: address.clone(),
address,
created_at: None,
expires_at: None,
})
}
pub async fn list_inboxes(&self) -> Result<Vec<Inbox>> {
Ok(Vec::new())
}
pub async fn read_messages(
&self,
inbox_id_or_address: &str,
limit: usize,
offset: usize,
) -> Result<Vec<Message>> {
let mut q = vec![("email", inbox_id_or_address.to_string())];
if limit > 0 {
q.push(("limit", limit.to_string()));
}
if offset > 0 {
q.push(("offset", offset.to_string()));
}
let raw = self.send(Method::GET, "/getMail", &q, None).await?;
let val = raw.unwrap_or(json!({}));
if let Some(arr) = val.get("messages").and_then(|v| v.as_array()) {
return Ok(serde_json::from_value(json!(arr)).map_err(json_err)?);
}
if let Some(arr) = val.get("emails").and_then(|v| v.as_array()) {
return Ok(serde_json::from_value(json!(arr)).map_err(json_err)?);
}
Ok(serde_json::from_value(val).unwrap_or_default())
}
pub async fn wait_for_message(
&self,
inbox_id_or_address: &str,
timeout: Duration,
) -> Result<Message> {
self.wait_for_message_with(
inbox_id_or_address,
timeout,
Duration::from_secs(2),
None,
)
.await
}
pub async fn wait_for_message_with(
&self,
inbox_id_or_address: &str,
timeout: Duration,
poll_interval: Duration,
since_message_id: Option<&str>,
) -> Result<Message> {
let poll_interval = poll_interval.max(Duration::from_millis(500));
let deadline = Instant::now() + timeout;
let mut seen: HashSet<String> = HashSet::new();
if let Some(id) = since_message_id {
seen.insert(id.to_string());
}
while Instant::now() < deadline {
let msgs = self.read_messages(inbox_id_or_address, 5, 0).await?;
for m in &msgs {
if !seen.contains(&m.id) {
return Ok(m.clone());
}
}
for m in msgs {
seen.insert(m.id);
}
sleep(poll_interval).await;
}
Err(Error::Timeout(timeout))
}
pub async fn wait_for_otp(
&self,
inbox_id_or_address: &str,
timeout: Duration,
) -> Result<String> {
let body = json!({
"inbox": inbox_id_or_address,
"timeout_seconds": timeout.as_secs(),
});
let raw = self
.send(Method::POST, "/extract-otp", &[], Some(body))
.await?;
let val = raw.unwrap_or(json!({}));
let code = val
.get("code")
.and_then(|v| v.as_str())
.ok_or_else(|| Error::Api {
status: 200,
message: "extract-otp succeeded but no code in response".into(),
payload: val.clone(),
})?;
Ok(code.to_string())
}
pub async fn delete_inbox(&self, inbox_id_or_address: &str) -> Result<()> {
let path = format!("/inbox/{}", urlencode(inbox_id_or_address));
self.send(Method::DELETE, &path, &[], None).await?;
Ok(())
}
}
fn random_local_part() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
let mut out = String::with_capacity(10);
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.subsec_nanos() as u64)
.unwrap_or(0);
let mut state = nanos.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
for _ in 0..10 {
state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
out.push(ALPHABET[(state >> 33) as usize % ALPHABET.len()] as char);
}
out
}
fn urlencode(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
fn json_err(e: serde_json::Error) -> Error {
Error::Api {
status: 0,
message: e.to_string(),
payload: Value::Null,
}
}
async fn handle_response(resp: Response) -> Result<Option<Value>> {
let status = resp.status();
let retry_after = resp
.headers()
.get(header::RETRY_AFTER)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
.map(Duration::from_secs)
.unwrap_or(Duration::from_secs(1));
if status == StatusCode::NO_CONTENT {
return Ok(None);
}
let text = resp.text().await?;
let payload: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
if status.is_success() {
return Ok(Some(payload));
}
let message = payload
.get("message")
.and_then(|v| v.as_str())
.map(str::to_string)
.unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
let trigger = payload
.get("trigger")
.and_then(|v| v.as_str())
.map(str::to_string);
let is_upgrade_status = matches!(status, StatusCode::FORBIDDEN | StatusCode::PAYMENT_REQUIRED);
let is_429_upgrade = status == StatusCode::TOO_MANY_REQUESTS
&& matches!(
trigger.as_deref(),
Some("rate_limit_daily") | Some("elite_soft_cap")
);
if is_upgrade_status || is_429_upgrade {
return Err(Error::Upgrade {
status: status.as_u16(),
message,
trigger,
required_tier: payload
.get("required_tier")
.and_then(|v| v.as_str())
.map(str::to_string),
upgrade_url: payload
.get("upgrade_url")
.and_then(|v| v.as_str())
.map(str::to_string),
contact_url: payload
.get("contact_url")
.and_then(|v| v.as_str())
.map(str::to_string),
cta_label: payload
.get("cta_label")
.and_then(|v| v.as_str())
.map(str::to_string),
current_usage: payload
.get("current_usage")
.and_then(|v| serde_json::from_value::<Usage>(v.clone()).ok()),
payload,
});
}
if status == StatusCode::TOO_MANY_REQUESTS {
return Err(Error::RateLimited {
retry_after,
payload,
});
}
Err(Error::Api {
status: status.as_u16(),
message,
payload,
})
}