email client introduced
This commit is contained in:
@ -2,10 +2,13 @@ use config::Config;
|
||||
use secrecy::{ExposeSecret, Secret};
|
||||
use sqlx::{postgres::{PgConnectOptions, PgSslMode}, ConnectOptions};
|
||||
|
||||
use crate::domain::SubscriberEmail;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
pub struct Settings {
|
||||
pub database: DatabaseSettings,
|
||||
pub application: ApplicationSettings,
|
||||
pub email_client: EmailClientSettings,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
@ -45,6 +48,23 @@ 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 run_mode = std::env::var("APP_ENVIRONMENT").unwrap_or("development".into());
|
||||
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
|
||||
|
||||
@ -1,17 +1,197 @@
|
||||
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<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()
|
||||
.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<(), String> {
|
||||
todo!("send_email not implemented")
|
||||
) -> 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::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("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);
|
||||
}
|
||||
}
|
||||
14
src/main.rs
14
src/main.rs
@ -1,6 +1,7 @@
|
||||
use std::net::TcpListener;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use zero2prod::configuration::get_configuration;
|
||||
use zero2prod::email_client;
|
||||
use zero2prod::startup::run;
|
||||
use zero2prod::telemetry::{get_subscriber, init_subscriber};
|
||||
|
||||
@ -10,11 +11,20 @@ async fn main() -> std::io::Result<()> {
|
||||
init_subscriber(subscriber);
|
||||
|
||||
let config = get_configuration().expect("Failed to read configuration");
|
||||
println!("Application configuration: {}", config.application.port);
|
||||
|
||||
let connection_pool = PgPoolOptions::new().acquire_timeout(std::time::Duration::from_secs(2))
|
||||
.max_connections(10).connect_lazy_with(config.database.with_db());
|
||||
|
||||
let sender_email = config.email_client.sender().unwrap();
|
||||
let timeout = config.email_client.timeout();
|
||||
let email_client = email_client::EmailClient::new(
|
||||
config.email_client.base_url,
|
||||
sender_email,
|
||||
config.email_client.authorization_token,
|
||||
timeout,
|
||||
);
|
||||
|
||||
let address = format!("{}:{}", config.application.host, config.application.port);
|
||||
let listener = TcpListener::bind(address).expect("Failed to bind random port");
|
||||
run(listener, connection_pool)?.await
|
||||
run(listener, connection_pool ,email_client)?.await
|
||||
}
|
||||
|
||||
@ -3,6 +3,9 @@ use serde::Deserialize;
|
||||
use chrono::Utc;
|
||||
use uuid::Uuid;
|
||||
use sqlx::{query, Pool, Postgres};
|
||||
use lettre::{
|
||||
address::AddressError, message::{header::ContentType, Mailbox}, transport::smtp::authentication::Credentials, Message, SmtpTransport, Transport
|
||||
};
|
||||
|
||||
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
|
||||
|
||||
@ -26,11 +29,64 @@ pub async fn subscribe(form: web::Form<FormData>, connection_pool: web::Data<Poo
|
||||
Err(_) => return HttpResponse::BadRequest().finish(),
|
||||
};
|
||||
match insert_subscriber(&new_subscriber, &connection_pool).await {
|
||||
Ok(_) => (),
|
||||
Err(_) => return HttpResponse::InternalServerError().finish(),
|
||||
}
|
||||
match send_mail(&new_subscriber) {
|
||||
Ok(_) => HttpResponse::Ok().finish(),
|
||||
Err(_) => HttpResponse::InternalServerError().finish(),
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
#[tracing::instrument(
|
||||
name = "Sending confirmation email",
|
||||
skip(new_subscriber)
|
||||
)]
|
||||
fn send_mail(new_subscriber: &NewSubscriber) -> Result<(), String> {
|
||||
let from: Mailbox = "Andre Heber <andre@futureblog.eu>".parse().map_err(|e: AddressError| {
|
||||
tracing::error!("Could not parse email - from: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let reply_to: Mailbox = "noreply@futureblog.eu".parse().map_err(|e: AddressError| {
|
||||
tracing::error!("Could not parse email - reply_to: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let to: Mailbox = new_subscriber.email.as_ref().parse().map_err(|e: AddressError| {
|
||||
tracing::error!("Could not parse email - to: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let email = Message::builder()
|
||||
.from(from)
|
||||
.reply_to(reply_to)
|
||||
.to(to)
|
||||
.subject("Rust Email")
|
||||
.header(ContentType::TEXT_PLAIN)
|
||||
.body(String::from("Hello, this is a test email from Rust!"))
|
||||
.map_err(|e| {
|
||||
tracing::error!("Could not build email: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
|
||||
let creds = Credentials::new("andre@futureblog.eu".to_string(), "d6vanPc4RUeQ".to_string());
|
||||
let mailer = SmtpTransport::relay("mail.futureblog.eu")
|
||||
.map_err(|e| {
|
||||
tracing::error!("Could not connect to server: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
let mailer = mailer.credentials(creds).build();
|
||||
|
||||
mailer.send(&email)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Could not send email: {:?}", e);
|
||||
e.to_string()
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl TryFrom<FormData> for NewSubscriber {
|
||||
type Error = String;
|
||||
|
||||
|
||||
@ -4,16 +4,23 @@ use sqlx::{Pool, Postgres};
|
||||
use tracing_actix_web::TracingLogger;
|
||||
use std::net::TcpListener;
|
||||
|
||||
use crate::email_client::EmailClient;
|
||||
use crate::routes::{health_check, subscribe};
|
||||
|
||||
pub fn run(listener: TcpListener, connection_pool: Pool<Postgres>) -> Result<Server, std::io::Error> {
|
||||
pub fn run(
|
||||
listener: TcpListener,
|
||||
connection_pool: Pool<Postgres>,
|
||||
email_client: EmailClient,
|
||||
) -> Result<Server, std::io::Error> {
|
||||
let connection_pool = web::Data::new(connection_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(connection_pool.clone())
|
||||
.app_data(email_client.clone())
|
||||
})
|
||||
.listen(listener)?
|
||||
.run();
|
||||
|
||||
Reference in New Issue
Block a user