use async_trait::async_trait;
use reqwest::Client;
use serde::Serialize;
use tracing::debug;
use crate::{Email, MailError, Result, Transport};
#[derive(Debug, Clone)]
pub struct SendGridConfig {
pub api_key: String,
pub endpoint: String,
}
impl SendGridConfig {
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
endpoint: "https://api.sendgrid.com/v3/mail/send".to_string(),
}
}
pub fn endpoint(mut self, endpoint: impl Into<String>) -> Self {
self.endpoint = endpoint.into();
self
}
}
pub struct SendGridTransport {
client: Client,
config: SendGridConfig,
}
impl SendGridTransport {
pub fn new(config: SendGridConfig) -> Self {
Self {
client: Client::new(),
config,
}
}
}
#[async_trait]
impl Transport for SendGridTransport {
async fn send(&self, email: &Email) -> Result<()> {
email.validate()?;
let payload = SendGridPayload::from_email(email)?;
debug!(
to = ?email.to.iter().map(|a| &a.email).collect::<Vec<_>>(),
subject = ?email.subject,
"Sending email via SendGrid"
);
let response = self
.client
.post(&self.config.endpoint)
.header("Authorization", format!("Bearer {}", self.config.api_key))
.header("Content-Type", "application/json")
.json(&payload)
.send()
.await
.map_err(|e| MailError::Network(e.to_string()))?;
let status = response.status();
if status.is_success() {
debug!("Email sent successfully via SendGrid");
Ok(())
} else if status.as_u16() == 429 {
let retry_after = response
.headers()
.get("retry-after")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse().ok())
.unwrap_or(60);
Err(MailError::RateLimited(retry_after))
} else {
let body = response.text().await.unwrap_or_default();
Err(MailError::Provider(format!(
"SendGrid error {}: {}",
status, body
)))
}
}
}
#[derive(Debug, Serialize)]
struct SendGridPayload {
personalizations: Vec<Personalization>,
from: EmailAddress,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<EmailAddress>,
subject: String,
content: Vec<Content>,
#[serde(skip_serializing_if = "Vec::is_empty")]
attachments: Vec<SendGridAttachment>,
}
#[derive(Debug, Serialize)]
struct Personalization {
to: Vec<EmailAddress>,
#[serde(skip_serializing_if = "Vec::is_empty")]
cc: Vec<EmailAddress>,
#[serde(skip_serializing_if = "Vec::is_empty")]
bcc: Vec<EmailAddress>,
}
#[derive(Debug, Serialize)]
struct EmailAddress {
email: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
#[derive(Debug, Serialize)]
struct Content {
#[serde(rename = "type")]
content_type: String,
value: String,
}
#[derive(Debug, Serialize)]
struct SendGridAttachment {
content: String,
filename: String,
#[serde(rename = "type")]
content_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
content_id: Option<String>,
disposition: String,
}
impl SendGridPayload {
fn from_email(email: &Email) -> Result<Self> {
use base64::Engine;
let from = email.from.as_ref().ok_or(MailError::MissingField("from"))?;
let mut content = Vec::new();
if let Some(text) = &email.text {
content.push(Content {
content_type: "text/plain".to_string(),
value: text.clone(),
});
}
if let Some(html) = &email.html {
content.push(Content {
content_type: "text/html".to_string(),
value: html.clone(),
});
}
let attachments: Vec<SendGridAttachment> = email
.attachments
.iter()
.map(|a| SendGridAttachment {
content: base64::engine::general_purpose::STANDARD.encode(&a.data),
filename: a.filename.clone(),
content_type: a.content_type.clone(),
content_id: a.content_id.clone(),
disposition: match a.disposition {
crate::ContentDisposition::Attachment => "attachment",
crate::ContentDisposition::Inline => "inline",
}
.to_string(),
})
.collect();
Ok(Self {
personalizations: vec![Personalization {
to: email
.to
.iter()
.map(|a| EmailAddress {
email: a.email.clone(),
name: a.name.clone(),
})
.collect(),
cc: email
.cc
.iter()
.map(|a| EmailAddress {
email: a.email.clone(),
name: a.name.clone(),
})
.collect(),
bcc: email
.bcc
.iter()
.map(|a| EmailAddress {
email: a.email.clone(),
name: a.name.clone(),
})
.collect(),
}],
from: EmailAddress {
email: from.email.clone(),
name: from.name.clone(),
},
reply_to: email.reply_to.as_ref().map(|a| EmailAddress {
email: a.email.clone(),
name: a.name.clone(),
}),
subject: email.subject.clone().unwrap_or_default(),
content,
attachments,
})
}
}