use crate::Endpoint;
use bon::bon;
use serde::Serialize;
use std::borrow::Cow;
use thiserror::Error;
use super::{SendEmailRequest, SendEmailResponse};
#[derive(Debug, Error)]
pub enum BatchError {
#[error("batch must contain at least one email")]
Empty,
#[error("batch exceeds maximum size of {max} (got {actual})")]
TooLarge { max: usize, actual: usize },
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(transparent)]
pub struct BatchSendRequest {
emails: Vec<SendEmailRequest>,
#[serde(skip)]
idempotency_key: Option<String>,
}
pub const BATCH_MAX_SIZE: usize = 500;
#[bon]
impl BatchSendRequest {
#[builder]
pub fn new(
emails: Vec<SendEmailRequest>,
#[builder(into)] idempotency_key: Option<String>,
) -> Result<Self, BatchError> {
if emails.is_empty() {
return Err(BatchError::Empty);
}
if emails.len() > BATCH_MAX_SIZE {
return Err(BatchError::TooLarge {
max: BATCH_MAX_SIZE,
actual: emails.len(),
});
}
Ok(Self {
emails,
idempotency_key,
})
}
#[must_use]
pub fn len(&self) -> usize {
self.emails.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.emails.is_empty()
}
}
impl Endpoint for BatchSendRequest {
type Request = BatchSendRequest;
type Response = Vec<SendEmailResponse>;
fn endpoint(&self) -> Cow<'static, str> {
"send/batch".into()
}
fn body(&self) -> &Self::Request {
self
}
fn extra_headers(&self) -> Vec<(Cow<'static, str>, Cow<'static, str>)> {
let mut headers = vec![];
if let Some(key) = &self.idempotency_key {
headers.push((Cow::Borrowed("Idempotency-Key"), Cow::Owned(key.clone())));
}
headers
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn minimal_email(to: &str) -> SendEmailRequest {
SendEmailRequest::builder()
.from("sender@example.com")
.to(vec![to.into()])
.subject("Test")
.text("Hello")
.build()
}
#[test]
fn new_rejects_empty() {
assert!(matches!(
BatchSendRequest::builder().emails(vec![]).build(),
Err(BatchError::Empty)
));
}
#[test]
fn new_rejects_over_500() {
let emails: Vec<_> = (0..501)
.map(|i| minimal_email(&format!("user{i}@example.com")))
.collect();
assert!(matches!(
BatchSendRequest::builder().emails(emails).build(),
Err(BatchError::TooLarge {
max: 500,
actual: 501
})
));
}
#[test]
fn new_accepts_valid_batch() {
let batch = BatchSendRequest::builder()
.emails(vec![
minimal_email("a@example.com"),
minimal_email("b@example.com"),
])
.build();
assert!(batch.is_ok());
assert_eq!(batch.unwrap().len(), 2);
}
#[test]
fn serializes_as_array() {
let batch = BatchSendRequest::builder()
.emails(vec![
minimal_email("a@example.com"),
minimal_email("b@example.com"),
])
.build()
.unwrap();
let val = serde_json::to_value(&batch).unwrap();
let arr = val.as_array().unwrap();
assert_eq!(arr.len(), 2);
assert_eq!(arr[0]["to"], json!(["a@example.com"]));
assert_eq!(arr[1]["to"], json!(["b@example.com"]));
}
#[test]
fn endpoint_path() {
let batch = BatchSendRequest::builder()
.emails(vec![minimal_email("a@example.com")])
.build()
.unwrap();
assert_eq!(batch.endpoint(), "send/batch");
}
#[test]
fn idempotency_key_header() {
let batch = BatchSendRequest::builder()
.emails(vec![minimal_email("a@example.com")])
.idempotency_key("batch-key-123")
.build()
.unwrap();
let headers = batch.extra_headers();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, "Idempotency-Key");
assert_eq!(headers[0].1, "batch-key-123");
}
}