use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use data_encoding::BASE64;
use reqwest::header::{self, HeaderMap, HeaderValue, InvalidHeaderValue};
use serde::Serialize;
use serde_json::{Map, to_value, value::Value, value::Value::Object};
use crate::error::{RequestNotSuccessful, SendgridError, SendgridResult};
use crate::v3::message::MailSettings;
#[cfg(feature = "blocking")]
use reqwest::blocking::Response as BlockingResponse;
use reqwest::{Client, Response};
mod clients;
pub mod message;
const V3_API_URL: &str = "https://api.sendgrid.com/v3/mail/send";
pub type SGMap<'a> = HashMap<&'a str, &'a str>;
#[derive(Clone, Debug)]
pub struct Sender<'a> {
client: Client,
#[cfg(feature = "blocking")]
blocking_client: reqwest::blocking::Client,
host: &'a str,
}
#[derive(Clone, Serialize)]
pub struct OpenTrackingSetting<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub enable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub substitution_tag: Option<&'a str>,
}
#[derive(Clone, Serialize)]
pub struct SubscriptionTrackingSetting {
#[serde(skip_serializing_if = "Option::is_none")]
pub enable: Option<bool>,
}
#[derive(Clone, Serialize)]
pub struct ClickTrackingSetting {
#[serde(skip_serializing_if = "Option::is_none")]
pub enable: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enable_text: Option<bool>,
}
#[derive(Clone, Serialize)]
pub struct TrackingSettings<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
pub click_tracking: Option<ClickTrackingSetting>,
#[serde(skip_serializing_if = "Option::is_none")]
pub open_tracking: Option<OpenTrackingSetting<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subscription_tracking: Option<SubscriptionTrackingSetting>,
}
#[derive(Serialize)]
pub struct Message<'a> {
from: Email<'a>,
subject: &'a str,
personalizations: Vec<Personalization<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
categories: Option<Vec<&'a str>>,
#[serde(skip_serializing_if = "Option::is_none")]
ip_pool_name: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to: Option<Email<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
reply_to_list: Option<Vec<Email<'a>>>,
#[serde(skip_serializing_if = "Option::is_none")]
content: Option<Vec<Content<'a>>>,
#[serde(skip_serializing_if = "Option::is_none")]
attachments: Option<Vec<Attachment<'a>>>,
#[serde(skip_serializing_if = "Option::is_none")]
template_id: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
tracking_settings: Option<TrackingSettings<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
asm: Option<ASM>,
#[serde(skip_serializing_if = "Option::is_none")]
mail_settings: Option<MailSettings>,
}
#[derive(Clone, Serialize)]
pub struct Email<'a> {
email: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<&'a str>,
}
#[derive(Clone, Default, Serialize)]
pub struct Content<'a> {
#[serde(rename = "type")]
content_type: &'a str,
value: &'a str,
}
#[derive(Default, Serialize)]
pub struct Personalization<'a> {
to: Vec<Email<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
cc: Option<Vec<Email<'a>>>,
#[serde(skip_serializing_if = "Option::is_none")]
bcc: Option<Vec<Email<'a>>>,
#[serde(skip_serializing_if = "Option::is_none")]
subject: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
headers: Option<SGMap<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
substitutions: Option<SGMap<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
custom_args: Option<SGMap<'a>>,
#[serde(skip_serializing_if = "Option::is_none")]
dynamic_template_data: Option<Map<String, Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
send_at: Option<u64>,
}
#[derive(Clone, Copy, Serialize)]
pub enum Disposition {
#[serde(rename = "inline")]
Inline,
#[serde(rename = "attachment")]
Attachment,
}
#[derive(Default, Serialize)]
pub struct Attachment<'a> {
content: Cow<'a, str>,
filename: &'a str,
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
mime_type: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
disposition: Option<Disposition>,
#[serde(skip_serializing_if = "Option::is_none")]
content_id: Option<&'a str>,
}
#[derive(Default, Serialize)]
pub struct ASM {
group_id: u32,
groups_to_display: HashSet<u32>,
}
impl<'a> Sender<'a> {
pub fn new(api_key: &str, client: Option<Client>) -> Self {
let client = client.unwrap_or(clients::new_client(api_key));
Self {
client,
#[cfg(feature = "blocking")]
blocking_client: clients::new_blocking_client(api_key),
host: V3_API_URL,
}
}
#[cfg(feature = "blocking")]
pub fn new_blocking(api_key: &str, blocking_client: Option<reqwest::blocking::Client>) -> Self {
let blocking_client = blocking_client.unwrap_or(clients::new_blocking_client(api_key));
Self {
client: clients::new_client(api_key),
#[cfg(feature = "blocking")]
blocking_client,
host: V3_API_URL,
}
}
pub fn set_host(&mut self, host: &'a str) {
self.host = host;
}
pub fn get_headers(api_key: &str) -> Result<HeaderMap, InvalidHeaderValue> {
let mut headers = HeaderMap::with_capacity(3);
let mut auth_value = HeaderValue::from_str(&format!("Bearer {api_key}"))?;
auth_value.set_sensitive(true);
headers.insert(header::AUTHORIZATION, auth_value);
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/json"),
);
headers.insert(header::USER_AGENT, HeaderValue::from_static("sendgrid-rs"));
Ok(headers)
}
pub async fn send(&self, mail: &Message<'a>) -> SendgridResult<Response> {
let resp = self
.client
.post(self.host)
.body(mail.gen_json())
.send()
.await?;
if resp.error_for_status_ref().is_err() {
return Err(RequestNotSuccessful::new(resp.status(), resp.text().await?).into());
}
Ok(resp)
}
#[cfg(feature = "blocking")]
pub fn blocking_send(&self, mail: &Message<'a>) -> SendgridResult<BlockingResponse> {
let body = mail.gen_json();
let resp = self.blocking_client.post(self.host).body(body).send()?;
if resp.error_for_status_ref().is_err() {
return Err(RequestNotSuccessful::new(resp.status(), resp.text()?).into());
}
Ok(resp)
}
}
impl<'a> Message<'a> {
pub fn new(from: Email<'a>) -> Self {
Self {
from,
subject: "",
personalizations: Vec::new(),
reply_to: None,
reply_to_list: None,
content: None,
attachments: None,
template_id: None,
categories: None,
ip_pool_name: None,
tracking_settings: None,
asm: None,
mail_settings: None,
}
}
pub fn set_from(mut self, from: Email<'a>) -> Self {
self.from = from;
self
}
pub fn set_reply_to(mut self, reply_to: Email<'a>) -> Self {
self.reply_to = Some(reply_to);
self
}
pub fn set_reply_to_list(mut self, reply_to_list: Vec<Email<'a>>) -> Self {
self.reply_to_list = Some(reply_to_list);
self
}
pub fn add_reply_to(mut self, reply_to: Email<'a>) -> Self {
self.reply_to_list
.get_or_insert_with(Vec::new)
.push(reply_to);
self
}
pub fn set_subject(mut self, subject: &'a str) -> Self {
self.subject = subject;
self
}
pub fn set_template_id(mut self, template_id: &'a str) -> Self {
self.template_id = Some(template_id);
self
}
pub fn set_ip_pool_name(mut self, ip_pool_name: &'a str) -> Self {
self.ip_pool_name = Some(ip_pool_name);
self
}
pub fn set_tracking_settings(mut self, tracking_settings: TrackingSettings<'a>) -> Self {
self.tracking_settings = Some(tracking_settings);
self
}
pub fn set_asm(mut self, asm: ASM) -> Self {
self.asm = Some(asm);
self
}
pub fn set_mail_settings(mut self, mail_settings: MailSettings) -> Self {
self.mail_settings = Some(mail_settings);
self
}
pub fn add_category(mut self, category: &'a str) -> Self {
self.categories.get_or_insert_with(Vec::new).push(category);
self
}
pub fn add_categories(mut self, categories: &[&'a str]) -> Self {
self.categories
.get_or_insert_with(Vec::new)
.extend_from_slice(categories);
self
}
pub fn add_content(mut self, c: Content<'a>) -> Self {
self.content.get_or_insert_with(Vec::new).push(c);
self
}
pub fn add_personalization(mut self, p: Personalization<'a>) -> Self {
self.personalizations.push(p);
self
}
pub fn add_attachment(mut self, a: Attachment<'a>) -> Self {
self.attachments.get_or_insert_with(Vec::new).push(a);
self
}
fn gen_json(&self) -> String {
serde_json::to_string(self).unwrap()
}
}
impl<'a> Email<'a> {
pub fn new(email: &'a str) -> Self {
Self { email, name: None }
}
pub fn set_name(mut self, name: &'a str) -> Self {
self.name = Some(name);
self
}
}
impl<'a> Content<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn set_content_type(mut self, content_type: &'a str) -> Self {
self.content_type = content_type;
self
}
pub fn set_value(mut self, value: &'a str) -> Self {
self.value = value;
self
}
}
impl<'a> Personalization<'a> {
pub fn new(email: Email<'a>) -> Self {
Self {
to: vec![email],
..Default::default()
}
}
pub fn new_many(email: Vec<Email<'a>>) -> Self {
Self {
to: email,
..Default::default()
}
}
pub fn add_to(mut self, to: Email<'a>) -> Self {
self.to.push(to);
self
}
pub fn add_cc(mut self, cc: Email<'a>) -> Self {
self.cc
.get_or_insert_with(|| Vec::with_capacity(1))
.push(cc);
self
}
pub fn add_bcc(mut self, bcc: Email<'a>) -> Self {
self.bcc
.get_or_insert_with(|| Vec::with_capacity(1))
.push(bcc);
self
}
pub fn add_headers(mut self, headers: &SGMap<'a>) -> Self {
self.headers
.get_or_insert_with(|| SGMap::with_capacity(headers.len()))
.extend(headers);
self
}
pub fn add_custom_args(mut self, custom_args: &SGMap<'a>) -> Self {
self.custom_args
.get_or_insert_with(|| SGMap::with_capacity(custom_args.len()))
.extend(custom_args);
self
}
pub fn add_substitutions(mut self, substitutions: &SGMap<'a>) -> Self {
self.substitutions
.get_or_insert_with(|| SGMap::with_capacity(substitutions.len()))
.extend(substitutions);
self
}
pub fn add_dynamic_template_data(mut self, dynamic_template_data: &SGMap<'a>) -> Self {
let new_vals = match to_value(dynamic_template_data).unwrap() {
Object(map) => map,
_ => unreachable!(),
};
self.dynamic_template_data
.get_or_insert_with(|| Map::with_capacity(new_vals.len()))
.extend(new_vals);
self
}
pub fn add_dynamic_template_data_json<T: Serialize + ?Sized>(
mut self,
json_object: &T,
) -> SendgridResult<Self> {
let new_vals = match to_value(json_object)? {
Object(map) => map,
_ => return Err(SendgridError::InvalidTemplateValue),
};
self.dynamic_template_data
.get_or_insert_with(|| Map::with_capacity(new_vals.len()))
.extend(new_vals);
Ok(self)
}
pub fn set_subject(mut self, subject: &'a str) -> Self {
self.subject = Some(subject);
self
}
pub fn set_send_at(mut self, send_at: u64) -> Self {
self.send_at = Some(send_at);
self
}
}
impl<'a> Attachment<'a> {
pub fn new() -> Self {
Self::default()
}
pub fn set_content(mut self, c: &'a [u8]) -> Self {
self.content = BASE64.encode(c).into();
self
}
pub fn set_base64_content(mut self, c: &'a str) -> Self {
self.content = c.into();
self
}
pub fn set_filename(mut self, filename: &'a str) -> Self {
self.filename = filename;
self
}
pub fn set_mime_type(mut self, mime: &'a str) -> Self {
self.mime_type = Some(mime);
self
}
pub fn set_content_idm(mut self, content_id: &'a str) -> Self {
self.content_id = Some(content_id);
self
}
pub fn set_disposition(mut self, disposition: Disposition) -> Self {
self.disposition = Some(disposition);
self
}
}
impl ASM {
pub fn new() -> Self {
Default::default()
}
pub fn set_group_id(mut self, group_id: u32) -> Self {
self.group_id = group_id;
self
}
pub fn set_groups_to_display(
mut self,
groups_to_display: HashSet<u32>,
) -> SendgridResult<Self> {
if groups_to_display.len() > 25 {
return Err(SendgridError::TooManyItems);
}
self.groups_to_display = groups_to_display;
Ok(self)
}
}
#[cfg(test)]
mod tests {
use crate::v3::message::{MailSettings, SandboxMode};
use crate::v3::{
ASM, ClickTrackingSetting, Email, Message, OpenTrackingSetting, Personalization,
SubscriptionTrackingSetting, TrackingSettings,
};
use serde::Serialize;
use std::collections::HashSet;
#[derive(Serialize)]
struct OuterModel {
inners: Vec<InnerModel>,
}
#[derive(Serialize)]
struct InnerModel {
x: String,
y: String,
z: String,
}
#[test]
fn ip_pool_name() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.set_ip_pool_name("test_ip_pool")
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"ip_pool_name":"test_ip_pool"}"#;
assert_eq!(json_str, expected);
}
#[test]
fn single_category() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.add_category("test_category")
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"categories":["test_category"]}"#;
assert_eq!(json_str, expected);
}
#[test]
fn click_tracking_setting() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.set_tracking_settings(TrackingSettings {
click_tracking: Some(ClickTrackingSetting {
enable: Some(true),
enable_text: None,
}),
open_tracking: None,
subscription_tracking: None,
})
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"tracking_settings":{"click_tracking":{"enable":true}}}"#;
assert_eq!(json_str, expected);
}
#[test]
fn open_tracking_setting() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.set_tracking_settings(TrackingSettings {
click_tracking: None,
open_tracking: Some(OpenTrackingSetting {
enable: Some(true),
substitution_tag: None,
}),
subscription_tracking: None,
})
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"tracking_settings":{"open_tracking":{"enable":true}}}"#;
assert_eq!(json_str, expected);
}
#[test]
fn subscription_tracking_setting() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.set_tracking_settings(TrackingSettings {
click_tracking: None,
open_tracking: None,
subscription_tracking: Some(SubscriptionTrackingSetting { enable: Some(true) }),
})
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"tracking_settings":{"subscription_tracking":{"enable":true}}}"#;
assert_eq!(json_str, expected);
}
#[test]
fn multiple_categories() {
let json_str_add_vec = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.add_categories(&["test_category1", "test_category2"])
.gen_json();
let json_str_multiple_adds = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.add_category("test_category1")
.add_category("test_category2")
.gen_json();
let json_str_vec_and_single = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.add_category("test_category1")
.add_categories(&["test_category2"])
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"categories":["test_category1","test_category2"]}"#;
assert_eq!(json_str_add_vec, expected);
assert_eq!(json_str_multiple_adds, expected);
assert_eq!(json_str_vec_and_single, expected);
}
#[test]
fn dynamic_template_data_sgmap() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(
Personalization::new(Email::new("to_email@test.com")).add_dynamic_template_data(
&[("Norway", "100"), ("Denmark", "50"), ("Iceland", "10")]
.into_iter()
.collect(),
),
)
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}],"dynamic_template_data":{"Denmark":"50","Iceland":"10","Norway":"100"}}]}"#;
assert_eq!(json_str, expected);
}
#[test]
fn dynamic_template_data_json() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(
Personalization::new(Email::new("to_email@test.com"))
.add_dynamic_template_data_json(&OuterModel {
inners: vec![
InnerModel {
x: "1".to_string(),
y: "2".to_string(),
z: "3".to_string(),
},
InnerModel {
x: "1".to_string(),
y: "2".to_string(),
z: "3".to_string(),
},
],
})
.unwrap(),
)
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}],"dynamic_template_data":{"inners":[{"x":"1","y":"2","z":"3"},{"x":"1","y":"2","z":"3"}]}}]}"#;
assert_eq!(json_str, expected);
}
#[test]
fn asm() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.set_asm(
ASM::new()
.set_group_id(123)
.set_groups_to_display(HashSet::from([123]))
.unwrap(),
)
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"asm":{"group_id":123,"groups_to_display":[123]}}"#;
assert_eq!(json_str, expected);
}
#[test]
fn reply_to_list() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.set_reply_to_list(vec![
Email::new("reply1@test.com").set_name("Reply One"),
Email::new("reply2@test.com"),
])
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"reply_to_list":[{"email":"reply1@test.com","name":"Reply One"},{"email":"reply2@test.com"}]}"#;
assert_eq!(json_str, expected);
}
#[test]
fn add_reply_to() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.add_reply_to(Email::new("reply1@test.com"))
.add_reply_to(Email::new("reply2@test.com"))
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"reply_to_list":[{"email":"reply1@test.com"},{"email":"reply2@test.com"}]}"#;
assert_eq!(json_str, expected);
}
#[test]
fn mail_settings() {
let json_str = Message::new(Email::new("from_email@test.com"))
.add_personalization(Personalization::new(Email::new("to_email@test.com")))
.set_mail_settings(
MailSettings::new().set_sandbox_mode(SandboxMode::new().set_enable(true)),
)
.gen_json();
let expected = r#"{"from":{"email":"from_email@test.com"},"subject":"","personalizations":[{"to":[{"email":"to_email@test.com"}]}],"mail_settings":{"sandbox_mode":{"enable":true}}}"#;
assert_eq!(json_str, expected);
}
}