Skip to main content

cybertemp/
lib.rs

1//! # cybertemp
2//!
3//! Async Rust SDK for [CyberTemp](https://cybertemp.xyz) — disposable
4//! email inboxes for testing, automation, OTP flows, and AI agents.
5//!
6//! ```no_run
7//! use cybertemp::Client;
8//! use std::time::Duration;
9//!
10//! #[tokio::main]
11//! async fn main() -> anyhow::Result<()> {
12//!     let client = Client::new("tk_...");
13//!     let inbox = client.create_inbox().await?;
14//!     let otp = client.wait_for_otp(&inbox.id, Duration::from_secs(30)).await?;
15//!     println!("OTP: {otp}");
16//!     Ok(())
17//! }
18//! ```
19//!
20//! Errors carry full context — when a tier/cap blocks a call you get an
21//! `Error::Upgrade` with the same `required_tier` / `upgrade_url`
22//! payload the standalone API returns, so you can surface a real CTA.
23
24#![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
36/// Default base URL for the public CyberTemp API.
37pub const DEFAULT_BASE_URL: &str = "https://api.cybertemp.xyz";
38
39/// A disposable inbox.
40#[derive(Debug, Clone, Deserialize)]
41pub struct Inbox {
42    /// Inbox identifier — pass this to subsequent calls.
43    pub id: String,
44    /// Full email address, e.g. `abc123@cybertemp.xyz`.
45    pub address: String,
46    /// ISO-8601 creation timestamp.
47    #[serde(default)]
48    pub created_at: Option<String>,
49    /// ISO-8601 expiry timestamp, when retention applies.
50    #[serde(default)]
51    pub expires_at: Option<String>,
52}
53
54/// One message inside an inbox.
55#[derive(Debug, Clone, Deserialize)]
56pub struct Message {
57    /// Message id.
58    pub id: String,
59    /// Sender address.
60    #[serde(alias = "from_addr")]
61    pub from: String,
62    /// Message subject line.
63    pub subject: String,
64    /// Plain-text body, if available.
65    #[serde(default, alias = "text")]
66    pub body_text: Option<String>,
67    /// HTML body, if available.
68    #[serde(default, alias = "html")]
69    pub body_html: Option<String>,
70    /// ISO-8601 receive timestamp.
71    #[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/// Optional fields for `create_inbox`.
88#[derive(Debug, Default, Clone, Serialize)]
89pub struct CreateInboxOptions<'a> {
90    /// Custom local part of the address.
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub username: Option<&'a str>,
93    /// Domain to use (must be one your account has access to).
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub domain: Option<&'a str>,
96}
97
98/// `{used, limit}` returned on cap-related errors.
99#[derive(Debug, Clone, Deserialize)]
100pub struct Usage {
101    /// Calls used so far this window.
102    pub used: i64,
103    /// Cap value.
104    pub limit: i64,
105}
106
107/// SDK error type. Use `matches!` on the variant to handle tier/cap
108/// blocks vs. transient rate limits vs. anything else.
109#[derive(Debug, Error)]
110pub enum Error {
111    /// User's plan doesn't include the feature, or they hit a cap.
112    #[error("upgrade required ({status}): {message}")]
113    Upgrade {
114        /// HTTP status (usually 402/403/429).
115        status: u16,
116        /// Human-readable message from the API.
117        message: String,
118        /// Trigger key (e.g. `tier_required_otp`).
119        trigger: Option<String>,
120        /// Tier required to unlock the action.
121        required_tier: Option<String>,
122        /// URL to send the user to.
123        upgrade_url: Option<String>,
124        /// Optional contact URL (for Enterprise upsells).
125        contact_url: Option<String>,
126        /// CTA label suggested by the API.
127        cta_label: Option<String>,
128        /// `{used, limit}` for cap-related blocks.
129        current_usage: Option<Usage>,
130        /// Full raw payload.
131        payload: Value,
132    },
133    /// Per-second rate limit. Retry after `retry_after`.
134    #[error("rate limited: retry in {retry_after:?}")]
135    RateLimited {
136        /// Duration the API suggests waiting.
137        retry_after: Duration,
138        /// Raw payload.
139        payload: Value,
140    },
141    /// Operation timed out (e.g. `wait_for_message` exceeded its budget).
142    #[error("timed out after {0:?}")]
143    Timeout(Duration),
144    /// No OTP code was found in the next message.
145    #[error("no OTP found in the latest message ({message_id})")]
146    NoOtpFound {
147        /// ID of the message that didn't contain a code.
148        message_id: String,
149    },
150    /// Anything else — network errors, unexpected status codes, etc.
151    #[error("API error ({status}): {message}")]
152    Api {
153        /// HTTP status.
154        status: u16,
155        /// Message from the API or HTTP layer.
156        message: String,
157        /// Raw payload, if JSON.
158        payload: Value,
159    },
160    /// HTTP / transport error from `reqwest`.
161    #[error(transparent)]
162    Transport(#[from] reqwest::Error),
163}
164
165type Result<T> = std::result::Result<T, Error>;
166
167/// CyberTemp HTTP client.
168///
169/// Cheap to clone — internally wraps an `Arc<reqwest::Client>`.
170#[derive(Debug, Clone)]
171pub struct Client {
172    http: Http,
173    base_url: String,
174    api_key: String,
175}
176
177impl Client {
178    /// Construct a client with the public base URL.
179    pub fn new(api_key: impl Into<String>) -> Self {
180        Self::with_base_url(api_key, DEFAULT_BASE_URL)
181    }
182
183    /// Construct a client pointing at a custom base URL (self-hosted /
184    /// staging).
185    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    /// Allocate a new disposable inbox.
228    pub async fn create_inbox(&self) -> Result<Inbox> {
229        self.create_inbox_with(&CreateInboxOptions::default()).await
230    }
231
232    /// Allocate a new disposable inbox with custom options.
233    ///
234    /// CyberTemp materialises inboxes lazily — the first `/getMail` call
235    /// against an unused address creates it. We pick a domain (caller's
236    /// override or the first public one), generate a random local part
237    /// if needed, and warm the inbox with a single read.
238    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        // Warm the inbox.
258        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    /// List inboxes — the standalone API doesn't expose a per-user list,
268    /// inboxes are addressed by their email. Returns an empty vec so
269    /// loop-shaped client code still compiles.
270    pub async fn list_inboxes(&self) -> Result<Vec<Inbox>> {
271        Ok(Vec::new())
272    }
273
274    /// Read messages from an inbox. Most recent first.
275    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        // Accept either { messages: [...] }, { emails: [...] }, or a bare array.
291        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    /// Block until a new message arrives, or `timeout` elapses.
301    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    /// Like `wait_for_message` but lets you tune the poll interval and
316    /// skip messages newer than `since_message_id`.
317    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    /// Wait for the next message and extract a 4–8 digit OTP from it.
346    ///
347    /// Delegates to the server-side `/extract-otp` endpoint (Pro+).
348    /// Free / Eco accounts get an `Error::Upgrade` carrying the
349    /// upgrade URL.
350    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    /// Delete an inbox.
375    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
382/// Generate 10 random url-safe characters for an email local part.
383fn 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    // Minimal percent-encoding for path components — enough for email
401    // addresses (the `@` is the only reserved character we hit).
402    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}