use std::env;
mod email;
mod error;
pub use {
email::{Email, EmailBody, EmailBuilder},
error::{BuildError, SendError, SetupError},
};
static USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
static MG_BASE_URL: &str = "https://api.eu.mailgun.net";
#[derive(Debug)]
pub struct Mailer {
from: String,
messages_url: reqwest::Url,
client: reqwest::Client,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MessageId(String);
impl Mailer {
pub fn from_env() -> Result<Self, SetupError> {
let domain = env::var("MAILER46_DOMAIN")
.map_err(|_| SetupError::EnvVarMissing("MAILER46_DOMAIN"))?;
let token =
env::var("MAILER46_TOKEN").map_err(|_| SetupError::EnvVarMissing("MAILER46_TOKEN"))?;
Self::new(domain, token)
}
pub fn new(domain: impl AsRef<str>, token: impl AsRef<str>) -> Result<Self, SetupError> {
Self::new_with_mg_url(MG_BASE_URL, domain, token)
}
pub fn new_with_mg_url(
mg_url: impl AsRef<str>,
domain: impl AsRef<str>,
token: impl AsRef<str>,
) -> Result<Self, SetupError> {
let from = format!("noreply@{}", domain.as_ref());
let messages_url = format!("{}/v3/{}/messages", mg_url.as_ref(), domain.as_ref())
.parse::<reqwest::Url>()
.map_err(|err| SetupError::InvalidVar("domain", err.to_string()))?;
let mut headers = reqwest::header::HeaderMap::new();
let token = base64::encode(format!("api:{}", token.as_ref()));
let auth_value = reqwest::header::HeaderValue::from_str(&format!("Basic {}", token))
.map_err(|err| SetupError::Build(err.to_string()))?;
headers.insert("Authorization", auth_value);
let client = reqwest::Client::builder()
.user_agent(USER_AGENT)
.default_headers(headers)
.build()
.map_err(|err| SetupError::Build(err.to_string()))?;
Ok(Self {
from,
messages_url,
client,
})
}
async fn send(&self, email: Email) -> Result<MessageId, SendError> {
let res = self
.client
.post(self.messages_url.clone())
.form(&email)
.send()
.await?;
if res.status() != reqwest::StatusCode::OK {
let status = res.status();
let body_bs = res.bytes().await?;
let body = String::from_utf8_lossy(&body_bs);
return Err(SendError::Non200Reply {
status,
body: body.into(),
});
}
let reply = res.json::<MailReply>().await?;
Ok(MessageId(reply.id))
}
}
#[derive(serde::Deserialize)]
pub(crate) struct MailReply {
id: String,
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::{matchers, Mock, MockServer, ResponseTemplate};
async fn setup() -> (Mailer, MockServer) {
let server = MockServer::start().await;
Mock::given(matchers::method("POST"))
.and(matchers::path("/v3/fakedomain/messages"))
.and(matchers::header(
"Authorization",
"Basic YXBpOnRvbWF0b3Rva2Vu",
))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"
{
"id": "<20210224131116.1.E5C867B3818DC87B@fakedomain>",
"message": "Queued. Thank you."
}
"#,
))
.mount(&server)
.await;
let client = Mailer::new_with_mg_url(&server.uri(), "fakedomain", "tomatotoken")
.expect("Creating Mailer");
(client, server)
}
#[test]
fn serialize_email() {
let email = EmailBuilder::default()
.from("niclas")
.to("someoneelse")
.subject("Subject")
.html_body("<h1>HELLO</h1>")
.text_body("HELLO")
.build()
.unwrap();
let json = serde_json::to_string(&email).expect("Serializing email");
assert_eq!(
json,
r#"{"from":"niclas","to":"someoneelse","subject":"Subject","html":"<h1>HELLO</h1>","text":"HELLO"}"#
);
}
#[tokio::test]
async fn send_a_test_email() {
let (client, server) = setup().await;
let res = EmailBuilder::default()
.to("david@mobility46.se")
.subject("test email!")
.text_body("I'm a body used in a test somewhere")
.build()
.expect("Building email")
.send(&client)
.await;
assert!(
res.is_ok(),
"Error reply: {}\nServer got following requests:\n{}",
res.err().unwrap(),
server
.received_requests()
.await
.map(|rqs| {
rqs.iter()
.map(|r| r.to_string())
.collect::<Vec<String>>()
.join("\n\n")
})
.unwrap_or_else(|| String::from("-"))
);
}
}