resent 0.1.0

Official Rust SDK for Resent transactional email
Documentation
//! Official Rust SDK for [Resent](https://resent.one) transactional email.
//!
//! ```no_run
//! use resent::{Resent, SendEmail};
//!
//! # async fn run() -> Result<(), resent::Error> {
//! let resent = Resent::new(std::env::var("RESENT_API_KEY").unwrap());
//!
//! let result = resent
//!     .emails()
//!     .send(SendEmail {
//!         from: "Acme <noreply@yourdomain.com>".into(),
//!         to: vec!["you@example.com".into()],
//!         subject: "Hello World".into(),
//!         html: Some("<strong>It works!</strong>".into()),
//!         ..Default::default()
//!     })
//!     .await?;
//!
//! println!("{:?}", result.submission_id);
//! # Ok(())
//! # }
//! ```

use reqwest::Client;
use serde::{Deserialize, Serialize};
use thiserror::Error;

const DEFAULT_BASE_URL: &str = "https://resent.one/api/v1";

/// Errors returned by the Resent client.
#[derive(Debug, Error)]
pub enum Error {
    #[error("missing API key — pass Resent::new(key) or set RESENT_API_KEY")]
    MissingApiKey,
    #[error("{0}")]
    Validation(String),
    #[error("HTTP error: {0}")]
    Http(#[from] reqwest::Error),
    #[error("Resent API error ({status}): {message}")]
    Api { status: u16, message: String, body: Option<serde_json::Value> },
}

/// Official Resent API client.
#[derive(Clone, Debug)]
pub struct Resent {
    api_key: String,
    base_url: String,
    http: Client,
}

impl Resent {
    /// Create a client with an API key.
    ///
    /// Falls back to `RESENT_API_KEY` when `api_key` is empty.
    pub fn new(api_key: impl Into<String>) -> Self {
        let key = api_key.into().trim().to_string();
        let key = if key.is_empty() {
            std::env::var("RESENT_API_KEY").unwrap_or_default()
        } else {
            key
        };
        Self {
            api_key: key,
            base_url: DEFAULT_BASE_URL.to_string(),
            http: Client::new(),
        }
    }

    /// Override the API base URL (default: `https://resent.one/api/v1`).
    pub fn with_base_url(mut self, base_url: impl Into<String>) -> Self {
        let mut url = base_url.into().trim().to_string();
        while url.ends_with('/') {
            url.pop();
        }
        self.base_url = url;
        self
    }

    /// Access email endpoints.
    pub fn emails(&self) -> Emails<'_> {
        Emails { client: self }
    }

    async fn send_email(&self, payload: SendEmail) -> Result<SendEmailResponse, Error> {
        if self.api_key.trim().is_empty() {
            return Err(Error::MissingApiKey);
        }
        if payload.from.trim().is_empty() {
            return Err(Error::Validation("from is required".into()));
        }
        if payload.to.is_empty() {
            return Err(Error::Validation("to is required".into()));
        }
        if payload.subject.trim().is_empty() {
            return Err(Error::Validation("subject is required".into()));
        }
        if payload.html.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true)
            && payload.text.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true)
        {
            return Err(Error::Validation("html or text body is required".into()));
        }

        let url = format!("{}/email/send", self.base_url);
        let response = self
            .http
            .post(url)
            .bearer_auth(&self.api_key)
            .header("Accept", "application/json")
            .json(&payload)
            .send()
            .await?;

        let status = response.status();
        let body_text = response.text().await.unwrap_or_default();
        let json: Option<serde_json::Value> = serde_json::from_str(&body_text).ok();

        if !status.is_success() {
            let message = json
                .as_ref()
                .and_then(|v| v.get("error"))
                .and_then(|v| v.as_str())
                .map(|s| s.to_string())
                .unwrap_or_else(|| {
                    if body_text.is_empty() {
                        format!("Resent API error ({})", status.as_u16())
                    } else {
                        body_text.clone()
                    }
                });
            return Err(Error::Api {
                status: status.as_u16(),
                message,
                body: json,
            });
        }

        serde_json::from_str(&body_text).map_err(|err| Error::Validation(err.to_string()))
    }
}

/// Email API namespace (`resent.emails()`).
#[derive(Debug)]
pub struct Emails<'a> {
    client: &'a Resent,
}

impl Emails<'_> {
    /// Send a transactional email.
    pub async fn send(&self, payload: SendEmail) -> Result<SendEmailResponse, Error> {
        self.client.send_email(payload).await
    }
}

/// Request body for `POST /email/send`.
#[derive(Clone, Debug, Default, Serialize)]
pub struct SendEmail {
    pub from: String,
    pub to: Vec<String>,
    pub subject: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub html: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub text: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cc: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bcc: Option<Vec<String>>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub reply_to: Option<String>,
}

impl SendEmail {
    pub fn new(
        from: impl Into<String>,
        to: impl IntoIterator<Item = impl Into<String>>,
        subject: impl Into<String>,
    ) -> Self {
        Self {
            from: from.into(),
            to: to.into_iter().map(Into::into).collect(),
            subject: subject.into(),
            ..Default::default()
        }
    }

    pub fn html(mut self, html: impl Into<String>) -> Self {
        self.html = Some(html.into());
        self
    }

    pub fn text(mut self, text: impl Into<String>) -> Self {
        self.text = Some(text.into());
        self
    }

    pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
        self.reply_to = Some(reply_to.into());
        self
    }
}

/// Successful send response.
#[derive(Clone, Debug, Deserialize)]
pub struct SendEmailResponse {
    pub id: Option<String>,
    pub submission_id: Option<String>,
    pub status: Option<String>,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn validates_missing_body() {
        let client = Resent::new("test_key");
        let err = tokio::runtime::Runtime::new()
            .unwrap()
            .block_on(client.emails().send(SendEmail {
                from: "a@b.com".into(),
                to: vec!["c@d.com".into()],
                subject: "hi".into(),
                ..Default::default()
            }))
            .unwrap_err();
        match err {
            Error::Validation(msg) => assert!(msg.contains("html or text")),
            other => panic!("unexpected {other:?}"),
        }
    }
}