feat(email_client): email client implementation with tests and configuration
continuous-integration/drone/push Build is failing Details

main
flavien 2022-12-04 15:35:38 +00:00
parent e81e628487
commit 78a55a6c06
9 changed files with 114 additions and 29 deletions

View File

@ -18,6 +18,12 @@ steps:
environment:
POSTGRES_HOST: postgres.server
SKIP_DOCKER: "true"
EMAIL_SENDER: Cyberduck <cyberduck@coincoinmail.fr>
EMAIL_HOST: coincoinmail.fr
EMAIL_PASSWORD:
from_secret: email_password
EMAIL_USER:
from_secret: email_user
commands:
- ./scripts/init_db.sh
- cargo build
@ -50,6 +56,10 @@ steps:
from_secret: postgres_password
POSTGRES_DB:
from_secret: postgres_db
EMAIL_PASSWORD:
from_secret: email_password
EMAIL_USER:
from_secret: email_user
commands:
- docker stack deploy -c docker/deployment/stack.yml zero2prod
volumes:

View File

@ -5,6 +5,10 @@ repos:
- id: fmt
- id: cargo-check
- id: clippy
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
hooks:
- id: check-yaml
- repo: local
hooks:
- id: sqlx-offline

View File

@ -31,6 +31,10 @@ services:
- POSTGRES_PASSWORD
- POSTGRES_PORT=5432
- POSTGRES_HOST=postgres
- EMAIL_USER
- EMAIL_PASSWORD
- EMAIL_SENDER="Cyberduck <cyberduck@coincoinmail.fr>"
- EMAIL_HOST=coincoinmail.fr
- PROD=true
networks:
- net

View File

@ -55,5 +55,9 @@ printf "DB_PASSWORD=${DB_PASSWORD}\n" >> .env
printf "DB_PORT=${DB_PORT}\n" >> .env
printf "DB_HOST=${DB_HOST}\n" >> .env
printf "DB_NAME=${DB_NAME}\n" >> .env
printf "EMAIL_USER=${EMAIL_USER}\n" >> .env
printf "EMAIL_SENDER=${EMAIL_SENDER}\n" >> .env
printf "EMAIL_HOST=${EMAIL_HOST}\n" >> .env
printf "EMAIL_PASSWORD=${EMAIL_PASSWORD}\n" >> .env
printf "${GREEN}Postgres has been migrated, ready to go!${END}"

View File

@ -1,11 +1,16 @@
//! src/configuration.rs
use dotenv::dotenv;
use lettre::transport::smtp::authentication::Credentials;
use lettre::AsyncSmtpTransport;
use lettre::Tokio1Executor;
use secrecy::ExposeSecret;
use secrecy::Secret;
use serde::Deserialize;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use uuid::Uuid;
use crate::email_client::EmailClient;
#[derive(Deserialize)]
pub struct DatabaseConfig {
pub user: String,
@ -19,7 +24,7 @@ pub fn parse_db_config() -> DatabaseConfig {
dotenv().ok();
envy::prefixed("DB_")
.from_env::<DatabaseConfig>()
.expect("Database config environment variables")
.expect("Missing database config environment variables")
}
impl DatabaseConfig {
@ -76,3 +81,36 @@ impl DatabaseConfig {
connection_pool
}
}
// default starttls => set as config ?
#[derive(Deserialize)]
pub struct EmailConfig {
pub sender: String,
pub user: String,
pub password: Secret<String>,
pub host: String,
}
pub fn parse_email_config() -> EmailConfig {
dotenv().ok();
envy::prefixed("EMAIL_")
.from_env::<EmailConfig>()
.expect("Missing email config environment variables")
}
impl EmailConfig {
pub fn email_client(&self) -> EmailClient {
let creds = Credentials::new(
self.user.to_owned(),
self.password.expose_secret().to_owned(),
);
let mailer = AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.host)
.unwrap()
.credentials(creds)
.build();
EmailClient {
sender: self.sender.parse().unwrap(),
mailer,
}
}
}

View File

@ -1,41 +1,37 @@
use lettre::transport::smtp::authentication::Credentials;
use lettre::message::Mailbox;
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
use crate::domain::SubscriberEmail;
use crate::domain::{SubscriberEmail, SubscriberName};
// here I use my own email server instead of an API
// I skip the "properly call and test/mock and API" part of the book
// But I learn how to directly send mail from rust
pub struct EmailClient {
sender: SubscriberEmail, // internals
pub sender: Mailbox,
pub mailer: AsyncSmtpTransport<Tokio1Executor>,
}
// create initializer from config
// test send from tests
impl EmailClient {
// init
pub fn format_recipient(name: SubscriberName, email: SubscriberEmail) -> Mailbox {
format!("{} <{}>", name.as_ref(), email.as_ref())
.parse()
.unwrap()
}
pub async fn send_email(
&self,
recipient: SubscriberEmail,
recipient: Mailbox,
subject: &str,
html_content: &str,
) -> Result<(), String> {
let email = Message::builder()
.from(self.sender.as_ref().parse().unwrap())
// todo => create from name + email
.to(recipient.as_ref().parse().unwrap())
.from(self.sender.to_owned())
.to(recipient)
.subject(subject)
.body(String::from(html_content))
.unwrap();
// set on init
let creds = Credentials::new(
self.sender.as_ref().to_string(),
"5yfHvyKUYbvvNB".to_string(),
);
// set on init
let mailer = AsyncSmtpTransport::<Tokio1Executor>::relay("smtp.coincoinmail.fr")
.unwrap()
.credentials(creds)
.build();
match mailer.send(email).await {
match self.mailer.send(email).await {
Ok(_) => Ok(()),
Err(e) => Err(format!("Could not send email: {:?}", e)),
}
@ -44,5 +40,22 @@ impl EmailClient {
#[cfg(test)]
mod tests {
// test sending => return ok
use crate::{
configuration::parse_email_config,
domain::{SubscriberEmail, SubscriberName},
};
use super::EmailClient;
#[tokio::test]
async fn send_email_without_error() {
let email_client = parse_email_config().email_client();
let name = SubscriberName::parse("flavien".to_owned()).unwrap();
let email = SubscriberEmail::parse("flavien@coincoinmail.fr".to_owned()).unwrap();
let recipient = EmailClient::format_recipient(name, email);
email_client
.send_email(recipient, "Zero2Prod test", "OK")
.await
.expect("Mail sending error");
}
}

View File

@ -1,6 +1,6 @@
use clap::{command, Parser};
use std::net::TcpListener;
use zero2prod::configuration::parse_db_config;
use zero2prod::configuration::{parse_db_config, parse_email_config};
use zero2prod::startup::run;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
@ -25,6 +25,7 @@ async fn main() -> std::io::Result<()> {
};
let listener = TcpListener::bind(address)?;
let connection_pool = parse_db_config().connection_pool().await;
let email_client = parse_email_config().email_client();
run(listener, connection_pool)?.await
run(listener, connection_pool, email_client)?.await
}

View File

@ -1,17 +1,26 @@
use crate::routes::{health_check, subscriptions};
use crate::{
email_client::EmailClient,
routes::{health_check, subscriptions},
};
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())
.service(health_check)
.service(subscriptions)
.app_data(db_pool.clone())
.app_data(email_client.clone())
})
.listen(listener)?
.run();

View File

@ -1,7 +1,7 @@
use once_cell::sync::Lazy;
use sqlx::PgPool;
use std::net::TcpListener;
use zero2prod::configuration::parse_db_config;
use zero2prod::configuration::{parse_db_config, parse_email_config};
use zero2prod::startup::run;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
@ -30,7 +30,9 @@ async fn spawn_app() -> TestApp {
let address = format!("http://127.0.0.1:{}", port);
let connection_pool = parse_db_config().test_connection_pool().await;
let server = run(listener, connection_pool.clone()).expect("Failed to bind address");
let email_client = parse_email_config().email_client();
let server =
run(listener, connection_pool.clone(), email_client).expect("Failed to bind address");
let _ = tokio::spawn(server);
TestApp {