1#![forbid(unsafe_code)]
25#![warn(missing_docs)]
26
27use std::collections::HashSet;
28use std::time::{Duration, Instant};
29
30use reqwest::{header, Client as Http, Method, Response, StatusCode};
31use serde::{Deserialize, Serialize};
32use serde_json::{json, Value};
33use thiserror::Error;
34use tokio::time::sleep;
35
36pub const DEFAULT_BASE_URL: &str = "https://api.cybertemp.xyz";
38
39#[derive(Debug, Clone, Deserialize)]
41pub struct Inbox {
42 pub id: String,
44 pub address: String,
46 #[serde(default)]
48 pub created_at: Option<String>,
49 #[serde(default)]
51 pub expires_at: Option<String>,
52}
53
54#[derive(Debug, Clone, Deserialize)]
56pub struct Message {
57 pub id: String,
59 #[serde(alias = "from_addr")]
61 pub from: String,
62 pub subject: String,
64 #[serde(default, alias = "text")]
66 pub body_text: Option<String>,
67 #[serde(default, alias = "html")]
69 pub body_html: Option<String>,
70 #[serde(default, alias = "timestamp")]
72 pub received_at: Option<String>,
73}
74
75#[derive(Debug, Clone, Deserialize)]
76struct MessagesList {
77 #[serde(default)]
78 messages: Vec<Message>,
79}
80
81#[derive(Debug, Clone, Deserialize)]
82struct InboxesList {
83 #[serde(default)]
84 inboxes: Vec<Inbox>,
85}
86
87#[derive(Debug, Default, Clone, Serialize)]
89pub struct CreateInboxOptions<'a> {
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub username: Option<&'a str>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub domain: Option<&'a str>,
96}
97
98#[derive(Debug, Clone, Deserialize)]
100pub struct Usage {
101 pub used: i64,
103 pub limit: i64,
105}
106
107#[derive(Debug, Error)]
110pub enum Error {
111 #[error("upgrade required ({status}): {message}")]
113 Upgrade {
114 status: u16,
116 message: String,
118 trigger: Option<String>,
120 required_tier: Option<String>,
122 upgrade_url: Option<String>,
124 contact_url: Option<String>,
126 cta_label: Option<String>,
128 current_usage: Option<Usage>,
130 payload: Value,
132 },
133 #[error("rate limited: retry in {retry_after:?}")]
135 RateLimited {
136 retry_after: Duration,
138 payload: Value,
140 },
141 #[error("timed out after {0:?}")]
143 Timeout(Duration),
144 #[error("no OTP found in the latest message ({message_id})")]
146 NoOtpFound {
147 message_id: String,
149 },
150 #[error("API error ({status}): {message}")]
152 Api {
153 status: u16,
155 message: String,
157 payload: Value,
159 },
160 #[error(transparent)]
162 Transport(#[from] reqwest::Error),
163}
164
165type Result<T> = std::result::Result<T, Error>;
166
167#[derive(Debug, Clone)]
171pub struct Client {
172 http: Http,
173 base_url: String,
174 api_key: String,
175}
176
177impl Client {
178 pub fn new(api_key: impl Into<String>) -> Self {
180 Self::with_base_url(api_key, DEFAULT_BASE_URL)
181 }
182
183 pub fn with_base_url(api_key: impl Into<String>, base_url: impl Into<String>) -> Self {
186 let api_key = api_key.into();
187 let mut headers = header::HeaderMap::new();
188 headers.insert(
189 header::USER_AGENT,
190 header::HeaderValue::from_static("cybertemp-rust/0.1.0"),
191 );
192 let http = Http::builder()
193 .default_headers(headers)
194 .timeout(Duration::from_secs(30))
195 .build()
196 .expect("reqwest client build");
197 Self {
198 http,
199 base_url: base_url.into().trim_end_matches('/').to_string(),
200 api_key,
201 }
202 }
203
204 async fn send(
205 &self,
206 method: Method,
207 path: &str,
208 query: &[(&str, String)],
209 body: Option<Value>,
210 ) -> Result<Option<Value>> {
211 let url = format!("{}{}", self.base_url, path);
212 let mut req = self
213 .http
214 .request(method, &url)
215 .header("X-API-KEY", &self.api_key)
216 .header(header::AUTHORIZATION, format!("Bearer {}", self.api_key));
217 if !query.is_empty() {
218 req = req.query(query);
219 }
220 if let Some(b) = body {
221 req = req.json(&b);
222 }
223 let resp = req.send().await?;
224 handle_response(resp).await
225 }
226
227 pub async fn create_inbox(&self) -> Result<Inbox> {
229 self.create_inbox_with(&CreateInboxOptions::default()).await
230 }
231
232 pub async fn create_inbox_with(&self, opts: &CreateInboxOptions<'_>) -> Result<Inbox> {
239 let domain = match opts.domain {
240 Some(d) => d.to_string(),
241 None => {
242 let raw = self.send(Method::GET, "/getDomains", &[], None).await?;
243 let wrap: serde_json::Value = raw.unwrap_or(json!({}));
244 wrap.get("domains")
245 .and_then(|v| v.as_array())
246 .and_then(|arr| arr.first())
247 .and_then(|v| v.as_str())
248 .unwrap_or("cybertemp.xyz")
249 .to_string()
250 }
251 };
252 let username = match opts.username {
253 Some(u) => u.to_string(),
254 None => random_local_part(),
255 };
256 let address = format!("{username}@{domain}");
257 let _ = self.read_messages(&address, 1, 0).await?;
259 Ok(Inbox {
260 id: address.clone(),
261 address,
262 created_at: None,
263 expires_at: None,
264 })
265 }
266
267 pub async fn list_inboxes(&self) -> Result<Vec<Inbox>> {
271 Ok(Vec::new())
272 }
273
274 pub async fn read_messages(
276 &self,
277 inbox_id_or_address: &str,
278 limit: usize,
279 offset: usize,
280 ) -> Result<Vec<Message>> {
281 let mut q = vec![("email", inbox_id_or_address.to_string())];
282 if limit > 0 {
283 q.push(("limit", limit.to_string()));
284 }
285 if offset > 0 {
286 q.push(("offset", offset.to_string()));
287 }
288 let raw = self.send(Method::GET, "/getMail", &q, None).await?;
289 let val = raw.unwrap_or(json!({}));
290 if let Some(arr) = val.get("messages").and_then(|v| v.as_array()) {
292 return Ok(serde_json::from_value(json!(arr)).map_err(json_err)?);
293 }
294 if let Some(arr) = val.get("emails").and_then(|v| v.as_array()) {
295 return Ok(serde_json::from_value(json!(arr)).map_err(json_err)?);
296 }
297 Ok(serde_json::from_value(val).unwrap_or_default())
298 }
299
300 pub async fn wait_for_message(
302 &self,
303 inbox_id_or_address: &str,
304 timeout: Duration,
305 ) -> Result<Message> {
306 self.wait_for_message_with(
307 inbox_id_or_address,
308 timeout,
309 Duration::from_secs(2),
310 None,
311 )
312 .await
313 }
314
315 pub async fn wait_for_message_with(
318 &self,
319 inbox_id_or_address: &str,
320 timeout: Duration,
321 poll_interval: Duration,
322 since_message_id: Option<&str>,
323 ) -> Result<Message> {
324 let poll_interval = poll_interval.max(Duration::from_millis(500));
325 let deadline = Instant::now() + timeout;
326 let mut seen: HashSet<String> = HashSet::new();
327 if let Some(id) = since_message_id {
328 seen.insert(id.to_string());
329 }
330 while Instant::now() < deadline {
331 let msgs = self.read_messages(inbox_id_or_address, 5, 0).await?;
332 for m in &msgs {
333 if !seen.contains(&m.id) {
334 return Ok(m.clone());
335 }
336 }
337 for m in msgs {
338 seen.insert(m.id);
339 }
340 sleep(poll_interval).await;
341 }
342 Err(Error::Timeout(timeout))
343 }
344
345 pub async fn wait_for_otp(
351 &self,
352 inbox_id_or_address: &str,
353 timeout: Duration,
354 ) -> Result<String> {
355 let body = json!({
356 "inbox": inbox_id_or_address,
357 "timeout_seconds": timeout.as_secs(),
358 });
359 let raw = self
360 .send(Method::POST, "/extract-otp", &[], Some(body))
361 .await?;
362 let val = raw.unwrap_or(json!({}));
363 let code = val
364 .get("code")
365 .and_then(|v| v.as_str())
366 .ok_or_else(|| Error::Api {
367 status: 200,
368 message: "extract-otp succeeded but no code in response".into(),
369 payload: val.clone(),
370 })?;
371 Ok(code.to_string())
372 }
373
374 pub async fn delete_inbox(&self, inbox_id_or_address: &str) -> Result<()> {
376 let path = format!("/inbox/{}", urlencode(inbox_id_or_address));
377 self.send(Method::DELETE, &path, &[], None).await?;
378 Ok(())
379 }
380}
381
382fn random_local_part() -> String {
384 use std::time::{SystemTime, UNIX_EPOCH};
385 const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
386 let mut out = String::with_capacity(10);
387 let nanos = SystemTime::now()
388 .duration_since(UNIX_EPOCH)
389 .map(|d| d.subsec_nanos() as u64)
390 .unwrap_or(0);
391 let mut state = nanos.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
392 for _ in 0..10 {
393 state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
394 out.push(ALPHABET[(state >> 33) as usize % ALPHABET.len()] as char);
395 }
396 out
397}
398
399fn urlencode(s: &str) -> String {
400 s.bytes()
403 .map(|b| match b {
404 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
405 (b as char).to_string()
406 }
407 _ => format!("%{b:02X}"),
408 })
409 .collect()
410}
411
412fn json_err(e: serde_json::Error) -> Error {
413 Error::Api {
414 status: 0,
415 message: e.to_string(),
416 payload: Value::Null,
417 }
418}
419
420async fn handle_response(resp: Response) -> Result<Option<Value>> {
421 let status = resp.status();
422 let retry_after = resp
423 .headers()
424 .get(header::RETRY_AFTER)
425 .and_then(|v| v.to_str().ok())
426 .and_then(|s| s.parse::<u64>().ok())
427 .map(Duration::from_secs)
428 .unwrap_or(Duration::from_secs(1));
429
430 if status == StatusCode::NO_CONTENT {
431 return Ok(None);
432 }
433
434 let text = resp.text().await?;
435 let payload: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
436
437 if status.is_success() {
438 return Ok(Some(payload));
439 }
440
441 let message = payload
442 .get("message")
443 .and_then(|v| v.as_str())
444 .map(str::to_string)
445 .unwrap_or_else(|| format!("HTTP {}", status.as_u16()));
446
447 let trigger = payload
448 .get("trigger")
449 .and_then(|v| v.as_str())
450 .map(str::to_string);
451
452 let is_upgrade_status = matches!(status, StatusCode::FORBIDDEN | StatusCode::PAYMENT_REQUIRED);
453 let is_429_upgrade = status == StatusCode::TOO_MANY_REQUESTS
454 && matches!(
455 trigger.as_deref(),
456 Some("rate_limit_daily") | Some("elite_soft_cap")
457 );
458
459 if is_upgrade_status || is_429_upgrade {
460 return Err(Error::Upgrade {
461 status: status.as_u16(),
462 message,
463 trigger,
464 required_tier: payload
465 .get("required_tier")
466 .and_then(|v| v.as_str())
467 .map(str::to_string),
468 upgrade_url: payload
469 .get("upgrade_url")
470 .and_then(|v| v.as_str())
471 .map(str::to_string),
472 contact_url: payload
473 .get("contact_url")
474 .and_then(|v| v.as_str())
475 .map(str::to_string),
476 cta_label: payload
477 .get("cta_label")
478 .and_then(|v| v.as_str())
479 .map(str::to_string),
480 current_usage: payload
481 .get("current_usage")
482 .and_then(|v| serde_json::from_value::<Usage>(v.clone()).ok()),
483 payload,
484 });
485 }
486
487 if status == StatusCode::TOO_MANY_REQUESTS {
488 return Err(Error::RateLimited {
489 retry_after,
490 payload,
491 });
492 }
493
494 Err(Error::Api {
495 status: status.as_u16(),
496 message,
497 payload,
498 })
499}