use async_trait::async_trait;
use base64::Engine;
use chrono::{DateTime, Utc};
use reqwest::Client;
use ring::hmac;
use sha2::{Digest, Sha256};
use crate::email::Email;
use crate::error::MailError;
use crate::mailer::{DeliveryResult, Mailer};
const SERVICE_NAME: &str = "ses";
const ACTION: &str = "SendRawEmail";
const VERSION: &str = "2010-12-01";
const ENCODING: &str = "AWS4-HMAC-SHA256";
pub struct AmazonSesMailer {
region: String,
access_key: String,
secret: String,
host: Option<String>,
client: Client,
ses_source: Option<String>,
ses_source_arn: Option<String>,
ses_from_arn: Option<String>,
ses_return_path_arn: Option<String>,
}
impl AmazonSesMailer {
pub fn new(
region: impl Into<String>,
access_key: impl Into<String>,
secret: impl Into<String>,
) -> Self {
Self {
region: region.into(),
access_key: access_key.into(),
secret: secret.into(),
host: None,
client: Client::new(),
ses_source: None,
ses_source_arn: None,
ses_from_arn: None,
ses_return_path_arn: None,
}
}
pub fn with_client(
region: impl Into<String>,
access_key: impl Into<String>,
secret: impl Into<String>,
client: Client,
) -> Self {
Self {
region: region.into(),
access_key: access_key.into(),
secret: secret.into(),
host: None,
client,
ses_source: None,
ses_source_arn: None,
ses_from_arn: None,
ses_return_path_arn: None,
}
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}
pub fn ses_source(mut self, source: impl Into<String>) -> Self {
self.ses_source = Some(source.into());
self
}
pub fn ses_source_arn(mut self, arn: impl Into<String>) -> Self {
self.ses_source_arn = Some(arn.into());
self
}
pub fn ses_from_arn(mut self, arn: impl Into<String>) -> Self {
self.ses_from_arn = Some(arn.into());
self
}
pub fn ses_return_path_arn(mut self, arn: impl Into<String>) -> Self {
self.ses_return_path_arn = Some(arn.into());
self
}
fn base_url(&self) -> String {
match &self.host {
Some(host) => host.clone(),
None => format!("https://email.{}.amazonaws.com", self.region),
}
}
fn host_header(&self) -> String {
format!("email.{}.amazonaws.com", self.region)
}
fn build_body(&self, email: &Email) -> Result<String, MailError> {
let raw_message = build_mime_message(email)?;
let encoded = base64::engine::general_purpose::STANDARD.encode(&raw_message);
let url_encoded = urlencoding::encode(&encoded);
let mut params = vec![
("Action".to_string(), ACTION.to_string()),
("Version".to_string(), VERSION.to_string()),
("RawMessage.Data".to_string(), url_encoded.into_owned()),
];
if let Some(ref source) = self.ses_source {
params.push(("Source".to_string(), source.clone()));
}
if let Some(ref source_arn) = self.ses_source_arn {
params.push(("SourceArn".to_string(), source_arn.clone()));
}
if let Some(ref from_arn) = self.ses_from_arn {
params.push(("FromArn".to_string(), from_arn.clone()));
}
if let Some(ref return_path_arn) = self.ses_return_path_arn {
params.push(("ReturnPathArn".to_string(), return_path_arn.clone()));
}
if let Some(config_set) = email.provider_options.get("configuration_set_name") {
if let Some(name) = config_set.as_str() {
params.push(("ConfigurationSetName".to_string(), name.to_string()));
}
}
if let Some(tags) = email.provider_options.get("tags") {
if let Some(arr) = tags.as_array() {
for (i, tag) in arr.iter().enumerate() {
let index = i + 1;
if let (Some(name), Some(value)) = (
tag.get("name").and_then(|v| v.as_str()),
tag.get("value").and_then(|v| v.as_str()),
) {
params.push((format!("Tags.member.{}.Name", index), name.to_string()));
params.push((format!("Tags.member.{}.Value", index), value.to_string()));
}
}
}
}
params.sort_by(|a, b| a.0.cmp(&b.0));
let body = params
.iter()
.map(|(k, v)| format!("{}={}", k, v))
.collect::<Vec<_>>()
.join("&");
Ok(body)
}
fn sign_request(
&self,
body: &str,
date_time: DateTime<Utc>,
security_token: Option<&str>,
) -> Vec<(String, String)> {
let host = self.host_header();
let amz_date_str = amz_datetime(&date_time);
let date = amz_date(&date_time);
let mut headers = vec![
(
"Content-Type".to_string(),
"application/x-www-form-urlencoded".to_string(),
),
("Host".to_string(), host.clone()),
("X-Amz-Date".to_string(), amz_date_str.clone()),
("Content-Length".to_string(), body.len().to_string()),
];
if let Some(token) = security_token {
headers.push(("X-Amz-Security-Token".to_string(), token.to_string()));
}
headers.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
let signed_headers = headers
.iter()
.map(|(k, _)| k.to_lowercase())
.collect::<Vec<_>>()
.join(";");
let canonical_headers = headers
.iter()
.map(|(k, v)| format!("{}:{}", k.to_lowercase(), v))
.collect::<Vec<_>>()
.join("\n");
let body_hash = hex_sha256(body.as_bytes());
let canonical_request = format!(
"POST\n/\n\n{}\n\n{}\n{}",
canonical_headers, signed_headers, body_hash
);
let request_hash = hex_sha256(canonical_request.as_bytes());
let credential_scope = format!("{}/{}/{}/aws4_request", date, self.region, SERVICE_NAME);
let string_to_sign = format!(
"{}\n{}\n{}\n{}",
ENCODING, amz_date_str, credential_scope, request_hash
);
let signature = self.generate_signature(&string_to_sign, &date_time);
let authorization = format!(
"{} Credential={}/{}, SignedHeaders={}, Signature={}",
ENCODING, self.access_key, credential_scope, signed_headers, signature
);
headers.push(("Authorization".to_string(), authorization));
headers
}
fn generate_signature(&self, string_to_sign: &str, date_time: &DateTime<Utc>) -> String {
let date = amz_date(date_time);
let k_secret = format!("AWS4{}", self.secret);
let k_date = hmac_sha256(k_secret.as_bytes(), date.as_bytes());
let k_region = hmac_sha256(&k_date, self.region.as_bytes());
let k_service = hmac_sha256(&k_region, SERVICE_NAME.as_bytes());
let k_signing = hmac_sha256(&k_service, b"aws4_request");
let signature = hmac_sha256(&k_signing, string_to_sign.as_bytes());
hex::encode(signature)
}
}
fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
let key = hmac::Key::new(hmac::HMAC_SHA256, key);
hmac::sign(&key, data).as_ref().to_vec()
}
fn hex_sha256(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
fn amz_date(dt: &DateTime<Utc>) -> String {
dt.format("%Y%m%d").to_string()
}
fn amz_datetime(dt: &DateTime<Utc>) -> String {
dt.format("%Y%m%dT%H%M%SZ").to_string()
}
fn build_mime_message(email: &Email) -> Result<Vec<u8>, MailError> {
let from = email.from.as_ref().ok_or(MailError::MissingField("from"))?;
if email.to.is_empty() {
return Err(MailError::MissingField("to"));
}
let mut message = String::new();
let boundary = format!(
"----=_Part_{}",
uuid::Uuid::new_v4().to_string().replace("-", "")
);
message.push_str(&format!("From: {}\r\n", from.formatted()));
message.push_str(&format!(
"To: {}\r\n",
email
.to
.iter()
.map(|a| a.formatted())
.collect::<Vec<_>>()
.join(", ")
));
if !email.cc.is_empty() {
message.push_str(&format!(
"Cc: {}\r\n",
email
.cc
.iter()
.map(|a| a.formatted())
.collect::<Vec<_>>()
.join(", ")
));
}
if let Some(reply_to) = email.reply_to.first() {
message.push_str(&format!("Reply-To: {}\r\n", reply_to.formatted()));
}
message.push_str(&format!("Subject: {}\r\n", email.subject));
message.push_str("MIME-Version: 1.0\r\n");
for (name, value) in &email.headers {
message.push_str(&format!("{}: {}\r\n", name, value));
}
let has_text = email.text_body.is_some();
let has_html = email.html_body.is_some();
let has_attachments = !email.attachments.is_empty();
let has_inline = email.attachments.iter().any(|a| a.is_inline());
if !has_attachments {
if has_text && has_html {
message.push_str(&format!(
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n\r\n",
boundary
));
message.push_str(&format!("--{}\r\n", boundary));
message.push_str("Content-Type: text/plain; charset=utf-8\r\n");
message.push_str("Content-Transfer-Encoding: quoted-printable\r\n\r\n");
message.push_str(email.text_body.as_ref().unwrap());
message.push_str("\r\n");
message.push_str(&format!("--{}\r\n", boundary));
message.push_str("Content-Type: text/html; charset=utf-8\r\n");
message.push_str("Content-Transfer-Encoding: quoted-printable\r\n\r\n");
message.push_str(email.html_body.as_ref().unwrap());
message.push_str("\r\n");
message.push_str(&format!("--{}--\r\n", boundary));
} else if has_html {
message.push_str("Content-Type: text/html; charset=utf-8\r\n");
message.push_str("Content-Transfer-Encoding: quoted-printable\r\n\r\n");
message.push_str(email.html_body.as_ref().unwrap());
} else if has_text {
message.push_str("Content-Type: text/plain; charset=utf-8\r\n");
message.push_str("Content-Transfer-Encoding: quoted-printable\r\n\r\n");
message.push_str(email.text_body.as_ref().unwrap());
} else {
message.push_str("Content-Type: text/plain; charset=utf-8\r\n\r\n");
}
} else {
let mixed_boundary = format!(
"----=_Mixed_{}",
uuid::Uuid::new_v4().to_string().replace("-", "")
);
let alt_boundary = format!(
"----=_Alt_{}",
uuid::Uuid::new_v4().to_string().replace("-", "")
);
let related_boundary = format!(
"----=_Related_{}",
uuid::Uuid::new_v4().to_string().replace("-", "")
);
message.push_str(&format!(
"Content-Type: multipart/mixed; boundary=\"{}\"\r\n\r\n",
mixed_boundary
));
message.push_str(&format!("--{}\r\n", mixed_boundary));
if has_inline && has_html {
message.push_str(&format!(
"Content-Type: multipart/related; boundary=\"{}\"\r\n\r\n",
related_boundary
));
message.push_str(&format!("--{}\r\n", related_boundary));
if has_text {
message.push_str(&format!(
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n\r\n",
alt_boundary
));
message.push_str(&format!("--{}\r\n", alt_boundary));
message.push_str("Content-Type: text/plain; charset=utf-8\r\n\r\n");
message.push_str(email.text_body.as_ref().unwrap());
message.push_str("\r\n");
message.push_str(&format!("--{}\r\n", alt_boundary));
message.push_str("Content-Type: text/html; charset=utf-8\r\n\r\n");
message.push_str(email.html_body.as_ref().unwrap());
message.push_str("\r\n");
message.push_str(&format!("--{}--\r\n", alt_boundary));
} else {
message.push_str("Content-Type: text/html; charset=utf-8\r\n\r\n");
message.push_str(email.html_body.as_ref().unwrap());
message.push_str("\r\n");
}
for attachment in email.attachments.iter().filter(|a| a.is_inline()) {
message.push_str(&format!("--{}\r\n", related_boundary));
message.push_str(&format!("Content-Type: {}\r\n", attachment.content_type));
message.push_str("Content-Transfer-Encoding: base64\r\n");
message.push_str(&format!(
"Content-Disposition: inline; filename=\"{}\"\r\n",
attachment.filename
));
if let Some(ref cid) = attachment.content_id {
message.push_str(&format!("Content-ID: <{}>\r\n", cid));
}
message.push_str("\r\n");
message.push_str(&attachment.base64_data());
message.push_str("\r\n");
}
message.push_str(&format!("--{}--\r\n", related_boundary));
} else if has_text && has_html {
message.push_str(&format!(
"Content-Type: multipart/alternative; boundary=\"{}\"\r\n\r\n",
alt_boundary
));
message.push_str(&format!("--{}\r\n", alt_boundary));
message.push_str("Content-Type: text/plain; charset=utf-8\r\n\r\n");
message.push_str(email.text_body.as_ref().unwrap());
message.push_str("\r\n");
message.push_str(&format!("--{}\r\n", alt_boundary));
message.push_str("Content-Type: text/html; charset=utf-8\r\n\r\n");
message.push_str(email.html_body.as_ref().unwrap());
message.push_str("\r\n");
message.push_str(&format!("--{}--\r\n", alt_boundary));
} else if has_html {
message.push_str("Content-Type: text/html; charset=utf-8\r\n\r\n");
message.push_str(email.html_body.as_ref().unwrap());
message.push_str("\r\n");
} else if has_text {
message.push_str("Content-Type: text/plain; charset=utf-8\r\n\r\n");
message.push_str(email.text_body.as_ref().unwrap());
message.push_str("\r\n");
}
for attachment in email.attachments.iter().filter(|a| !a.is_inline()) {
message.push_str(&format!("--{}\r\n", mixed_boundary));
message.push_str(&format!("Content-Type: {}\r\n", attachment.content_type));
message.push_str("Content-Transfer-Encoding: base64\r\n");
message.push_str(&format!(
"Content-Disposition: attachment; filename=\"{}\"\r\n",
attachment.filename
));
message.push_str("\r\n");
message.push_str(&attachment.base64_data());
message.push_str("\r\n");
}
message.push_str(&format!("--{}--\r\n", mixed_boundary));
}
Ok(message.into_bytes())
}
#[async_trait]
impl Mailer for AmazonSesMailer {
async fn deliver(&self, email: &Email) -> Result<DeliveryResult, MailError> {
let body = self.build_body(email)?;
let date_time = Utc::now();
let security_token = email
.provider_options
.get("security_token")
.and_then(|v| v.as_str());
let headers = self.sign_request(&body, date_time, security_token);
let url = self.base_url();
let mut request = self.client.post(&url);
for (name, value) in headers {
request = request.header(&name, &value);
}
request = request.header("User-Agent", format!("missive/{}", crate::VERSION));
request = request.body(body);
let response = request.send().await?;
let status = response.status();
let body = response.text().await?;
if status.is_success() {
let message_id = extract_xml_value(&body, "MessageId").unwrap_or_default();
let request_id = extract_xml_value(&body, "RequestId").unwrap_or_default();
Ok(DeliveryResult::with_response(
message_id,
serde_json::json!({
"provider": "amazon_ses",
"request_id": request_id,
}),
))
} else {
let error_code =
extract_xml_value(&body, "Code").unwrap_or_else(|| "Unknown".to_string());
let error_message =
extract_xml_value(&body, "Message").unwrap_or_else(|| "Unknown error".to_string());
Err(MailError::provider_with_status(
"amazon_ses",
format!("[{}] {}", error_code, error_message),
status.as_u16(),
))
}
}
fn provider_name(&self) -> &'static str {
"amazon_ses"
}
}
fn extract_xml_value(xml: &str, tag: &str) -> Option<String> {
let start_tag = format!("<{}>", tag);
let end_tag = format!("</{}>", tag);
let start = xml.find(&start_tag)? + start_tag.len();
let end = xml[start..].find(&end_tag)? + start;
Some(xml[start..end].to_string())
}