main
Colby Knox Publish 0.1.0 fb6e12f 11d ago
use std::sync::Mutex;
use std::time::{Duration, Instant};

use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};

const API_BASE: &str = "https://api.resend.com";

/// Resend's rate limit is per team and shared with every other service that
/// sends through the same account. Space our calls out so a template-list
/// refresh can never starve a real send.
const MIN_GAP: Duration = Duration::from_millis(550);

pub struct ResendClient {
    api_key: String,
    last_call: Mutex<Option<Instant>>,
}

impl ResendClient {
    pub fn new(api_key: String) -> Self {
        Self { api_key, last_call: Mutex::new(None) }
    }

    fn throttle(&self) {
        let mut last = self.last_call.lock().unwrap();
        if let Some(t) = *last {
            let since = t.elapsed();
            if since < MIN_GAP {
                std::thread::sleep(MIN_GAP - since);
            }
        }
        *last = Some(Instant::now());
    }

    fn req(&self, method: &str, path: &str) -> ureq::Request {
        self.throttle();
        ureq::request(method, &format!("{API_BASE}{path}"))
            .set("Authorization", &format!("Bearer {}", self.api_key))
            .set("User-Agent", concat!("adom-resend-studio/", env!("CARGO_PKG_VERSION")))
            .timeout(Duration::from_secs(20))
    }

    /// What this key may do. Resend's "Sending access" keys can only send
    /// email; managing templates needs a "Full access" key.
    pub fn check_access(&self) -> (&'static str, Option<String>) {
        match self.req("GET", "/templates?limit=1").call() {
            Ok(_) => ("full", None),
            Err(ureq::Error::Status(401, resp)) => {
                let body = resp.into_string().unwrap_or_default();
                if body.contains("restricted") {
                    ("sending", None)
                } else {
                    ("invalid", Some(map_body(401, &body)))
                }
            }
            Err(ureq::Error::Status(403, resp)) => ("invalid", Some(map_body(403, &resp.into_string().unwrap_or_default()))),
            Err(e) => ("unknown", Some(map_ureq_err(e).to_string())),
        }
    }

    /// Every template in the account, following the cursor until has_more is false.
    pub fn list_templates(&self) -> Result<Vec<Value>> {
        let mut out = Vec::new();
        let mut after: Option<String> = None;
        loop {
            let path = match &after {
                Some(a) => format!("/templates?limit=100&after={a}"),
                None => "/templates?limit=100".to_string(),
            };
            let body: Value = self
                .req("GET", &path)
                .call()
                .map_err(map_ureq_err)?
                .into_json()
                .context("parsing template list")?;
            let page = body.get("data").and_then(|d| d.as_array()).cloned().unwrap_or_default();
            let last_id = page.last().and_then(|t| t.get("id")).and_then(|v| v.as_str()).map(String::from);
            out.extend(page);
            let has_more = body.get("has_more").and_then(|v| v.as_bool()).unwrap_or(false);
            match (has_more, last_id) {
                (true, Some(id)) => after = Some(id),
                _ => break,
            }
        }
        Ok(out)
    }

    /// Looks a template up by id or alias. Ok(None) on 404.
    pub fn get_template(&self, id_or_alias: &str) -> Result<Option<Value>> {
        match self.req("GET", &format!("/templates/{id_or_alias}")).call() {
            Ok(resp) => Ok(Some(resp.into_json().context("parsing template")?)),
            Err(ureq::Error::Status(404, _)) => Ok(None),
            Err(e) => Err(map_ureq_err(e)),
        }
    }

    /// Creates a template. New templates start as drafts on Resend's side.
    pub fn create_template(&self, payload: &Value) -> Result<String> {
        let body: Value = self
            .req("POST", "/templates")
            .send_json(payload.clone())
            .map_err(map_ureq_err)?
            .into_json()
            .context("parsing create-template response")?;
        body.get("id")
            .and_then(|v| v.as_str())
            .map(String::from)
            .ok_or_else(|| anyhow!("Resend response missing template id: {body}"))
    }

    /// Updates a template's working draft. The published version, which is
    /// what real sends use, is untouched until `publish_template`.
    pub fn update_template(&self, id: &str, payload: &Value) -> Result<()> {
        self.req("PATCH", &format!("/templates/{id}"))
            .send_json(payload.clone())
            .map_err(map_ureq_err)?;
        Ok(())
    }

    pub fn publish_template(&self, id: &str) -> Result<()> {
        self.req("POST", &format!("/templates/{id}/publish"))
            .send_json(json!({}))
            .map_err(map_ureq_err)?;
        Ok(())
    }

    /// Sends one email. Returns Resend's email id.
    pub fn send_email(&self, payload: &Value) -> Result<String> {
        let body: Value = self
            .req("POST", "/emails")
            .send_json(payload.clone())
            .map_err(map_ureq_err)?
            .into_json()
            .context("parsing send response")?;
        Ok(body.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string())
    }
}

fn map_body(code: u16, body: &str) -> String {
    let msg = serde_json::from_str::<Value>(body)
        .ok()
        .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
        .unwrap_or_else(|| body.to_string());
    format!("Resend API returned HTTP {code}: {msg}")
}

fn map_ureq_err(err: ureq::Error) -> anyhow::Error {
    match err {
        ureq::Error::Status(code, resp) => anyhow!(map_body(code, &resp.into_string().unwrap_or_default())),
        ureq::Error::Transport(t) => anyhow!("Resend API request failed: {t}"),
    }
}