use reqwest::{multipart, Client, Response, StatusCode};
use std::time::Duration;
use crate::account_resource::AccountResource;
use crate::campaigns::CampaignsResource;
use crate::contacts::ContactsResource;
use crate::conversations::ConversationsResource;
use crate::drafts::DraftsResource;
use crate::enterprise::EnterpriseResource;
use crate::error::{ApiErrorResponse, Error, Result};
use crate::labels::LabelsResource;
use crate::media::Media;
use crate::rules::RulesResource;
use crate::messages::Messages;
use crate::templates::TemplatesResource;
use crate::verify::VerifyResource;
use crate::webhook_resource::WebhooksResource;
pub const DEFAULT_BASE_URL: &str = "https://sendly.live/api/v1";
pub const VERSION: &str = "0.9.5";
#[derive(Debug, Clone)]
pub struct SendlyConfig {
pub base_url: String,
pub timeout: Duration,
pub max_retries: u32,
pub organization_id: Option<String>,
}
impl Default for SendlyConfig {
fn default() -> Self {
Self {
base_url: DEFAULT_BASE_URL.to_string(),
timeout: Duration::from_secs(30),
max_retries: 3,
organization_id: None,
}
}
}
impl SendlyConfig {
pub fn new() -> Self {
Self::default()
}
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn max_retries(mut self, retries: u32) -> Self {
self.max_retries = retries;
self
}
pub fn organization_id(mut self, id: impl Into<String>) -> Self {
self.organization_id = Some(id.into());
self
}
}
#[derive(Debug, Clone)]
pub struct Sendly {
api_key: String,
config: SendlyConfig,
client: Client,
organization_id: Option<String>,
}
impl Sendly {
pub fn new(api_key: impl Into<String>) -> Self {
Self::with_config(api_key, SendlyConfig::default())
}
pub fn with_config(api_key: impl Into<String>, config: SendlyConfig) -> Self {
let client = Client::builder()
.timeout(config.timeout)
.build()
.expect("Failed to build HTTP client");
let organization_id = config
.organization_id
.clone()
.or_else(|| std::env::var("SENDLY_ORG_ID").ok());
Self {
api_key: api_key.into(),
config,
client,
organization_id,
}
}
pub fn messages(&self) -> Messages {
Messages::new(self)
}
pub fn webhooks(&self) -> WebhooksResource {
WebhooksResource::new(self)
}
pub fn account(&self) -> AccountResource {
AccountResource::new(self)
}
pub fn verify(&self) -> VerifyResource {
VerifyResource::new(self)
}
pub fn templates(&self) -> TemplatesResource {
TemplatesResource::new(self)
}
pub fn campaigns(&self) -> CampaignsResource {
CampaignsResource::new(self)
}
pub fn contacts(&self) -> ContactsResource {
ContactsResource::new(self)
}
pub fn conversations(&self) -> ConversationsResource {
ConversationsResource::new(self)
}
pub fn labels(&self) -> LabelsResource {
LabelsResource::new(self)
}
pub fn rules(&self) -> RulesResource {
RulesResource::new(self)
}
pub fn drafts(&self) -> DraftsResource {
DraftsResource::new(self)
}
pub fn media(&self) -> Media {
Media::new(self)
}
pub fn enterprise(&self) -> EnterpriseResource {
EnterpriseResource::new(self)
}
pub fn set_organization_id(&mut self, id: impl Into<String>) {
self.organization_id = Some(id.into());
}
pub(crate) async fn get(&self, path: &str, query: &[(String, String)]) -> Result<Response> {
self.request_with_retry(|| async {
let url = format!("{}{}", self.config.base_url, path);
let req = self
.client
.get(&url)
.query(query)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Accept", "application/json")
.header("User-Agent", format!("sendly-rs/{}", VERSION));
let req = if let Some(ref org_id) = self.organization_id {
req.header("X-Organization-Id", org_id)
} else {
req
};
req.send().await
})
.await
}
pub(crate) async fn post<T: serde::Serialize>(&self, path: &str, body: &T) -> Result<Response> {
self.request_with_retry(|| async {
let url = format!("{}{}", self.config.base_url, path);
let req = self
.client
.post(&url)
.json(body)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", format!("sendly-rs/{}", VERSION));
let req = if let Some(ref org_id) = self.organization_id {
req.header("X-Organization-Id", org_id)
} else {
req
};
req.send().await
})
.await
}
pub(crate) async fn put<T: serde::Serialize>(&self, path: &str, body: &T) -> Result<Response> {
self.request_with_retry(|| async {
let url = format!("{}{}", self.config.base_url, path);
let req = self
.client
.put(&url)
.json(body)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", format!("sendly-rs/{}", VERSION));
let req = if let Some(ref org_id) = self.organization_id {
req.header("X-Organization-Id", org_id)
} else {
req
};
req.send().await
})
.await
}
pub(crate) async fn patch<T: serde::Serialize>(
&self,
path: &str,
body: &T,
) -> Result<Response> {
self.request_with_retry(|| async {
let url = format!("{}{}", self.config.base_url, path);
let req = self
.client
.patch(&url)
.json(body)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")
.header("Accept", "application/json")
.header("User-Agent", format!("sendly-rs/{}", VERSION));
let req = if let Some(ref org_id) = self.organization_id {
req.header("X-Organization-Id", org_id)
} else {
req
};
req.send().await
})
.await
}
pub(crate) async fn post_multipart(
&self,
path: &str,
form: multipart::Form,
) -> Result<Response> {
let url = format!("{}{}", self.config.base_url, path);
let req = self
.client
.post(&url)
.multipart(form)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Accept", "application/json")
.header("User-Agent", format!("sendly-rs/{}", VERSION));
let req = if let Some(ref org_id) = self.organization_id {
req.header("X-Organization-Id", org_id)
} else {
req
};
let response = req
.send()
.await
.map_err(|e| {
if e.is_timeout() {
Error::Timeout
} else if e.is_connect() {
Error::Network {
message: e.to_string(),
}
} else {
Error::Http(e)
}
})?;
self.handle_response(response).await
}
pub(crate) async fn delete(&self, path: &str) -> Result<Response> {
self.request_with_retry(|| async {
let url = format!("{}{}", self.config.base_url, path);
let req = self
.client
.delete(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Accept", "application/json")
.header("User-Agent", format!("sendly-rs/{}", VERSION));
let req = if let Some(ref org_id) = self.organization_id {
req.header("X-Organization-Id", org_id)
} else {
req
};
req.send().await
})
.await
}
async fn request_with_retry<F, Fut>(&self, request_fn: F) -> Result<Response>
where
F: Fn() -> Fut,
Fut: std::future::Future<Output = std::result::Result<Response, reqwest::Error>>,
{
let mut last_error: Option<Error> = None;
for attempt in 0..=self.config.max_retries {
if attempt > 0 {
let delay = Duration::from_secs(2u64.pow(attempt - 1));
tokio::time::sleep(delay).await;
}
match request_fn().await {
Ok(response) => {
return self.handle_response(response).await;
}
Err(e) => {
if e.is_timeout() {
last_error = Some(Error::Timeout);
} else if e.is_connect() {
last_error = Some(Error::Network {
message: e.to_string(),
});
} else {
return Err(Error::Http(e));
}
}
}
}
Err(last_error.unwrap_or(Error::Network {
message: "Request failed after retries".to_string(),
}))
}
async fn handle_response(&self, response: Response) -> Result<Response> {
let status = response.status();
if status.is_success() {
return Ok(response);
}
let retry_after = response
.headers()
.get("Retry-After")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok());
let error_body: ApiErrorResponse = response.json().await.unwrap_or(ApiErrorResponse {
message: None,
error: None,
code: None,
});
let message = error_body.message();
Err(match status {
StatusCode::UNAUTHORIZED => Error::Authentication { message },
StatusCode::PAYMENT_REQUIRED => Error::InsufficientCredits { message },
StatusCode::NOT_FOUND => Error::NotFound { message },
StatusCode::TOO_MANY_REQUESTS => Error::RateLimit {
message,
retry_after,
},
StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => {
Error::Validation { message }
}
_ => Error::Api {
message,
status_code: status.as_u16(),
code: error_body.code,
},
})
}
}