use crate::Endpoint;
use bon::Builder;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
use std::collections::HashMap;
#[derive(Debug, Clone, PartialEq, Serialize, Builder)]
pub struct SendEmailRequest {
#[builder(into)]
pub from: String,
#[builder(into)]
pub to: Vec<String>,
#[builder(into)]
pub subject: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub html: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub cc: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub bcc: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub reply_to: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub headers: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub attachments: Option<Vec<Attachment>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub route: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub metadata: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub tag: Option<String>,
#[serde(skip)]
#[builder(into)]
pub idempotency_key: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Builder)]
pub struct Attachment {
#[builder(into)]
pub filename: String,
#[builder(into)]
pub content: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub content_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[builder(into)]
pub content_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SendEmailResponse {
pub message_id: String,
pub status: EmailStatus,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
#[serde(rename_all = "snake_case")]
pub enum EmailStatus {
Pending,
Queued,
Suppressed,
Processed,
Delivered,
Opened,
Clicked,
SoftBounced,
HardBounced,
SpamComplaint,
Failed,
Blocked,
PolicyRejected,
Unsubscribed,
#[serde(other)]
Unknown,
}
impl std::fmt::Display for EmailStatus {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Pending => write!(f, "pending"),
Self::Queued => write!(f, "queued"),
Self::Suppressed => write!(f, "suppressed"),
Self::Processed => write!(f, "processed"),
Self::Delivered => write!(f, "delivered"),
Self::Opened => write!(f, "opened"),
Self::Clicked => write!(f, "clicked"),
Self::SoftBounced => write!(f, "soft_bounced"),
Self::HardBounced => write!(f, "hard_bounced"),
Self::SpamComplaint => write!(f, "spam_complaint"),
Self::Failed => write!(f, "failed"),
Self::Blocked => write!(f, "blocked"),
Self::PolicyRejected => write!(f, "policy_rejected"),
Self::Unsubscribed => write!(f, "unsubscribed"),
Self::Unknown => write!(f, "unknown"),
}
}
}
impl Endpoint for SendEmailRequest {
type Request = SendEmailRequest;
type Response = SendEmailResponse;
fn endpoint(&self) -> Cow<'static, str> {
"send".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;
#[test]
fn serialize_minimal_request() {
let req = SendEmailRequest::builder()
.from("sender@example.com")
.to(vec!["recipient@example.com".into()])
.subject("Hello")
.text("Hi there!")
.build();
assert_eq!(
serde_json::to_value(&req).unwrap(),
json!({
"from": "sender@example.com",
"to": ["recipient@example.com"],
"subject": "Hello",
"text": "Hi there!",
})
);
}
#[test]
fn serialize_full_request() {
let req = SendEmailRequest::builder()
.from("John Doe <john@example.com>")
.to(vec!["user1@example.com".into(), "user2@example.com".into()])
.subject("Newsletter")
.html("<h1>News</h1>")
.text("News")
.cc(vec!["cc@example.com".into()])
.bcc(vec!["bcc@example.com".into()])
.reply_to(vec!["reply@example.com".into()])
.tag("newsletter")
.route("my-route")
.idempotency_key("unique-123")
.build();
let val = serde_json::to_value(&req).unwrap();
assert_eq!(val["from"], "John Doe <john@example.com>");
assert_eq!(val["to"], json!(["user1@example.com", "user2@example.com"]));
assert_eq!(val["cc"], json!(["cc@example.com"]));
assert_eq!(val["tag"], "newsletter");
assert_eq!(val["route"], "my-route");
assert!(val.get("idempotency_key").is_none());
}
#[test]
fn serialize_with_attachment() {
let req = SendEmailRequest::builder()
.from("sender@example.com")
.to(vec!["recipient@example.com".into()])
.subject("With attachment")
.text("See attached")
.attachments(vec![
Attachment::builder()
.filename("report.pdf")
.content("base64content")
.build(),
Attachment::builder()
.filename("logo.png")
.content("base64logo")
.content_id("logo")
.build(),
])
.build();
let val = serde_json::to_value(&req).unwrap();
let attachments = val["attachments"].as_array().unwrap();
assert_eq!(attachments.len(), 2);
assert_eq!(attachments[0]["filename"], "report.pdf");
assert!(attachments[0].get("content_id").is_none());
assert_eq!(attachments[1]["content_id"], "logo");
}
#[test]
fn deserialize_response() {
let json = r#"{"message_id":"abc-123","status":"queued"}"#;
let resp: SendEmailResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.message_id, "abc-123");
assert!(matches!(resp.status, EmailStatus::Queued));
}
#[test]
fn deserialize_unknown_status() {
let json = r#"{"message_id":"abc-123","status":"deferred"}"#;
let resp: SendEmailResponse = serde_json::from_str(json).unwrap();
assert_eq!(resp.message_id, "abc-123");
assert!(matches!(resp.status, EmailStatus::Unknown));
}
#[test]
fn idempotency_key_in_extra_headers() {
let req = SendEmailRequest::builder()
.from("sender@example.com")
.to(vec!["recipient@example.com".into()])
.subject("Test")
.text("Test")
.idempotency_key("my-key")
.build();
let headers = req.extra_headers();
assert_eq!(headers.len(), 1);
assert_eq!(headers[0].0, "Idempotency-Key");
assert_eq!(headers[0].1, "my-key");
}
#[test]
fn no_extra_headers_without_idempotency_key() {
let req = SendEmailRequest::builder()
.from("sender@example.com")
.to(vec!["recipient@example.com".into()])
.subject("Test")
.text("Test")
.build();
assert!(req.extra_headers().is_empty());
}
}