Skip to content

Commit c3ee6f7

Browse files
committed
feat: email sends expected request
1 parent 4c499a9 commit c3ee6f7

File tree

12 files changed

+578
-289
lines changed

12 files changed

+578
-289
lines changed

Cargo.lock

Lines changed: 371 additions & 264 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,15 +29,17 @@ tracing-actix-web = "0.5"
2929
sqlx = { version = "0.5.7", default-features = false, features = ["offline", "runtime-actix-rustls", "macros", "postgres", "uuid", "chrono", "migrate"] }
3030
unicode-segmentation = "1"
3131
validator = "0.16.0"
32-
fake = "2.6.1"
32+
reqwest = { version = "0.11.18", default-features = false, features = ["json", "rustls-tls"] }
3333

3434
[dev-dependencies]
35-
reqwest = "0.11.18"
3635
once_cell = "1"
3736
claim = "0.5"
3837
quickcheck = "1.0.3"
3938
quickcheck_macros = "1.0.0"
4039
rand = "0.8.5"
40+
fake = "2.6.1"
41+
wiremock = "0.5.19"
42+
serde_json = "1"
4143

4244
[profile.release]
4345
strip = true

configuration/production.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,4 @@ database:
44
require_ssl: true
55
email_client:
66
base_url: "https://api.postmarkapp.com"
7-
sender_email: "something@gmail.com"
7+
sender_email: "lawal@rowship.com"

src/configuration.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::domain::SubscriberEmail;
12
use secrecy::{ExposeSecret, Secret};
23
use serde_aux::field_attributes::deserialize_number_from_string;
34
use sqlx::postgres::{PgConnectOptions, PgSslMode};
@@ -8,6 +9,7 @@ use tracing_log::log;
89
pub struct Settings {
910
pub database: DatabaseSettings,
1011
pub application: ApplicationSettings,
12+
pub email_client: EmailClientSettings,
1113
}
1214

1315
#[derive(serde::Deserialize)]
@@ -17,6 +19,19 @@ pub struct ApplicationSettings {
1719
pub host: String,
1820
}
1921

22+
#[derive(serde::Deserialize)]
23+
pub struct EmailClientSettings {
24+
pub base_url: String,
25+
pub sender_email: String,
26+
pub authorization_token: Secret<String>,
27+
}
28+
29+
impl EmailClientSettings {
30+
pub fn sender(&self) -> Result<SubscriberEmail, String> {
31+
SubscriberEmail::parse(self.sender_email.clone())
32+
}
33+
}
34+
2035
#[derive(serde::Deserialize)]
2136
pub struct DatabaseSettings {
2237
pub username: String,

src/domain/new_subscriber.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1+
use crate::domain::subscriber_email::SubscriberEmail;
12
use crate::domain::subscriber_name::SubscriberName;
23
pub struct NewSubscriber {
3-
pub email: String,
4+
pub email: SubscriberEmail,
45
pub name: SubscriberName,
56
}

src/domain/subscriber_email.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use validator::validate_email;
22

3-
#[derive(Debug, PartialEq)]
3+
#[derive(Debug, PartialEq, Clone)]
44
pub struct SubscriberEmail(String);
55

66
impl SubscriberEmail {

src/email_client.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
use crate::domain::SubscriberEmail;
2+
use reqwest::Client;
3+
use secrecy::{ExposeSecret, Secret};
4+
5+
#[derive(Clone)]
6+
pub struct EmailClient {
7+
http_client: Client,
8+
base_url: String,
9+
sender: SubscriberEmail,
10+
authorization_token: Secret<String>,
11+
}
12+
13+
impl EmailClient {
14+
pub fn new(
15+
base_url: String,
16+
sender: SubscriberEmail,
17+
authorization_token: Secret<String>,
18+
) -> Self {
19+
Self {
20+
http_client: Client::new(),
21+
base_url,
22+
sender,
23+
authorization_token,
24+
}
25+
}
26+
}
27+
28+
impl EmailClient {
29+
pub async fn send_email(
30+
&self,
31+
recipient: SubscriberEmail,
32+
subject: &str,
33+
html_content: &str,
34+
text_content: &str,
35+
) -> Result<(), reqwest::Error> {
36+
let url = format!("{}/email", self.base_url);
37+
let request_body = SendEmailRequest {
38+
from: self.sender.as_ref(),
39+
to: recipient.as_ref(),
40+
subject,
41+
html_body: html_content,
42+
text_body: text_content,
43+
};
44+
45+
let _builder = self
46+
.http_client
47+
.post(url)
48+
.header(
49+
"X-Postmark-Server-Token",
50+
self.authorization_token.expose_secret(),
51+
)
52+
.json(&request_body)
53+
.send()
54+
.await?;
55+
Ok(())
56+
}
57+
}
58+
59+
#[derive(serde::Serialize)]
60+
#[serde(rename_all = "PascalCase")]
61+
pub struct SendEmailRequest <'a> {
62+
pub from: &'a str,
63+
pub to: &'a str,
64+
pub subject: &'a str,
65+
pub html_body: &'a str,
66+
pub text_body: &'a str,
67+
}
68+
69+
#[cfg(test)]
70+
mod tests {
71+
use crate::{domain::SubscriberEmail, email_client::EmailClient};
72+
use fake::{
73+
faker::internet::en::SafeEmail,
74+
faker::lorem::en::{Paragraph, Sentence},
75+
Fake, Faker,
76+
};
77+
use secrecy::Secret;
78+
use wiremock::{
79+
matchers::{header, header_exists, method, path},
80+
Mock, MockServer, Request, ResponseTemplate,
81+
};
82+
83+
struct SendEmailBodyMatcher;
84+
85+
impl wiremock::Match for SendEmailBodyMatcher {
86+
fn matches(&self, request: &Request) -> bool {
87+
let result: Result<serde_json::Value, _> = serde_json::from_slice(&request.body);
88+
89+
if let Ok(body) = result {
90+
body.get("From").is_some()
91+
&& body.get("To").is_some()
92+
&& body.get("Subject").is_some()
93+
&& body.get("HtmlBody").is_some()
94+
&& body.get("TextBody").is_some()
95+
} else {
96+
false
97+
}
98+
}
99+
}
100+
101+
#[tokio::test]
102+
async fn send_email_sends_the_expected_request() {
103+
let mock_server = MockServer::start().await;
104+
let sender = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
105+
let email_client = EmailClient::new(mock_server.uri(), sender, Secret::new(Faker.fake()));
106+
107+
Mock::given(header_exists("X-Postmark-Server-Token"))
108+
.and(header("Content-Type", "application/json"))
109+
.and(method("POST"))
110+
.and(path("/email"))
111+
.and(SendEmailBodyMatcher)
112+
.respond_with(ResponseTemplate::new(200))
113+
.expect(1)
114+
.mount(&mock_server)
115+
.await;
116+
117+
let recipient = SubscriberEmail::parse(SafeEmail().fake()).unwrap();
118+
let subject: String = Sentence(1..2).fake();
119+
let content: String = Paragraph(1..10).fake();
120+
121+
let _ = email_client
122+
.send_email(recipient, &subject, &content, &content)
123+
.await;
124+
}
125+
}

src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
pub mod configuration;
22
pub mod domain;
3+
pub mod email_client;
34
pub mod routes;
45
pub mod startup;
56
pub mod telemetry;

src/main.rs

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
use robust_rust::configuration::get_configuration;
2-
use robust_rust::startup::run;
3-
use robust_rust::telemetry::{get_subscriber, init_subscriber};
1+
use robust_rust::{
2+
configuration::get_configuration, email_client::EmailClient, startup::run,
3+
telemetry::get_subscriber, telemetry::init_subscriber,
4+
};
45
use sqlx::postgres::PgPoolOptions;
56
use std::net::TcpListener;
67

@@ -16,7 +17,19 @@ async fn main() -> std::io::Result<()> {
1617
"{}:{}",
1718
configuration.application.host, configuration.application.port
1819
);
20+
21+
let sender_email = configuration
22+
.email_client
23+
.sender()
24+
.expect("Invalid sender email address.");
25+
26+
let email_client = EmailClient::new(
27+
configuration.email_client.base_url,
28+
sender_email,
29+
configuration.email_client.authorization_token,
30+
);
31+
1932
let listener = TcpListener::bind(address).expect("Failed to bind random port");
20-
run(listener, connection_pool)?.await?;
33+
run(listener, connection_pool, email_client)?.await?;
2134
Ok(())
2235
}

src/routes/subscriptions.rs

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use crate::domain::{NewSubscriber, SubscriberName};
1+
use crate::domain::{NewSubscriber, SubscriberEmail, SubscriberName};
22
use actix_web::{web, HttpResponse};
33
use chrono::Utc;
44
use sqlx::types::Uuid;
@@ -19,16 +19,10 @@ pub struct FormData {
1919
name = %form.name
2020
)
2121
)]
22-
2322
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
24-
let subscriber_name = match SubscriberName::parse(form.0.name) {
25-
Ok(name) => name,
26-
Err(_) => return HttpResponse::BadRequest().finish(),
27-
};
28-
29-
let new_subscriber = NewSubscriber {
30-
email: form.0.email,
31-
name: subscriber_name,
23+
let new_subscriber = match form.0.try_into() {
24+
Ok(subscriber) => subscriber,
25+
Err(e) => return HttpResponse::BadRequest().body(e),
3226
};
3327

3428
match insert_subscriber(&new_subscriber, &pool).await {
@@ -40,6 +34,16 @@ pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> Ht
4034
}
4135
}
4236

37+
impl TryFrom<FormData> for NewSubscriber {
38+
type Error = String;
39+
40+
fn try_from(value: FormData) -> Result<Self, Self::Error> {
41+
let name = SubscriberName::parse(value.name)?;
42+
let email = SubscriberEmail::parse(value.email)?;
43+
Ok(Self { name, email })
44+
}
45+
}
46+
4347
#[tracing::instrument(
4448
name = "Saving a new subscriber details in the database",
4549
skip(new_subscriber, pool)
@@ -55,7 +59,7 @@ async fn insert_subscriber(
5559
VALUES ($1, $2, $3, $4)
5660
"#,
5761
subscribe_id,
58-
new_subscriber.email,
62+
new_subscriber.email.as_ref(),
5963
new_subscriber.name.as_ref(),
6064
Utc::now()
6165
)

0 commit comments

Comments
 (0)