feat: add SMTP configuration and registration endpoint for email verification

This commit is contained in:
itsscb 2025-03-14 21:40:03 +01:00
parent 8499636209
commit d971a10cf4
12 changed files with 151 additions and 12 deletions

View File

@ -33,4 +33,7 @@ jobs:
- uses: shuttle-hq/deploy-action@v2
with:
shuttle-api-key: ${{ secrets.SHUTTLE_API_KEY }}
project-id: proj_01JNH9KPMRS34FKC2NHWQ5YNNB
project-id: proj_01JNH9KPMRS34FKC2NHWQ5YNNB
secrets: |
SMTP_MAIL = '${{ secrets.SMTP_MAIL }}'
SMTP_SECRET = '${{ secrets.SMTP_SECRET }}'

3
.gitignore vendored
View File

@ -26,5 +26,6 @@ Cargo.lock
#/target
.shuttle/
node_modules/
Secrets.toml
Secrets.dev.toml
**/*.pdf

View File

@ -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"] }

View File

@ -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)),
)

View File

@ -1,4 +1,5 @@
mod health;
pub mod onboarding;
mod version;
pub use health::health_check;

12
src/api/v1/onboarding.rs Normal file
View File

@ -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)),
)
}

View File

@ -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<SMTPConfig>,
Json(mail): Json<Registration>,
) -> 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("<h1>Verify your Email Address!</h1>"))
.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)
}
}
}

View File

@ -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")),
)
}

View File

@ -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())
}

1
src/model.rs Normal file
View File

@ -0,0 +1 @@
pub mod config;

2
src/model/config.rs Normal file
View File

@ -0,0 +1,2 @@
mod smtp_config;
pub use smtp_config::SMTPConfig;

View File

@ -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<T: AsRef<str>>(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", &"<reducted>")
.finish()
}
}