WIP - ActixWeb multi-tenant blog and newsletter API server. Originally forked from LukeMathWalker/zero-to-production.
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Adds configuration for email server by provider

+132 -23
+1
configuration/production.yaml
··· 4 4 require_ssl: true 5 5 email_client: 6 6 base_url: "https://api.postmarkapp.com" 7 + server: "postmark"
+5
configuration/test.yaml
··· 10 10 id: "12345abcdef" 11 11 database: 12 12 port: 54321 13 + email_client: 14 + base_url: http://localhost:8025 15 + server: mailpit 13 16 s3_client: 14 17 access_key: S3SecretKeyx123456789 15 18 endpoint: http://127.0.0.1:9002 16 19 region: global 17 20 secret_key: S3AccessKey 21 + hosts: 22 + client: "http://127.0.0.1:5173" 18 23 redis_uri: "redis://127.0.0.1:63791"
+4 -1
src/configuration.rs
··· 1 1 use crate::clients::cloudinary_client::CloudinaryClient; 2 2 use crate::clients::s3_client::S3Client; 3 3 use crate::domain::SubscriberEmail; 4 - use crate::email_client::EmailClient; 4 + use crate::email_client::{EmailClient, EmailServer, deserialize_email_server_from_string}; 5 5 use secrecy::{ExposeSecret, Secret}; 6 6 use serde::Deserialize; 7 7 use serde_aux::field_attributes::deserialize_number_from_string; ··· 108 108 pub authorization_token: Secret<String>, 109 109 #[serde(deserialize_with = "deserialize_number_from_string")] 110 110 pub timeout_milliseconds: u64, 111 + #[serde(deserialize_with = "deserialize_email_server_from_string")] 112 + pub server: EmailServer, 111 113 } 112 114 113 115 impl EmailClientSettings { ··· 119 121 sender_email, 120 122 self.authorization_token, 121 123 timeout, 124 + self.server, 122 125 ) 123 126 } 124 127
+105 -12
src/email_client.rs
··· 1 1 use crate::domain::SubscriberEmail; 2 2 use reqwest::Client; 3 3 use secrecy::{ExposeSecret, Secret}; 4 + use serde::{Deserialize, Deserializer, Serialize}; 4 5 5 6 pub struct EmailClient { 6 7 http_client: Client, 7 8 base_url: String, 8 9 sender: SubscriberEmail, 9 10 authorization_token: Secret<String>, 11 + pub server: EmailServer, 10 12 } 11 13 12 14 impl EmailClient { ··· 15 17 sender: SubscriberEmail, 16 18 authorization_token: Secret<String>, 17 19 timeout: std::time::Duration, 20 + server: EmailServer, 18 21 ) -> Self { 19 22 let http_client = Client::builder().timeout(timeout).build().unwrap(); 20 23 Self { ··· 22 25 base_url, 23 26 sender, 24 27 authorization_token, 28 + server, 25 29 } 26 30 } 27 31 ··· 32 36 html_content: &str, 33 37 text_content: &str, 34 38 ) -> Result<(), reqwest::Error> { 35 - let url = format!("{}/email", self.base_url); 39 + let url: String = self.server.url(&self.base_url); 36 40 let request_body = SendEmailRequest { 37 41 from: self.sender.as_ref(), 38 42 to: recipient.as_ref(), ··· 40 44 html_body: html_content, 41 45 text_body: text_content, 42 46 }; 43 - self.http_client 44 - .post(&url) 45 - .header( 46 - "X-Postmark-Server-Token", 47 - self.authorization_token.expose_secret(), 48 - ) 49 - .json(&request_body) 50 - .send() 51 - .await? 52 - .error_for_status()?; 47 + let builder = self.http_client.post(&url).header( 48 + "X-Postmark-Server-Token", 49 + self.authorization_token.expose_secret(), 50 + ); 51 + 52 + match self.server { 53 + EmailServer::Mailpit => builder.json(&MailpitSendEmailRequest::from(request_body)), 54 + EmailServer::Postmark => builder.json(&request_body), 55 + } 56 + .send() 57 + .await? 58 + .error_for_status()?; 59 + 53 60 Ok(()) 54 61 } 55 62 } ··· 64 71 text_body: &'a str, 65 72 } 66 73 74 + impl From<SendEmailRequest<'_>> for MailpitSendEmailRequest { 75 + fn from(email_request: SendEmailRequest) -> Self { 76 + MailpitSendEmailRequest { 77 + from: MailpitContact { 78 + email: email_request.from.to_string(), 79 + name: Some("".to_string()), 80 + }, 81 + to: vec![MailpitContact { 82 + email: email_request.to.to_string(), 83 + name: Some("".to_string()), 84 + }], 85 + subject: email_request.subject.to_string(), 86 + text: email_request.text_body.to_string(), 87 + html: email_request.html_body.to_string(), 88 + } 89 + } 90 + } 91 + 92 + #[derive(Serialize, Debug)] 93 + #[serde(rename_all = "PascalCase")] 94 + pub struct MailpitContact { 95 + pub email: String, 96 + #[serde(default, skip_serializing_if = "Option::is_none")] 97 + pub name: Option<String>, 98 + } 99 + 100 + #[derive(Serialize, Debug)] 101 + #[serde(rename_all = "PascalCase")] 102 + pub struct MailpitSendEmailRequest { 103 + pub from: MailpitContact, 104 + pub to: Vec<MailpitContact>, 105 + pub subject: String, 106 + pub text: String, 107 + pub html: String, 108 + } 109 + 110 + /// The possible email services for our application. 111 + #[derive(Clone)] 112 + pub enum EmailServer { 113 + Postmark, 114 + Mailpit, 115 + } 116 + 117 + impl EmailServer { 118 + pub fn as_str(&self) -> &'static str { 119 + match self { 120 + EmailServer::Postmark => "postmark", 121 + EmailServer::Mailpit => "mailpit", 122 + } 123 + } 124 + 125 + pub fn url(&self, base_url: &str) -> String { 126 + match self { 127 + EmailServer::Postmark => format!("{}/email", base_url), 128 + EmailServer::Mailpit => format!("{}/api/v1/send", base_url), 129 + } 130 + } 131 + } 132 + 133 + impl TryFrom<String> for EmailServer { 134 + type Error = String; 135 + 136 + fn try_from(s: String) -> Result<Self, Self::Error> { 137 + match s.to_lowercase().as_str() { 138 + "postmark" => Ok(Self::Postmark), 139 + "mailpit" => Ok(Self::Mailpit), 140 + other => Err(format!( 141 + "{} is not a supported email server. Use either `postmark` or `mailpit`.", 142 + other 143 + )), 144 + } 145 + } 146 + } 147 + 148 + pub fn deserialize_email_server_from_string<'de, D>( 149 + deserializer: D, 150 + ) -> Result<EmailServer, D::Error> 151 + where 152 + D: Deserializer<'de>, 153 + { 154 + let email_server: String = Deserialize::deserialize(deserializer)?; 155 + 156 + EmailServer::try_from(email_server).map_err(serde::de::Error::custom) 157 + } 158 + 67 159 #[cfg(test)] 68 160 mod tests { 69 161 use crate::domain::SubscriberEmail; 70 - use crate::email_client::EmailClient; 162 + use crate::email_client::{EmailClient, EmailServer}; 71 163 use claims::{assert_err, assert_ok}; 72 164 use fake::faker::internet::en::SafeEmail; 73 165 use fake::faker::lorem::en::{Paragraph, Sentence}; ··· 115 207 email(), 116 208 Secret::new(Faker.fake()), 117 209 std::time::Duration::from_millis(200), 210 + EmailServer::Postmark, 118 211 ) 119 212 } 120 213
+3 -3
tests/api/admin/newsletters/detail/publish.rs
··· 70 70 .await; 71 71 assert_eq!(201, response.status().as_u16()); 72 72 73 - Mock::given(path("/email")) 73 + Mock::given(path("/api/v1/send")) 74 74 .and(method("POST")) 75 75 .respond_with(ResponseTemplate::new(200)) 76 76 .expect(1) ··· 170 170 .await; 171 171 assert_eq!(201, response.status().as_u16()); 172 172 173 - Mock::given(path("/email")) 173 + Mock::given(path("/api/v1/send")) 174 174 .and(method("POST")) 175 175 .respond_with(ResponseTemplate::new(200)) 176 176 .expect(1) ··· 225 225 })) 226 226 .await; 227 227 228 - Mock::given(path("/email")) 228 + Mock::given(path("/api/v1/send")) 229 229 .and(method("POST")) 230 230 // Setting a long delay to ensure that the second request 231 231 // arrives before the first one completes
+11 -4
tests/api/helpers.rs
··· 3 3 use fake::faker::name::en::Name; 4 4 use newsletter_api::clients::cloudinary_client::CloudinaryClient; 5 5 use newsletter_api::configuration::{DatabaseSettings, get_configuration}; 6 - use newsletter_api::email_client::EmailClient; 6 + use newsletter_api::email_client::{EmailClient, EmailServer}; 7 7 use newsletter_api::issue_delivery_worker::{ExecutionOutcome, try_execute_task}; 8 8 use newsletter_api::models::{NewUser, NewUserData, UserProfile}; 9 9 use newsletter_api::startup::{Application, get_connection_pool}; ··· 320 320 "user_id": user_id 321 321 }); 322 322 323 - let _mock_guard = Mock::given(path("/email")) 323 + let _mock_guard = Mock::given(path("/api/v1/send")) 324 324 .and(method("POST")) 325 325 .respond_with(ResponseTemplate::new(200)) 326 326 .named("Create unconfirmed subscriber") ··· 371 371 confirmation_link 372 372 }; 373 373 374 - let html = get_link(body["HtmlBody"].as_str().unwrap()); 375 - let plain_text = get_link(body["TextBody"].as_str().unwrap()); 374 + let html = match self.email_client.server { 375 + EmailServer::Mailpit => get_link(body["Html"].as_str().unwrap()), 376 + EmailServer::Postmark => get_link(body["HtmlBody"].as_str().unwrap()), 377 + }; 378 + let plain_text = match self.email_client.server { 379 + EmailServer::Mailpit => get_link(body["Text"].as_str().unwrap()), 380 + EmailServer::Postmark => get_link(body["TextBody"].as_str().unwrap()), 381 + }; 382 + 376 383 ConfirmationLinks { html, plain_text } 377 384 } 378 385 }
+3 -3
tests/api/subscriptions.rs
··· 9 9 // Arrange 10 10 let app = spawn_app().await; 11 11 12 - Mock::given(path("/email")) 12 + Mock::given(path("/api/v1/send")) 13 13 .and(method("POST")) 14 14 .respond_with(ResponseTemplate::new(200)) 15 15 .mount(&app.email_server) ··· 75 75 // Arrange 76 76 let app = spawn_app().await; 77 77 78 - Mock::given(path("/email")) 78 + Mock::given(path("/api/v1/send")) 79 79 .and(method("POST")) 80 80 .respond_with(ResponseTemplate::new(200)) 81 81 .expect(1) ··· 97 97 // Arrange 98 98 let app = spawn_app().await; 99 99 100 - Mock::given(path("/email")) 100 + Mock::given(path("/api/v1/send")) 101 101 .and(method("POST")) 102 102 .respond_with(ResponseTemplate::new(200)) 103 103 .mount(&app.email_server)