diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1fdd091..cd9157c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -33,4 +33,7 @@ jobs: - uses: shuttle-hq/deploy-action@v2 with: shuttle-api-key: ${{ secrets.SHUTTLE_API_KEY }} - project-id: proj_01JNH9KPMRS34FKC2NHWQ5YNNB \ No newline at end of file + project-id: proj_01JNH9KPMRS34FKC2NHWQ5YNNB + secrets: | + SMTP_MAIL = '${{ secrets.SMTP_MAIL }}' + SMTP_SECRET = '${{ secrets.SMTP_SECRET }}' \ No newline at end of file diff --git a/.gitignore b/.gitignore index aaab43d..b13755a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,6 @@ Cargo.lock #/target .shuttle/ node_modules/ - +Secrets.toml +Secrets.dev.toml **/*.pdf diff --git a/Cargo.toml b/Cargo.toml index 8305f65..4c2969a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,9 +5,12 @@ edition = "2024" [dependencies] axum = "0.8.1" +email_address = "0.2.9" serde_json = "1.0.140" shuttle-axum = "0.53.0" shuttle-runtime = "0.53.0" tokio = "1.28.2" tower-http = { version = "0.6.2", features = ["fs"] } tracing = "0.1.41" +serde = { version = "1.0.203", features = ["derive"] } +lettre = { version = "0.11.15", default-features = false, features = ["builder", "smtp-transport", "ring", "rustls", "rustls-tls"] } diff --git a/src/api.rs b/src/api.rs index b08a27d..ee59104 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,12 +1,13 @@ -use crate::api::v1::{get_version, health_check}; +use crate::api::v1::{get_version, health_check, onboarding}; +use crate::model::config::SMTPConfig; use axum::{routing, Router}; pub mod v1; -pub fn new() -> Router { +pub fn new(smtp_config: SMTPConfig) -> Router { Router::new().nest( "/v1", - Router::new() + onboarding::new(smtp_config) .route("/version", routing::get(get_version)) .route("/health", routing::get(health_check)), ) diff --git a/src/api/v1.rs b/src/api/v1.rs index 620bdb0..95371ae 100644 --- a/src/api/v1.rs +++ b/src/api/v1.rs @@ -1,4 +1,5 @@ mod health; +pub mod onboarding; mod version; pub use health::health_check; diff --git a/src/api/v1/onboarding.rs b/src/api/v1/onboarding.rs new file mode 100644 index 0000000..7243e8d --- /dev/null +++ b/src/api/v1/onboarding.rs @@ -0,0 +1,12 @@ +mod registration; + +use crate::api::v1::onboarding::registration::register; +use crate::config::SMTPConfig; +use axum::{routing, Router}; + +pub fn new(smtp_config: SMTPConfig) -> Router { + Router::new().nest( + "/onboarding", + Router::new().route("/register", routing::post(register).with_state(smtp_config)), + ) +} diff --git a/src/api/v1/onboarding/registration.rs b/src/api/v1/onboarding/registration.rs new file mode 100644 index 0000000..a706bd8 --- /dev/null +++ b/src/api/v1/onboarding/registration.rs @@ -0,0 +1,66 @@ +use crate::config::SMTPConfig; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use lettre::message::header::ContentType; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Message, SmtpTransport, Transport}; +use serde::Deserialize; +use std::fmt::Debug; +use std::str::FromStr; +use tracing::{error, info, instrument, warn}; + +#[derive(Debug, Deserialize)] +pub struct Registration { + email_address: String, +} + +#[instrument(skip(mail, smtp))] +pub async fn register( + State(smtp): State, + Json(mail): Json, +) -> impl IntoResponse { + let email = match email_address::EmailAddress::from_str(&mail.email_address) { + Ok(m) => m, + Err(e) => { + let err = format!("Invalid email address: {e}"); + warn!(email = mail.email_address, error = %err); + return (StatusCode::BAD_REQUEST, err); + } + }; + info!(email = mail.email_address, "RegistrationRequest received"); + + #[allow(clippy::unwrap_used)] + let verification_email = Message::builder() + .from( + format!("Digitaler Frieden <{}>", smtp.mail()) + .parse() + .unwrap(), + ) + .to(email.to_string().parse().unwrap()) + .subject("Verify your email address") + .header(ContentType::TEXT_HTML) + .body(String::from("

Verify your Email Address!

")) + .unwrap(); + + let credentials = Credentials::new(smtp.mail().to_string(), smtp.secret().to_string()); + + #[allow(clippy::unwrap_used)] + let mailer = SmtpTransport::relay("smtp.gmail.com") + .unwrap() + .credentials(credentials) + .build(); + + match mailer.send(&verification_email) { + Ok(_) => { + info!(email = mail.email_address, "Verification email sent"); + (StatusCode::OK, "Verification email sent".to_string()) + } + Err(e) => { + let err = format!("Failed to send verification email: {e}"); + error!(error = %err); + (StatusCode::INTERNAL_SERVER_ERROR, err) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 5eb8b5b..34df6cb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,19 @@ mod api; +mod model; +pub use model::config; + +use crate::model::config::SMTPConfig; use axum::Router; use tower_http::services::{ServeDir, ServeFile}; const VERSION: &str = env!("CARGO_PKG_VERSION"); -pub fn new() -> Router { - Router::new().nest("/api", api::new()).fallback_service( - ServeDir::new("frontend/dist/frontend/browser") - .not_found_service(ServeFile::new("frontend/dist/frontend/browser/index.html")), - ) +pub fn new(smtp_config: SMTPConfig) -> Router { + Router::new() + .nest("/api", api::new(smtp_config)) + .fallback_service( + ServeDir::new("frontend/dist/frontend/browser") + .not_found_service(ServeFile::new("frontend/dist/frontend/browser/index.html")), + ) } diff --git a/src/main.rs b/src/main.rs index ac9ca1a..f9b2a9a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,13 @@ +use shuttle_runtime::SecretStore; + #[shuttle_runtime::main] #[allow(clippy::unused_async)] -async fn main() -> shuttle_axum::ShuttleAxum { - let router = digitaler_frieden::new(); +async fn main(#[shuttle_runtime::Secrets] secrets: SecretStore) -> shuttle_axum::ShuttleAxum { + #[allow(clippy::expect_used)] + let smtp_config = digitaler_frieden::config::SMTPConfig::new( + &secrets.get("SMTP_MAIL").expect("SMTP_MAIL not set"), + &secrets.get("SMTP_SECRET").expect("SMTP_SECRET not set"), + ); + let router = digitaler_frieden::new(smtp_config); Ok(router.into()) } diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..ef68c36 --- /dev/null +++ b/src/model.rs @@ -0,0 +1 @@ +pub mod config; diff --git a/src/model/config.rs b/src/model/config.rs new file mode 100644 index 0000000..445c8a6 --- /dev/null +++ b/src/model/config.rs @@ -0,0 +1,2 @@ +mod smtp_config; +pub use smtp_config::SMTPConfig; diff --git a/src/model/config/smtp_config.rs b/src/model/config/smtp_config.rs new file mode 100644 index 0000000..3b47b38 --- /dev/null +++ b/src/model/config/smtp_config.rs @@ -0,0 +1,36 @@ +use serde::Deserialize; +use std::fmt::Debug; + +#[derive(Clone, Deserialize)] +pub struct SMTPConfig { + mail: String, + secret: String, +} + +impl SMTPConfig { + pub fn new>(mail: T, secret: T) -> Self { + Self { + mail: mail.as_ref().to_owned(), + secret: secret.as_ref().to_owned(), + } + } + + #[must_use] + pub fn mail(&self) -> &str { + &self.mail + } + + #[must_use] + pub fn secret(&self) -> &str { + &self.secret + } +} + +impl Debug for SMTPConfig { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SMTPConfig") + .field("mail", &self.mail) + .field("secret", &"") + .finish() + } +}