use reqwest::Client; use secrecy::{ExposeSecret, Secret}; use crate::domain::SubscriberEmail; pub struct EmailClient { http_client: reqwest::Client, base_url: String, sender: SubscriberEmail, authorization_token: Secret, } impl EmailClient { pub fn new( base_url: String, sender: SubscriberEmail, authorization_token: Secret, timeout: std::time::Duration, ) -> Self { let http_client = Client::builder() .timeout(timeout) .build() .expect("Failed to build reqwest client"); Self { http_client, base_url, sender, authorization_token, } } pub async fn send_email( &self, recipient: SubscriberEmail, subject: &str, html_content: &str, text_content: &str, ) -> Result<(), reqwest::Error> { let url = format!("{}/email", self.base_url); let _builder = self .http_client .post(&url) .header("X-Postmark-Server-Token", self.authorization_token.expose_secret()) .json(&SendEmailRequest { from: self.sender.as_ref(), to: recipient.as_ref(), subject, html_body: html_content, text_body: text_content, }) .send() .await? .error_for_status()?; Ok(()) } } #[derive(serde::Serialize)] #[serde(rename_all = "PascalCase")] struct SendEmailRequest<'a> { from: &'a str, to: &'a str, subject: &'a str, html_body: &'a str, text_body: &'a str, } #[cfg(test)] mod tests { use claim::{assert_err, assert_ok}; use fake::{faker::{internet::en::SafeEmail, lorem::en::{Paragraph, Sentence}}, Fake, Faker}; use secrecy::Secret; use wiremock::{http::Method, matchers::{any, header, header_exists, method, path}, Mock, MockServer, Request, ResponseTemplate}; use crate::domain::SubscriberEmail; use super::EmailClient; 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 { EmailClient::new( base_url, email(), Secret::new(Faker.fake()), std::time::Duration::from_millis(100), ) } #[tokio::test] async fn send_email_fires_a_request_to_base_url() { let mock_server = MockServer::start().await; let email_client = email_client(mock_server.uri()); Mock::given(header_exists("X-Postmark-Server-Token")) .and(header("Content-Type", "application/json")) .and(path("/email")) .and(method(Method::POST)) .and(SendEmailBodyMatcher) .respond_with(ResponseTemplate::new(200)) .expect(1) .mount(&mock_server) .await; let _ = email_client .send_email(email(), &subject(), &content(), &content()) .await; } struct SendEmailBodyMatcher; impl wiremock::Match for SendEmailBodyMatcher { fn matches(&self, request: &Request) -> bool { let result: Result = 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("HtmlBody").is_some() && body.get("TextBody").is_some() } else { false } } } #[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 outcome = email_client .send_email(email(), &subject(), &content(), &content()) .await; assert_ok!(outcome); } #[tokio::test] async fn send_email_fails_if_the_server_returns_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 outcome = email_client .send_email(email(), &subject(), &content(), &content()) .await; assert_err!(outcome); } #[tokio::test] async fn send_email_times_out_if_the_server_takes_too_long() { let mock_server = MockServer::start().await; let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); let email_client = EmailClient::new( mock_server.uri(), sender, Secret::new(Faker.fake()), std::time::Duration::from_millis(100), ); let subscriber_email = SubscriberEmail::parse(SafeEmail().fake()).unwrap(); let subject: String = Sentence(1..2).fake(); let content: String = Paragraph(1..10).fake(); Mock::given(any()) .respond_with(ResponseTemplate::new(200) .set_delay(std::time::Duration::from_secs(180))) .expect(1) .mount(&mock_server) .await; let outcome = email_client .send_email(subscriber_email, &subject, &content, &content) .await; assert_err!(outcome); } }