Skip to content

Commit

Permalink
feat: set up email client with Resend
Browse files Browse the repository at this point in the history
  • Loading branch information
migueloller committed Dec 18, 2023
1 parent 363579e commit eddf433
Show file tree
Hide file tree
Showing 10 changed files with 516 additions and 138 deletions.
360 changes: 227 additions & 133 deletions Cargo.lock

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,17 @@ features = [
"migrate",
]

[dependencies.reqwest]
version = "0.11"
default-features = false
features = ["json", "rustls-tls"]

[dev-dependencies]
claims = "0.7"
fake = "~2.3"
once_cell = "1"
quickcheck = "0.9.2"
quickcheck_macros = "0.9.1"
reqwest = "0.11"
serde_json = "1"
tokio = { version = "1", features = ["rt", "macros"] }
wiremock = "0.5"
5 changes: 5 additions & 0 deletions configuration/base.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@ database:
username: "postgres"
password: "password"
database_name: "newsletter"
email_client:
base_url: "localhost"
sender_email: "[email protected]"
authorization_token: "my-secret-token"
timeout_milliseconds: 10000
3 changes: 3 additions & 0 deletions configuration/production.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ application:
host: 0.0.0.0
database:
require_ssl: true
email_client:
base_url: "https://api.resend.com"
sender_email: "[email protected]"
20 changes: 20 additions & 0 deletions src/configuration.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::domain::SubscriberEmail;
use secrecy::{ExposeSecret, Secret};
use serde_aux::field_attributes::deserialize_number_from_string;
use sqlx::postgres::PgConnectOptions;
Expand All @@ -7,6 +8,7 @@ use sqlx::postgres::PgSslMode;
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
pub email_client: EmailClientSettings,
}

#[derive(serde::Deserialize)]
Expand Down Expand Up @@ -48,6 +50,24 @@ impl DatabaseSettings {
}
}

#[derive(serde::Deserialize)]
pub struct EmailClientSettings {
pub base_url: String,
pub sender_email: String,
pub authorization_token: Secret<String>,
pub timeout_milliseconds: u64,
}

impl EmailClientSettings {
pub fn sender(&self) -> Result<SubscriberEmail, String> {
SubscriberEmail::parse(self.sender_email.clone())
}

pub fn timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.timeout_milliseconds)
}
}

pub fn get_configuration() -> Result<Settings, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory.");
let configuration_directory = base_path.join("configuration");
Expand Down
212 changes: 212 additions & 0 deletions src/email_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
use crate::domain::SubscriberEmail;
use reqwest::Client;
use secrecy::{ExposeSecret, Secret};

pub struct EmailClient {
http_client: reqwest::Client,
sender: SubscriberEmail,
base_url: String,
authorization_token: Secret<String>,
}

impl EmailClient {
pub fn new(
base_url: String,
sender: SubscriberEmail,
authorization_token: Secret<String>,
timeout: std::time::Duration,
) -> Self {
let http_client = Client::builder().timeout(timeout).build().unwrap();

Self {
http_client,
sender,
base_url,
authorization_token,
}
}

pub async fn send_email(
&self,
recipient: SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str,
) -> Result<(), reqwest::Error> {
let url = format!("{}/emails", self.base_url);
let request_body = SendEmailRequest {
from: self.sender.as_ref(),
to: recipient.as_ref(),
subject,
html: html_content,
text: text_content,
};

self.http_client
.post(&url)
.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: &'a str,
to: &'a str,
subject: &'a str,
html: &'a str,
text: &'a str,
}

#[cfg(test)]
mod tests {
use crate::domain::SubscriberEmail;
use crate::email_client::EmailClient;
use claims::assert_err;
use claims::assert_ok;
use fake::faker::internet::en::SafeEmail;
use fake::faker::lorem::en::{Paragraph, Sentence};
use fake::{Fake, Faker};
use secrecy::Secret;
use wiremock::matchers::{any, header, header_exists, method, path};
use wiremock::{Mock, MockServer, Request, ResponseTemplate};

/// Generate a random email subject.
fn subject() -> String {
Sentence(1..2).fake()
}

/// Generate random email content.
fn content() -> String {
Paragraph(1..10).fake()
}

/// Generate a random subscriber email.
fn email() -> SubscriberEmail {
SubscriberEmail::parse(SafeEmail().fake()).unwrap()
}

/// Get a test instance of `EmailClient`.
fn email_client(base_url: String) -> EmailClient {
EmailClient::new(
base_url,
email(),
Secret::new(Faker.fake()),
std::time::Duration::from_millis(200),
)
}

struct SendEmailBodyMatcher;

impl wiremock::Match for SendEmailBodyMatcher {
fn matches(&self, request: &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
}
}
}

#[tokio::test]
async fn send_email_sends_the_expected_request() {
// Arrange
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(path("/emails"))
.and(method("POST"))
.and(SendEmailBodyMatcher)
.respond_with(ResponseTemplate::new(200))
.expect(1)
.mount(&mock_server)
.await;

// Act
let _ = email_client
.send_email(email(), &subject(), &content(), &content())
.await;

// Assert
// Mock expectations are checked on drop.
}

#[tokio::test]
async fn send_email_succeeds_if_the_server_returns_200() {
// Arrange
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;

// Act
let outcome = email_client
.send_email(email(), &subject(), &content(), &content())
.await;

// Assert
assert_ok!(outcome);
}

#[tokio::test]
async fn send_email_fails_if_the_server_returns_500() {
// Arrange
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;

// Act
let outcome = email_client
.send_email(email(), &subject(), &content(), &content())
.await;

// Assert
assert_err!(outcome);
}

#[tokio::test]
async fn send_email_times_out_if_the_server_takes_too_long() {
// Arrange
let mock_server = MockServer::start().await;
let email_client = email_client(mock_server.uri());

let response = ResponseTemplate::new(200).set_delay(std::time::Duration::from_secs(180));
Mock::given(any())
.respond_with(response)
.expect(1)
.mount(&mock_server)
.await;

// Act
let outcome = email_client
.send_email(email(), &subject(), &content(), &content())
.await;

// Assert
assert_err!(outcome);
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
pub mod configuration;
pub mod domain;
pub mod email_client;
pub mod routes;
pub mod startup;
pub mod telemetry;
16 changes: 15 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use sqlx::PgPool;
use std::net::TcpListener;
use zero2prod::{
configuration::get_configuration,
email_client::EmailClient,
startup::run,
telemetry::{get_subscriber, init_subscriber},
};
Expand All @@ -13,11 +14,24 @@ async fn main() -> Result<(), std::io::Error> {

let configuration = get_configuration().expect("Failed to read configuration.");
let connection_pool = PgPool::connect_lazy_with(configuration.database.with_db());

let sender_email = configuration
.email_client
.sender()
.expect("Inavlid sender email address.");
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
timeout,
);

let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port
);
let listener = TcpListener::bind(address)?;

run(listener, connection_pool)?.await
run(listener, connection_pool, email_client)?.await
}
13 changes: 11 additions & 2 deletions src/startup.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
use crate::routes::{health_check, subscribe};
use crate::{
email_client::EmailClient,
routes::{health_check, subscribe},
};
use actix_web::{dev::Server, web, App, HttpServer};
use sqlx::PgPool;
use std::net::TcpListener;
use tracing_actix_web::TracingLogger;

pub fn run(listener: TcpListener, db_pool: PgPool) -> Result<Server, std::io::Error> {
pub fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
) -> Result<Server, std::io::Error> {
let db_pool = web::Data::new(db_pool);
let email_client = web::Data::new(email_client);
let server = HttpServer::new(move || {
App::new()
.wrap(TracingLogger::default())
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
.app_data(db_pool.clone())
.app_data(email_client.clone())
})
.listen(listener)?
.run();
Expand Down
15 changes: 14 additions & 1 deletion tests/health_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use std::net::TcpListener;
use uuid::Uuid;
use zero2prod::{
configuration::{get_configuration, DatabaseSettings},
email_client::EmailClient,
telemetry::{get_subscriber, init_subscriber},
};

Expand Down Expand Up @@ -143,7 +144,19 @@ async fn spawn_app() -> TestApp {
configuration.database.database_name = Uuid::new_v4().to_string();
let connection_pool = configure_database(&configuration.database).await;

let server = zero2prod::startup::run(listener, connection_pool.clone())
let sender_email = configuration
.email_client
.sender()
.expect("Invalid sender email address.");
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
timeout,
);

let server = zero2prod::startup::run(listener, connection_pool.clone(), email_client)
.expect("Failed to bind address.");
let _ = tokio::spawn(server);

Expand Down

0 comments on commit eddf433

Please sign in to comment.