use super::{
InputError, ServiceRejection, SesClient, SesError,
transport::{ProtectedBytes, ProtectedText},
};
use crate::domain::mail::identity::EmailAddress;
use serde::{Deserialize, Serialize};
use std::fmt;
use url::Url;
use uuid::Uuid;
use zeroize::Zeroizing;
pub(in crate::domain::mail) const MAX_BODY_BYTES: usize = 96 * 1024;
const MAX_UNSUBSCRIBE_URL_BYTES: usize = 978;
pub(in crate::domain::mail) struct EmailMessage<'a> {
pub(in crate::domain::mail) recipient: &'a EmailAddress,
pub(in crate::domain::mail) subject: &'a str,
pub(in crate::domain::mail) text: &'a str,
pub(in crate::domain::mail) html: &'a str,
pub(in crate::domain::mail) campaign_id: Option<Uuid>,
pub(in crate::domain::mail) attempt_id: Uuid,
pub(in crate::domain::mail) mail_epoch: Uuid,
pub(in crate::domain::mail) one_click: Option<&'a OneClickUrl>,
}
pub(in crate::domain::mail) struct OneClickUrl(ProtectedText);
impl OneClickUrl {
pub(in crate::domain::mail) fn as_str(&self) -> &str {
self.0.as_str()
}
pub(in crate::domain::mail) fn parse(value: &str) -> Result<Self, InputError> {
if value.len() > MAX_UNSUBSCRIBE_URL_BYTES
|| !value
.bytes()
.all(|byte| (33..=126).contains(&byte) && !b"<>".contains(&byte))
{
return Err(InputError::OneClickUrl);
}
let parsed = Url::parse(value).map_err(|_| InputError::OneClickUrl)?;
let valid = parsed.scheme() == "https"
&& parsed.host_str().is_some()
&& parsed.username().is_empty()
&& parsed.password().is_none()
&& parsed.fragment().is_none();
let canonical = Zeroizing::new(String::from(parsed));
if !valid || canonical.as_str() != value {
return Err(InputError::OneClickUrl);
}
Ok(Self(ProtectedText::from_owned(canonical)))
}
}
#[derive(Eq, PartialEq)]
pub(crate) struct MessageId(Zeroizing<String>);
impl fmt::Debug for MessageId {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("MessageId([redacted])")
}
}
impl MessageId {
pub(in crate::domain::mail) fn parse(value: &str) -> Result<Self, SesError> {
if value.is_empty()
|| value.len() > 256
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_".contains(&byte))
{
return Err(SesError::InvalidResponse);
}
Ok(Self(Zeroizing::new(value.to_owned())))
}
pub(in crate::domain::mail) fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Eq, PartialEq)]
pub(in crate::domain::mail) enum SendOutcome {
Accepted(MessageId),
Rejected(ServiceRejection),
Retryable(ServiceRejection),
Unknown,
}
#[derive(Serialize)]
struct SendRequest<'a> {
#[serde(rename = "FromEmailAddress")]
from: &'a str,
#[serde(rename = "ConfigurationSetName")]
configuration_set: &'a str,
#[serde(rename = "Destination")]
destination: Destination<'a>,
#[serde(rename = "Content")]
content: Content<'a>,
#[serde(rename = "EmailTags")]
tags: Vec<Tag<'a>>,
}
#[derive(Serialize)]
struct Destination<'a> {
#[serde(rename = "ToAddresses")]
to: [&'a str; 1],
}
#[derive(Serialize)]
struct Content<'a> {
#[serde(rename = "Simple")]
simple: Simple<'a>,
}
#[derive(Serialize)]
struct Simple<'a> {
#[serde(rename = "Subject")]
subject: Text<'a>,
#[serde(rename = "Body")]
body: Body<'a>,
#[serde(rename = "Headers", skip_serializing_if = "Vec::is_empty")]
headers: Vec<Header<'a>>,
}
#[derive(Serialize)]
struct Body<'a> {
#[serde(rename = "Text")]
text: Text<'a>,
#[serde(rename = "Html")]
html: Text<'a>,
}
#[derive(Serialize)]
struct Text<'a> {
#[serde(rename = "Data")]
data: &'a str,
#[serde(rename = "Charset")]
charset: &'static str,
}
#[derive(Serialize)]
struct Header<'a> {
#[serde(rename = "Name")]
name: &'static str,
#[serde(rename = "Value")]
value: &'a str,
}
#[derive(Serialize)]
struct Tag<'a> {
#[serde(rename = "Name")]
name: &'static str,
#[serde(rename = "Value")]
value: &'a str,
}
impl SesClient {
pub(in crate::domain::mail) async fn send(
&self,
message: &EmailMessage<'_>,
) -> Result<SendOutcome, SesError> {
let body = self.prepare_message(message)?;
let reply = self.request(body).await;
let body = match reply.and_then(|reply| reply.require_ok()) {
Ok(body) => body,
Err(SesError::Unknown) => return Ok(SendOutcome::Unknown),
Err(SesError::Rejected(reason)) => return Ok(classify_rejection(reason)),
Err(error) => return Err(error),
};
#[derive(Deserialize)]
struct SendResponse {
#[serde(rename = "MessageId")]
message_id: ProtectedText,
}
Ok(serde_json::from_slice::<SendResponse>(body.as_ref())
.ok()
.and_then(|reply| MessageId::parse(reply.message_id.as_str()).ok())
.map_or(SendOutcome::Unknown, SendOutcome::Accepted))
}
fn prepare_message(&self, message: &EmailMessage<'_>) -> Result<ProtectedBytes, SesError> {
validate_message(message)?;
let campaign_id = message.campaign_id.map(|id| id.to_string());
let mut attempt_buffer = Zeroizing::new([0_u8; 36]);
let attempt_id = message
.attempt_id
.hyphenated()
.encode_lower(&mut *attempt_buffer);
let mut epoch_buffer = Zeroizing::new([0_u8; 36]);
let mail_epoch = message
.mail_epoch
.hyphenated()
.encode_lower(&mut *epoch_buffer);
let mut tags = Vec::with_capacity(3);
if let Some(campaign_id) = campaign_id.as_ref() {
tags.push(Tag {
name: "maincopy-campaign",
value: campaign_id,
});
}
tags.push(Tag {
name: "maincopy-attempt",
value: attempt_id,
});
tags.push(Tag {
name: "maincopy-epoch",
value: mail_epoch,
});
let mut unsubscribe = Zeroizing::new(String::new());
let headers = match message.one_click {
Some(url) => {
unsubscribe.reserve(url.0.as_str().len() + 2);
unsubscribe.push('<');
unsubscribe.push_str(url.0.as_str());
unsubscribe.push('>');
vec![
Header {
name: "List-Unsubscribe",
value: &unsubscribe,
},
Header {
name: "List-Unsubscribe-Post",
value: "List-Unsubscribe=One-Click",
},
]
}
None => Vec::new(),
};
let wire = SendRequest {
from: self.configuration.sender.as_str(),
configuration_set: self.configuration.configuration_set.as_str(),
destination: Destination {
to: [message.recipient.as_str()],
},
content: Content {
simple: Simple {
subject: Text {
data: message.subject,
charset: "UTF-8",
},
body: Body {
text: Text {
data: message.text,
charset: "UTF-8",
},
html: Text {
data: message.html,
charset: "UTF-8",
},
},
headers,
},
},
tags,
};
ProtectedBytes::json(&wire)
}
}
fn validate_message(message: &EmailMessage<'_>) -> Result<(), InputError> {
if message.subject.is_empty()
|| message.subject.len() > 998
|| message.subject.chars().any(char::is_control)
|| message.text.is_empty()
|| message.html.is_empty()
|| message.text.len() > MAX_BODY_BYTES
|| message.html.len() > MAX_BODY_BYTES
|| message.campaign_id.is_some_and(|id| id.is_nil())
|| message.attempt_id.is_nil()
|| message.mail_epoch.is_nil()
{
return Err(InputError::Message);
}
Ok(())
}
fn classify_rejection(reason: ServiceRejection) -> SendOutcome {
match reason {
ServiceRejection::Throttled | ServiceRejection::LimitExceeded => {
SendOutcome::Retryable(reason)
}
ServiceRejection::AccessDenied
| ServiceRejection::InvalidRequest
| ServiceRejection::NotFound
| ServiceRejection::AlreadyExists
| ServiceRejection::SendingDisabled
| ServiceRejection::MessageRejected => SendOutcome::Rejected(reason),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn one_click_url_obeys_the_ses_combined_header_bound_and_ascii_contract() {
let prefix = "https://example.com/remove/";
assert!(OneClickUrl::parse(&format!("{prefix}{}", "x".repeat(978 - prefix.len()))).is_ok());
assert!(
OneClickUrl::parse(&format!("{prefix}{}", "x".repeat(979 - prefix.len()))).is_err()
);
for invalid in [
"https://example.com/é",
"https://example.com/<token>",
"https://user:secret@example.com/remove",
"https://example.com/#token",
"http://example.com/remove",
"https://example.com/\r\nX-Injected: yes",
] {
assert!(OneClickUrl::parse(invalid).is_err());
}
}
#[test]
fn malformed_provider_message_identifiers_cannot_escape_as_receipts() {
assert!(MessageId::parse("010001-abcdef-123456").is_ok());
assert!(MessageId::parse("person@example.com").is_err());
assert!(MessageId::parse("").is_err());
assert!(MessageId::parse(&"x".repeat(257)).is_err());
}
#[test]
fn accepted_delivery_debug_never_exposes_the_recipient_linkable_receipt() {
let receipt = MessageId::parse("010001-abcdef-123456").unwrap();
assert_eq!(receipt.as_str(), "010001-abcdef-123456");
assert_eq!(format!("{receipt:?}"), "MessageId([redacted])");
assert_eq!(
format!("{:?}", SendOutcome::Accepted(receipt)),
"Accepted(MessageId([redacted]))"
);
}
#[test]
fn missing_content_or_operation_identity_cannot_reach_the_transport() {
let recipient = EmailAddress::parse("reader@example.com").unwrap();
let valid = || EmailMessage {
recipient: &recipient,
subject: "Subject",
text: "Text",
html: "<p>Text</p>",
campaign_id: Some(Uuid::from_u128(1)),
mail_epoch: Uuid::from_u128(3),
attempt_id: Uuid::from_u128(2),
one_click: None,
};
assert!(validate_message(&valid()).is_ok());
let mut message = valid();
message.subject = "";
assert!(validate_message(&message).is_err());
let subject = "x".repeat(999);
let mut message = valid();
message.subject = &subject;
assert!(validate_message(&message).is_err());
let mut message = valid();
message.text = "";
assert!(validate_message(&message).is_err());
let mut message = valid();
message.html = "";
assert!(validate_message(&message).is_err());
let oversized = "x".repeat(MAX_BODY_BYTES + 1);
let mut message = valid();
message.text = &oversized;
assert!(validate_message(&message).is_err());
let mut message = valid();
message.html = &oversized;
assert!(validate_message(&message).is_err());
let mut message = valid();
message.campaign_id = Some(Uuid::nil());
assert!(validate_message(&message).is_err());
let mut message = valid();
message.attempt_id = Uuid::nil();
assert!(validate_message(&message).is_err());
let mut message = valid();
message.mail_epoch = Uuid::nil();
assert!(validate_message(&message).is_err());
}
}