Files
zero2prod/src/email_client.rs
Alphonse Paix 54218f92a9 Admin can now write posts
Posts can be displayed on the website. Subscribers are automatically
notified by email. This gives the opportunity to track explicitly how
many people followed the link provided in the emails sent without being
intrusive (no invisible image).
2025-09-18 17:22:33 +02:00

207 lines
5.9 KiB
Rust

use crate::{configuration::EmailClientSettings, domain::SubscriberEmail};
use reqwest::Client;
use secrecy::{ExposeSecret, SecretString};
use std::time::Duration;
pub struct EmailClient {
http_client: Client,
base_url: reqwest::Url,
sender: SubscriberEmail,
authorization_token: SecretString,
}
impl EmailClient {
pub fn build(config: EmailClientSettings) -> Result<Self, anyhow::Error> {
let client = Self {
http_client: Client::builder()
.timeout(Duration::from_millis(config.timeout_milliseconds))
.build()
.unwrap(),
base_url: reqwest::Url::parse(&config.base_url)?,
sender: config.sender().map_err(|e| anyhow::anyhow!(e))?,
authorization_token: config.authorization_token,
};
Ok(client)
}
pub async fn send_email(
&self,
recipient: &SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str,
) -> Result<(), reqwest::Error> {
let url = self.base_url.join("email").unwrap();
let request_body = SendEmailRequest {
from: EmailField {
email: self.sender.as_ref(),
},
to: vec![EmailField {
email: recipient.as_ref(),
}],
subject,
text: text_content,
html: html_content,
};
self.http_client
.post(url)
.header("X-Requested-With", "XMLHttpRequest")
.header(
"Authorization",
format!("Bearer {}", self.authorization_token.expose_secret()),
)
.json(&request_body)
.send()
.await?
.error_for_status()?;
Ok(())
}
}
#[derive(serde::Serialize)]
struct SendEmailRequest<'a> {
from: EmailField<'a>,
to: Vec<EmailField<'a>>,
subject: &'a str,
text: &'a str,
html: &'a str,
}
#[derive(serde::Serialize)]
struct EmailField<'a> {
email: &'a str,
}
#[cfg(test)]
mod tests {
use crate::{
configuration::EmailClientSettings, domain::SubscriberEmail, email_client::EmailClient,
};
use claims::{assert_err, assert_ok};
use fake::{
Fake, Faker,
faker::{
internet::en::SafeEmail,
lorem::en::{Paragraph, Sentence},
},
};
use std::time::Duration;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{any, header, header_exists, method, path},
};
struct SendEmailBodyMatcher;
impl wiremock::Match for SendEmailBodyMatcher {
fn matches(&self, request: &wiremock::Request) -> bool {
let result: Result<serde_json::Value, _> = serde_json::from_slice(&request.body);
if let Ok(body) = result {
body.get("from").is_some()
&& body.get("to").is_some()
&& body.get("subject").is_some()
&& body.get("html").is_some()
&& body.get("text").is_some()
} else {
false
}
}
}
fn subject() -> String {
Sentence(1..2).fake()
}
fn content() -> String {
Paragraph(1..10).fake()
}
fn email() -> SubscriberEmail {
SubscriberEmail::parse(SafeEmail().fake()).unwrap()
}
fn email_client(base_url: String) -> EmailClient {
let sender_email = SafeEmail().fake();
let token: String = Faker.fake();
let settings = EmailClientSettings::new(base_url, sender_email, token, 200);
EmailClient::build(settings).unwrap()
}
#[tokio::test]
async fn send_email_sends_the_expected_request() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(header_exists("Authorization"))
.and(header("Content-Type", "application/json"))
.and(header("X-Requested-With", "XMLHttpRequest"))
.and(path("email"))
.and(method("POST"))
.and(SendEmailBodyMatcher)
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
email_client
.send_email(&email(), &subject(), &content(), &content())
.await
.unwrap();
}
#[tokio::test]
async fn send_email_succeeds_if_the_server_returns_200() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_ok!(response);
}
#[tokio::test]
async fn send_email_fails_if_the_server_retuns_500() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(500))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_err!(response);
}
#[tokio::test]
async fn send_email_times_out_if_the_server_takes_too_long() {
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());
Mock::given(any())
.respond_with(ResponseTemplate::new(200).set_delay(Duration::from_secs(180)))
.expect(1)
.mount(&mock_server)
.await;
let response = email_client
.send_email(&email(), &subject(), &content(), &content())
.await;
assert_err!(response);
}
}