#![deny(
anonymous_parameters,
clippy::all,
const_err,
illegal_floating_point_literal_pattern,
late_bound_lifetime_arguments,
path_statements,
patterns_in_fns_without_body,
rust_2018_idioms,
trivial_numeric_casts,
unused_extern_crates
)]
#![warn(
clippy::dbg_macro,
clippy::decimal_literal_representation,
clippy::get_unwrap,
clippy::nursery,
clippy::pedantic,
clippy::todo,
clippy::unimplemented,
clippy::use_debug,
clippy::all,
unused_qualifications,
variant_size_differences
)]
#![allow(clippy::missing_const_for_fn)]
use std::fmt::Debug;
use nanoserde::{DeJson, SerJson};
use ureq::Response;
pub struct OhMySmtp {
api_key: String,
agent: ureq::Agent,
}
impl OhMySmtp {
#[must_use]
pub fn new(api_key: impl ToString) -> Self {
Self {
api_key: api_key.to_string(),
agent: ureq::AgentBuilder::new().user_agent("ohmysmtp/0.1.1").build()
}
}
pub fn send(&self, email: &Email) -> Result<(), Error> {
#[cfg(feature = "email-validation")]
{
if email_address_parser::EmailAddress::parse(&email.to, None).is_none() {
return Err(Error::InvalidEmail);
}
}
let request = self.agent.post("https://app.ohmysmtp.com/api/v1/send");
let email_json_string = nanoserde::SerJson::serialize_json(email);
let read_status = |status: u16, response: Response| match status {
200 => Ok(()),
400 => {
if let Ok(response_string) = response.into_string() {
if response_string.contains("Invalid API") {
return Err(Error::InvalidApiToken);
} else if response_string.contains("not parseable") {
return Err(Error::FromAddressNotParseable);
} else if response_string.contains("undefined field") {
return Err(Error::NoToField);
} else if response_string.contains("is invalid") {
return Err(Error::ToAddressNotParseable);
} else if response_string.contains("blocked address") {
return Err(Error::ToAddressBlocked);
} else if response_string.contains("maximum volume") {
return Err(Error::RateLimit);
} else if response_string.contains("Extension file type blocked") {
return Err(Error::ExtensionTypeBlocked);
}
return Err(Error::Other(response_string));
}
Err(Error::Other(status.to_string()))
}
401 => Err(Error::MissingApiToken),
403 => {
if let Ok(response_string) = response.into_string() {
if response_string.contains("Domain DKIM") {
return Err(Error::DomainDkimVerificationNotCompleted);
}
if response_string.contains("not have an active plan") {
return Err(Error::InactivePlanForDomain);
}
if response_string.contains("unable to send email") {
return Err(Error::OrganizationDisabled);
}
if response_string.contains("Verified domain") {
return Err(Error::FromAddressNotEqualToRegisteredDomain);
}
return Err(Error::Other(response_string));
}
Err(Error::Other(status.to_string()))
}
406 => Err(Error::InvalidRequestFormat),
429 => Err(Error::RateLimit),
500 => Err(Error::NoContent),
_ => Err(Error::Other(
response
.into_string()
.unwrap_or_else(|_| status.to_string()),
)),
};
match request
.set("Accept", "application/json")
.set("Content-Type", "application/json")
.set("OhMySMTP-Server-Token", &self.api_key)
.send_string(&email_json_string)
{
Ok(response) => {
let status = response.status();
read_status(status, response)
}
Err(ureq::Error::Status(code, response)) => {
let status = code;
read_status(status, response)
}
Err(error) => Err(Error::NetworkError(error.to_string())),
}
}
}
#[derive(Debug, DeJson, SerJson, Clone)]
pub struct Email {
from: String,
to: String,
#[nserde(rename = "textbody")]
text_body: Option<String>,
#[nserde(rename = "htmlbody")]
html_body: Option<String>,
cc: Option<String>,
bcc: Option<String>,
subject: Option<String>,
#[nserde(rename = "replyto")]
reply_to: Option<String>,
list_unsubscribe: Option<String>,
attachments: Option<Vec<File>>,
tags: Option<Vec<String>>,
}
impl Default for Email {
fn default() -> Self {
Self {
from: "".into(),
to: "".into(),
text_body: None,
cc: None,
bcc: None,
subject: None,
reply_to: None,
list_unsubscribe: None,
attachments: None,
tags: None,
html_body: None,
}
}
}
impl Email {
#[must_use]
pub fn new(from: impl ToString, to: impl ToString, body: impl ToString) -> Self {
Self {
from: from.to_string(),
to: to.to_string(),
text_body: Some(body.to_string()),
..Self::default()
}
}
#[must_use]
pub fn with_html(mut self, html_body: impl ToString) -> Self {
self.html_body = Some(html_body.to_string());
self.text_body = None;
self
}
#[must_use]
pub fn with_text_body(mut self, textbody: impl ToString) -> Self {
self.text_body = Some(textbody.to_string());
self.html_body = None;
self
}
#[must_use]
pub fn with_cc(mut self, cc: impl ToString) -> Self {
self.cc = Some(cc.to_string());
self
}
#[must_use]
pub fn with_bcc(mut self, bcc: impl ToString) -> Self {
self.bcc = Some(bcc.to_string());
self
}
#[must_use]
pub fn with_subject(mut self, subject: impl ToString) -> Self {
self.subject = Some(subject.to_string());
self
}
#[must_use]
pub fn with_replyto(mut self, replyto: impl ToString) -> Self {
self.reply_to = Some(replyto.to_string());
self
}
#[must_use]
pub fn with_list_unsubscribe(mut self, listunsubscribe: impl ToString) -> Self {
self.list_unsubscribe = Some(listunsubscribe.to_string());
self
}
#[must_use]
pub fn with_attachments(mut self, attachments: Vec<File>) -> Self {
self.attachments = Some(attachments);
self
}
#[must_use]
pub fn with_attachment(mut self, attachment: File) -> Self {
self.attachments = Some(vec![attachment]);
self
}
#[must_use]
pub fn with_tags(mut self, tags: Vec<impl ToString>) -> Self {
self.tags = Some(tags.into_iter().map(|x| x.to_string()).collect());
self
}
#[must_use]
pub fn with_tag(mut self, tag: impl ToString) -> Self {
self.tags = Some(vec![tag.to_string()]);
self
}
}
#[derive(Debug, DeJson, SerJson, Clone)]
pub struct File {
name: String,
content: String,
content_type: String,
cid: Option<String>,
}
impl File {
pub fn new(bytes: &dyn AsRef<[u8]>, name: impl ToString, filetype: &FileType) -> Self {
Self {
name: name.to_string(),
content: base64::encode(bytes),
content_type: match filetype {
FileType::Jpeg | FileType::Jpg => "image/jpeg".into(),
FileType::Png => "image/png".into(),
FileType::Gif => "image/gif".into(),
FileType::Txt => "text/plain".into(),
FileType::Pdf => "application/pdf".into(),
FileType::Docx => {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document".into()
}
FileType::Xlsx => {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet".into()
}
FileType::Pptx => {
"application/vnd.openxmlformats-officedocument.presentationml.presentation"
.into()
}
FileType::Csv => "text/csv".into(),
},
cid: None,
}
}
}
pub enum FileType {
Jpeg,
Jpg,
Png,
Gif,
Txt,
Pdf,
Docx,
Xlsx,
Pptx,
Csv,
}
#[derive(Debug, PartialEq)]
pub enum Error {
InvalidApiToken,
FromAddressNotParseable,
NoToField,
ToAddressNotParseable,
ToAddressBlocked,
TooManyToAddrs,
ExtensionTypeBlocked,
MissingApiToken,
DomainDkimVerificationNotCompleted,
InactivePlanForDomain,
OrganizationDisabled,
FromAddressNotEqualToRegisteredDomain,
InvalidRequestFormat,
RateLimit,
NoContent,
NetworkError(String),
Other(String),
#[cfg(feature = "email-validation")]
InvalidEmail,
}