From 8d55feca5093893b8eb0dfeab30eea94c2e06344 Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Tue, 4 Nov 2025 16:27:54 +0100 Subject: [PATCH] feat: relay contact requests to SMTP server --- backend/Cargo.lock | 53 +++ backend/Cargo.toml | 1 + backend/README.md | 151 +++++++-- backend/settings/base.yaml | 6 +- backend/settings/development.yaml | 10 +- backend/src/lib.rs | 22 +- backend/src/main.rs | 2 + backend/src/route/contact.rs | 513 ++++++++++++++++++++++++++++++ backend/src/route/health.rs | 12 +- backend/src/route/meta.rs | 79 +++-- backend/src/route/mod.rs | 44 ++- backend/src/settings.rs | 242 +++++++++++++- backend/src/startup.rs | 39 ++- backend/src/telemetry.rs | 16 + 14 files changed, 1091 insertions(+), 99 deletions(-) create mode 100644 backend/src/route/contact.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index e7c4f88..fdbf345 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1526,6 +1526,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "validator", ] [[package]] @@ -1729,6 +1730,28 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -2569,6 +2592,36 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "validator" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa" +dependencies = [ + "idna", + "once_cell", + "regex", + "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +dependencies = [ + "darling", + "once_cell", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "valuable" version = "0.1.1" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 5f6f526..256f530 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -26,6 +26,7 @@ thiserror = "2.0.17" tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["fmt", "std", "env-filter", "registry", "json", "tracing-log"] } +validator = { version = "0.20.0", features = ["derive"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } diff --git a/backend/README.md b/backend/README.md index 17a4d12..9f1d93d 100644 --- a/backend/README.md +++ b/backend/README.md @@ -4,12 +4,54 @@ The backend for [phundrak.com](https://phundrak.com), built with Rust and the [P ## Features -- **RESTful API** with OpenAPI documentation +- **RESTful API** with automatic OpenAPI/Swagger documentation +- **Contact form** with SMTP email relay (supports TLS, STARTTLS, and unencrypted) - **Type-safe routing** using Poem's declarative API -- **Structured logging** with `tracing` +- **Hierarchical configuration** with YAML files and environment variable overrides +- **Structured logging** with `tracing` and `tracing-subscriber` - **Strict linting** for code quality and safety - **Comprehensive testing** with integration test support +## API Endpoints + +The application provides the following endpoints: + +- **Swagger UI**: `/` - Interactive API documentation +- **OpenAPI Spec**: `/specs` - OpenAPI specification in YAML format +- **Health Check**: `GET /api/health` - Returns server health status +- **Application Metadata**: `GET /api/meta` - Returns version and build info +- **Contact Form**: `POST /api/contact` - Submit contact form (relays to SMTP) + +## Configuration + +Configuration is loaded from multiple sources in order of precedence: + +1. `settings/base.yaml` - Base configuration +2. `settings/{environment}.yaml` - Environment-specific (development/production) +3. Environment variables prefixed with `APP__` (e.g., `APP__APPLICATION__PORT=8080`) + +The environment is determined by the `APP_ENVIRONMENT` variable (defaults to "development"). + +### Configuration Example + +```yaml +application: + port: 3100 + version: "0.1.0" + +email: + host: smtp.example.com + port: 587 + user: user@example.com + from: Contact Form + password: your_password + recipient: Admin + starttls: true # Use STARTTLS (typically port 587) + tls: false # Use implicit TLS (typically port 465) +``` + +You can also use a `.env` file for local development settings. + ## Development ### Prerequisites @@ -25,7 +67,7 @@ To start the development server: cargo run ``` -The server will start on the configured port (check your configuration for details). +The server will start on the configured port (default: 3100). ### Building @@ -49,6 +91,8 @@ Run all tests: ```bash cargo test +# or +just test ``` Run a specific test: @@ -63,31 +107,53 @@ Run tests with output: cargo test -- --nocapture ``` +Run tests with coverage: + +```bash +cargo tarpaulin --config .tarpaulin.local.toml +# or +just coverage +``` + +### Testing Notes + +- Integration tests use random TCP ports to avoid conflicts +- Tests use `get_test_app()` helper for consistent test setup +- Telemetry is automatically disabled during tests +- Tests are organized in `#[cfg(test)]` modules within each file + ## Code Quality ### Linting -This project uses strict Clippy linting rules: +This project uses extremely strict Clippy linting rules: - `#![deny(clippy::all)]` - `#![deny(clippy::pedantic)]` - `#![deny(clippy::nursery)]` +- `#![warn(missing_docs)]` Run Clippy to check for issues: ```bash cargo clippy --all-targets +# or +just lint ``` +All code must pass these checks before committing. + ### Continuous Checking with Bacon For continuous testing and linting during development, use [bacon](https://dystroy.org/bacon/): ```bash -bacon +bacon # Runs clippy-all by default +bacon test # Runs tests continuously +bacon clippy # Runs clippy on default target only ``` -This will watch your files and automatically run clippy or tests on changes. +Press 'c' in bacon to run clippy-all. ## Code Style @@ -99,29 +165,29 @@ This will watch your files and automatically run clippy or tests on changes. ### Logging -- Use `tracing::event!` for logging -- Always set `target: "backend"` -- Use appropriate log levels (trace, debug, info, warn, error) +Always use `tracing::event!` with proper target and level: -Example: ```rust -tracing::event!(target: "backend", tracing::Level::INFO, "Server started"); +tracing::event!( + target: "backend", // or "backend::module_name" + tracing::Level::INFO, + "Message here" +); ``` ### Imports Organize imports in three groups: 1. Standard library (`std::*`) -2. External crates -3. Local modules +2. External crates (poem, serde, etc.) +3. Local modules (`crate::*`) -Use explicit paths (e.g., `poem_openapi::ApiResponse` instead of wildcards). +### Testing Conventions -### Testing - -- Use `#[cfg(test)]` module blocks -- Leverage Poem's test utilities for endpoint testing -- Use random TCP listeners for integration tests to avoid port conflicts +- Use `#[tokio::test]` for async tests +- Use descriptive test names that explain what is being tested +- Test both success and error cases +- For endpoint tests, verify both status codes and response bodies ## Project Structure @@ -129,15 +195,50 @@ Use explicit paths (e.g., `poem_openapi::ApiResponse` instead of wildcards). backend/ ├── src/ │ ├── main.rs # Application entry point -│ ├── api/ # API endpoints -│ ├── models/ # Data models -│ ├── services/ # Business logic -│ └── utils/ # Utility functions -├── tests/ # Integration tests +│ ├── lib.rs # Library root with run() and prepare() +│ ├── startup.rs # Application builder, server setup +│ ├── settings.rs # Configuration management +│ ├── telemetry.rs # Logging and tracing setup +│ └── route/ # API route handlers +│ ├── mod.rs # Route organization +│ ├── contact.rs # Contact form endpoint +│ ├── health.rs # Health check endpoint +│ └── meta.rs # Metadata endpoint +├── settings/ # Configuration files +│ ├── base.yaml # Base configuration +│ ├── development.yaml # Development overrides +│ └── production.yaml # Production overrides ├── Cargo.toml # Dependencies and metadata └── README.md # This file ``` +## Architecture + +### Application Initialization Flow + +1. `main.rs` calls `run()` from `lib.rs` +2. `run()` calls `prepare()` which: + - Loads environment variables from `.env` file + - Initializes `Settings` from YAML files and environment variables + - Sets up telemetry/logging (unless in test mode) + - Builds the `Application` with optional TCP listener +3. `Application::build()`: + - Sets up OpenAPI service with all API endpoints + - Configures Swagger UI at the root path (`/`) + - Configures API routes under `/api` prefix + - Creates server with TCP listener +4. Application runs with CORS middleware and settings injected as data + +### Email Handling + +The contact form supports multiple SMTP configurations: +- **Implicit TLS (SMTPS)** - typically port 465 +- **STARTTLS (Always/Opportunistic)** - typically port 587 +- **Unencrypted** (for local dev) - with or without authentication + +The `SmtpTransport` is built dynamically from `EmailSettings` based on +TLS/STARTTLS configuration. + ## License -See the root repository for license information. +AGPL-3.0-only - See the root repository for full license information. diff --git a/backend/settings/base.yaml b/backend/settings/base.yaml index 7a73e5c..eb95418 100644 --- a/backend/settings/base.yaml +++ b/backend/settings/base.yaml @@ -3,7 +3,11 @@ application: version: "0.1.0" email: - host: localhost + host: email.example.com + port: 587 user: user from: Contact Form password: hunter2 + recipient: Admin + starttls: false + tls: false diff --git a/backend/settings/development.yaml b/backend/settings/development.yaml index 24d1c3b..417a9fe 100644 --- a/backend/settings/development.yaml +++ b/backend/settings/development.yaml @@ -5,4 +5,12 @@ application: protocol: http host: 127.0.0.1 base_url: http://127.0.0.1:3100 - name: "com.phundrak.backend.prod" + name: "com.phundrak.backend.dev" + +email: + host: localhost + port: 1025 + user: "" + password: "" + tls: false + starttls: false diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 17ddcc3..5a8146d 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,13 +1,23 @@ +//! Backend API server for phundrak.com +//! +//! This is a REST API built with the Poem framework that provides: +//! - Health check endpoints +//! - Application metadata endpoints +//! - Contact form submission with email integration + #![deny(clippy::all)] #![deny(clippy::pedantic)] #![deny(clippy::nursery)] -#![allow(clippy::missing_panics_doc)] -#![allow(clippy::missing_errors_doc)] +#![warn(missing_docs)] #![allow(clippy::unused_async)] +/// API route handlers and endpoints pub mod route; +/// Application configuration settings pub mod settings; +/// Application startup and server configuration pub mod startup; +/// Logging and tracing setup pub mod telemetry; type MaybeListener = Option>; @@ -36,13 +46,19 @@ fn prepare(listener: MaybeListener) -> startup::Application { tracing::event!( target: "backend", tracing::Level::INFO, - "Documentation available at http://{}:{}/docs", + "Documentation available at http://{}:{}/", application.host(), application.port() ); application } +/// Runs the application with the specified TCP listener. +/// +/// # Errors +/// +/// Returns a `std::io::Error` if the server fails to start or encounters +/// an I/O error during runtime (e.g., port already in use, network issues). #[cfg(not(tarpaulin_include))] pub async fn run(listener: MaybeListener) -> Result<(), std::io::Error> { let application = prepare(listener); diff --git a/backend/src/main.rs b/backend/src/main.rs index 7a76854..0fda2a3 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,3 +1,5 @@ +//! Backend server entry point. + #[cfg(not(tarpaulin_include))] #[tokio::main] async fn main() -> Result<(), std::io::Error> { diff --git a/backend/src/route/contact.rs b/backend/src/route/contact.rs new file mode 100644 index 0000000..0d8c87c --- /dev/null +++ b/backend/src/route/contact.rs @@ -0,0 +1,513 @@ +//! Contact form endpoint for handling user submissions and sending emails. +//! +//! This module provides functionality to: +//! - Validate contact form submissions +//! - Detect spam using honeypot fields +//! - Send emails via SMTP with various TLS configurations + +use lettre::{ + Message, SmtpTransport, Transport, message::header::ContentType, + transport::smtp::authentication::Credentials, +}; +use poem_openapi::{ApiResponse, Object, OpenApi, payload::Json}; +use validator::Validate; + +use super::ApiCategory; +use crate::settings::{EmailSettings, Starttls}; + +impl TryFrom<&EmailSettings> for SmtpTransport { + type Error = lettre::transport::smtp::Error; + + fn try_from(settings: &EmailSettings) -> Result { + if settings.tls { + // Implicit TLS (SMTPS) - typically port 465 + tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using implicit TLS (SMTPS)"); + let creds = Credentials::new(settings.user.clone(), settings.password.clone()); + Ok(Self::relay(&settings.host)? + .port(settings.port) + .credentials(creds) + .build()) + } else { + // STARTTLS or no encryption + match settings.starttls { + Starttls::Never => { + // For local development without TLS + tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using unencrypted connection"); + let builder = Self::builder_dangerous(&settings.host).port(settings.port); + if settings.user.is_empty() { + Ok(builder.build()) + } else { + let creds = + Credentials::new(settings.user.clone(), settings.password.clone()); + Ok(builder.credentials(creds).build()) + } + } + Starttls::Opportunistic | Starttls::Always => { + // STARTTLS - typically port 587 + tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Using STARTTLS"); + let creds = Credentials::new(settings.user.clone(), settings.password.clone()); + Ok(Self::starttls_relay(&settings.host)? + .port(settings.port) + .credentials(creds) + .build()) + } + } + } + } +} + +#[derive(Debug, Object, Validate)] +struct ContactRequest { + #[validate(length( + min = 1, + max = "100", + message = "Name must be between 1 and 100 characters" + ))] + name: String, + #[validate(email(message = "Invalid email address"))] + email: String, + #[validate(length( + min = 10, + max = 5000, + message = "Message must be between 10 and 5000 characters" + ))] + message: String, + /// Honeypot field - should always be empty + #[oai(rename = "website")] + honeypot: Option, +} + +#[derive(Debug, Object, serde::Deserialize)] +struct ContactResponse { + success: bool, + message: String, +} + +impl From for Json { + fn from(value: ContactResponse) -> Self { + Self(value) + } +} + +#[derive(ApiResponse)] +enum ContactApiResponse { + /// Success + #[oai(status = 200)] + Ok(Json), + /// Bad Request - validation failed + #[oai(status = 400)] + BadRequest(Json), + /// Too Many Requests - rate limit exceeded + #[oai(status = 429)] + TooManyRequests(Json), + /// Internal Server Error + #[oai(status = 500)] + InternalServerError(Json), +} + +/// API for handling contact form submissions and sending emails. +#[derive(Clone)] +pub struct ContactApi { + settings: EmailSettings, +} + +impl From for ContactApi { + fn from(settings: EmailSettings) -> Self { + Self { settings } + } +} + +#[OpenApi(tag = "ApiCategory::Contact")] +impl ContactApi { + /// Submit a contact form + /// + /// Send a message through the contact form. Rate limited to prevent spam. + #[oai(path = "/contact", method = "post")] + async fn submit_contact( + &self, + body: Json, + remote_addr: Option>, + ) -> ContactApiResponse { + let body = body.0; + if body.honeypot.is_some() { + tracing::event!(target: "backend::contact", tracing::Level::INFO, "Honeypot triggered, rejecting request silently. IP: {}", remote_addr.map_or_else(|| "No remote address found".to_owned(), |ip| ip.0.to_string())); + return ContactApiResponse::Ok( + ContactResponse { + success: true, + message: "Message sent successfully, but not really, you bot".to_owned(), + } + .into(), + ); + } + if let Err(e) = body.validate() { + return ContactApiResponse::BadRequest( + ContactResponse { + success: false, + message: format!("Validation error: {e}"), + } + .into(), + ); + } + match self.send_email(&body).await { + Ok(()) => { + tracing::event!(target: "backend::contact", tracing::Level::INFO, "Message sent successfully from: {}", body.email); + ContactApiResponse::Ok( + ContactResponse { + success: true, + message: "Message sent successfully".to_owned(), + } + .into(), + ) + } + Err(e) => { + tracing::event!(target: "backend::contact", tracing::Level::ERROR, "Failed to send email: {}", e); + ContactApiResponse::InternalServerError( + ContactResponse { + success: false, + message: "Failed to send message. Please try again later.".to_owned(), + } + .into(), + ) + } + } + } + + async fn send_email(&self, request: &ContactRequest) -> Result<(), Box> { + let email_body = format!( + r"New contact form submission: + +Name: {} +Email: {}, + +Message: +{}", + request.name, request.email, request.message + ); + tracing::event!(target: "email", tracing::Level::DEBUG, "Sending email content: {}", email_body); + let email = Message::builder() + .from(self.settings.from.parse()?) + .reply_to(format!("{} <{}>", request.name, request.email).parse()?) + .to(self.settings.recipient.parse()?) + .subject(format!("Contact Form: {}", request.name)) + .header(ContentType::TEXT_PLAIN) + .body(email_body)?; + tracing::event!(target: "email", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}")); + + let mailer = SmtpTransport::try_from(&self.settings)?; + mailer.send(&email)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Tests for ContactRequest validation + #[test] + fn contact_request_valid() { + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "This is a test message that is long enough.".to_string(), + honeypot: None, + }; + assert!(request.validate().is_ok()); + } + + #[test] + fn contact_request_name_too_short() { + let request = ContactRequest { + name: String::new(), + email: "john@example.com".to_string(), + message: "This is a test message that is long enough.".to_string(), + honeypot: None, + }; + assert!(request.validate().is_err()); + } + + #[test] + fn contact_request_name_too_long() { + let request = ContactRequest { + name: "a".repeat(101), + email: "john@example.com".to_string(), + message: "This is a test message that is long enough.".to_string(), + honeypot: None, + }; + assert!(request.validate().is_err()); + } + + #[test] + fn contact_request_name_at_max_length() { + let request = ContactRequest { + name: "a".repeat(100), + email: "john@example.com".to_string(), + message: "This is a test message that is long enough.".to_string(), + honeypot: None, + }; + assert!(request.validate().is_ok()); + } + + #[test] + fn contact_request_invalid_email() { + let request = ContactRequest { + name: "John Doe".to_string(), + email: "not-an-email".to_string(), + message: "This is a test message that is long enough.".to_string(), + honeypot: None, + }; + assert!(request.validate().is_err()); + } + + #[test] + fn contact_request_message_too_short() { + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Short".to_string(), + honeypot: None, + }; + assert!(request.validate().is_err()); + } + + #[test] + fn contact_request_message_too_long() { + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "a".repeat(5001), + honeypot: None, + }; + assert!(request.validate().is_err()); + } + + #[test] + fn contact_request_message_at_min_length() { + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "a".repeat(10), + honeypot: None, + }; + assert!(request.validate().is_ok()); + } + + #[test] + fn contact_request_message_at_max_length() { + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "a".repeat(5000), + honeypot: None, + }; + assert!(request.validate().is_ok()); + } + + // Tests for SmtpTransport TryFrom implementation + #[test] + fn smtp_transport_implicit_tls() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 465, + user: "user@example.com".to_string(), + password: "password".to_string(), + from: "from@example.com".to_string(), + recipient: "to@example.com".to_string(), + tls: true, + starttls: Starttls::Never, + }; + + let result = SmtpTransport::try_from(&settings); + assert!(result.is_ok()); + } + + #[test] + fn smtp_transport_starttls_always() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + password: "password".to_string(), + from: "from@example.com".to_string(), + recipient: "to@example.com".to_string(), + tls: false, + starttls: Starttls::Always, + }; + + let result = SmtpTransport::try_from(&settings); + assert!(result.is_ok()); + } + + #[test] + fn smtp_transport_starttls_opportunistic() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + password: "password".to_string(), + from: "from@example.com".to_string(), + recipient: "to@example.com".to_string(), + tls: false, + starttls: Starttls::Opportunistic, + }; + + let result = SmtpTransport::try_from(&settings); + assert!(result.is_ok()); + } + + #[test] + fn smtp_transport_no_encryption_with_credentials() { + let settings = EmailSettings { + host: "localhost".to_string(), + port: 1025, + user: "user@example.com".to_string(), + password: "password".to_string(), + from: "from@example.com".to_string(), + recipient: "to@example.com".to_string(), + tls: false, + starttls: Starttls::Never, + }; + + let result = SmtpTransport::try_from(&settings); + assert!(result.is_ok()); + } + + #[test] + fn smtp_transport_no_encryption_no_credentials() { + let settings = EmailSettings { + host: "localhost".to_string(), + port: 1025, + user: String::new(), + password: String::new(), + from: "from@example.com".to_string(), + recipient: "to@example.com".to_string(), + tls: false, + starttls: Starttls::Never, + }; + + let result = SmtpTransport::try_from(&settings); + assert!(result.is_ok()); + } + + // Integration tests for contact API endpoint + #[tokio::test] + async fn contact_endpoint_honeypot_triggered() { + let app = crate::get_test_app(); + let cli = poem::test::TestClient::new(app); + + let body = serde_json::json!({ + "name": "Bot Name", + "email": "bot@example.com", + "message": "This is a spam message from a bot.", + "website": "http://spam.com" + }); + + let resp = cli.post("/api/contact").body_json(&body).send().await; + resp.assert_status_is_ok(); + + let json_text = resp.0.into_body().into_string().await.unwrap(); + let json: ContactResponse = serde_json::from_str(&json_text).unwrap(); + assert!(json.success); + assert!(json.message.contains("not really")); + } + + #[tokio::test] + async fn contact_endpoint_validation_error_empty_name() { + let app = crate::get_test_app(); + let cli = poem::test::TestClient::new(app); + + let body = serde_json::json!({ + "name": "", + "email": "test@example.com", + "message": "This is a valid message that is long enough." + }); + + let resp = cli.post("/api/contact").body_json(&body).send().await; + resp.assert_status(poem::http::StatusCode::BAD_REQUEST); + + let json_text = resp.0.into_body().into_string().await.unwrap(); + let json: ContactResponse = serde_json::from_str(&json_text).unwrap(); + assert!(!json.success); + assert!(json.message.contains("Validation error")); + } + + #[tokio::test] + async fn contact_endpoint_validation_error_invalid_email() { + let app = crate::get_test_app(); + let cli = poem::test::TestClient::new(app); + + let body = serde_json::json!({ + "name": "Test User", + "email": "not-an-email", + "message": "This is a valid message that is long enough." + }); + + let resp = cli.post("/api/contact").body_json(&body).send().await; + resp.assert_status(poem::http::StatusCode::BAD_REQUEST); + + let json_text = resp.0.into_body().into_string().await.unwrap(); + let json: ContactResponse = serde_json::from_str(&json_text).unwrap(); + assert!(!json.success); + assert!(json.message.contains("Validation error")); + } + + #[tokio::test] + async fn contact_endpoint_validation_error_message_too_short() { + let app = crate::get_test_app(); + let cli = poem::test::TestClient::new(app); + + let body = serde_json::json!({ + "name": "Test User", + "email": "test@example.com", + "message": "Short" + }); + + let resp = cli.post("/api/contact").body_json(&body).send().await; + resp.assert_status(poem::http::StatusCode::BAD_REQUEST); + + let json_text = resp.0.into_body().into_string().await.unwrap(); + let json: ContactResponse = serde_json::from_str(&json_text).unwrap(); + assert!(!json.success); + assert!(json.message.contains("Validation error")); + } + + #[tokio::test] + async fn contact_endpoint_validation_error_name_too_long() { + let app = crate::get_test_app(); + let cli = poem::test::TestClient::new(app); + + let body = serde_json::json!({ + "name": "a".repeat(101), + "email": "test@example.com", + "message": "This is a valid message that is long enough." + }); + + let resp = cli.post("/api/contact").body_json(&body).send().await; + resp.assert_status(poem::http::StatusCode::BAD_REQUEST); + + let json_text = resp.0.into_body().into_string().await.unwrap(); + let json: ContactResponse = serde_json::from_str(&json_text).unwrap(); + assert!(!json.success); + assert!(json.message.contains("Validation error")); + } + + #[tokio::test] + async fn contact_endpoint_validation_error_message_too_long() { + let app = crate::get_test_app(); + let cli = poem::test::TestClient::new(app); + + let body = serde_json::json!({ + "name": "Test User", + "email": "test@example.com", + "message": "a".repeat(5001) + }); + + let resp = cli.post("/api/contact").body_json(&body).send().await; + resp.assert_status(poem::http::StatusCode::BAD_REQUEST); + + let json_text = resp.0.into_body().into_string().await.unwrap(); + let json: ContactResponse = serde_json::from_str(&json_text).unwrap(); + assert!(!json.success); + assert!(json.message.contains("Validation error")); + } +} diff --git a/backend/src/route/health.rs b/backend/src/route/health.rs index 1948df4..fbbe868 100644 --- a/backend/src/route/health.rs +++ b/backend/src/route/health.rs @@ -1,3 +1,5 @@ +//! Health check endpoint for monitoring service availability. + use poem_openapi::{ApiResponse, OpenApi}; use super::ApiCategory; @@ -8,13 +10,15 @@ enum HealthResponse { Ok, } +/// Health check API for monitoring service availability. +#[derive(Default, Clone)] pub struct HealthApi; -#[OpenApi(prefix_path = "/v1/health-check", tag = "ApiCategory::Health")] +#[OpenApi(tag = "ApiCategory::Health")] impl HealthApi { - #[oai(path = "/", method = "get")] + #[oai(path = "/health", method = "get")] async fn ping(&self) -> HealthResponse { - tracing::event!(target: "backend", tracing::Level::DEBUG, "Accessing health-check endpoint"); + tracing::event!(target: "backend::health", tracing::Level::DEBUG, "Accessing health-check endpoint"); HealthResponse::Ok } } @@ -23,7 +27,7 @@ impl HealthApi { async fn health_check_works() { let app = crate::get_test_app(); let cli = poem::test::TestClient::new(app); - let resp = cli.get("/v1/health-check").send().await; + let resp = cli.get("/api/health").send().await; resp.assert_status_is_ok(); resp.assert_text("").await; } diff --git a/backend/src/route/meta.rs b/backend/src/route/meta.rs index 1dfd95e..3ee4bb2 100644 --- a/backend/src/route/meta.rs +++ b/backend/src/route/meta.rs @@ -1,8 +1,10 @@ +//! Application metadata endpoint for retrieving version and name information. + use poem::Result; use poem_openapi::{ApiResponse, Object, OpenApi, payload::Json}; use super::ApiCategory; -use crate::settings::Settings; +use crate::settings::ApplicationSettings; #[derive(Object, Debug, Clone, serde::Serialize, serde::Deserialize)] struct Meta { @@ -10,10 +12,10 @@ struct Meta { name: String, } -impl From> for Meta { - fn from(value: poem::web::Data<&Settings>) -> Self { - let version = value.application.version.clone(); - let name = value.application.name.clone(); +impl From<&MetaApi> for Meta { + fn from(value: &MetaApi) -> Self { + let version = value.version.clone(); + let name = value.name.clone(); Self { version, name } } } @@ -24,63 +26,56 @@ enum MetaResponse { Meta(Json), } -pub struct MetaApi; +/// API for retrieving application metadata (name and version). +#[derive(Clone)] +pub struct MetaApi { + name: String, + version: String, +} -#[OpenApi(prefix_path = "/v1/meta", tag = "ApiCategory::Meta")] +impl From<&ApplicationSettings> for MetaApi { + fn from(value: &ApplicationSettings) -> Self { + let name = value.name.clone(); + let version = value.version.clone(); + Self { name, version } + } +} + +#[OpenApi(tag = "ApiCategory::Meta")] impl MetaApi { - #[oai(path = "/", method = "get")] - async fn meta(&self, settings: poem::web::Data<&Settings>) -> Result { - tracing::event!(target: "backend", tracing::Level::DEBUG, "Accessing meta endpoint"); - Ok(MetaResponse::Meta(Json(settings.into()))) + #[oai(path = "/meta", method = "get")] + async fn meta(&self) -> Result { + tracing::event!(target: "backend::meta", tracing::Level::DEBUG, "Accessing meta endpoint"); + Ok(MetaResponse::Meta(Json(self.into()))) } } #[cfg(test)] mod tests { - use super::*; - use crate::settings::ApplicationSettings; - #[tokio::test] async fn meta_endpoint_returns_correct_data() { let app = crate::get_test_app(); let cli = poem::test::TestClient::new(app); - let resp = cli.get("/v1/meta").send().await; + let resp = cli.get("/api/meta").send().await; resp.assert_status_is_ok(); - // let json = resp.0.into_json().await; - // assert!(json.is_ok(), "Response should be valid JSON"); - // let json_value: serde_json::Value = json.unwrap(); + let json_value: serde_json::Value = resp.json().await.value().deserialize(); - // assert!(json_value.get("version").is_some(), "Response should have version field"); - // assert!(json_value.get("name").is_some(), "Response should have name field"); + assert!( + json_value.get("version").is_some(), + "Response should have version field" + ); + assert!( + json_value.get("name").is_some(), + "Response should have name field" + ); } #[tokio::test] async fn meta_endpoint_returns_200_status() { let app = crate::get_test_app(); let cli = poem::test::TestClient::new(app); - let resp = cli.get("/v1/meta").send().await; + let resp = cli.get("/api/meta").send().await; resp.assert_status_is_ok(); } - - #[test] - fn meta_from_settings_conversion() { - let settings = Settings { - application: ApplicationSettings { - name: "test-app".to_string(), - version: "1.0.0".to_string(), - port: 8080, - host: "127.0.0.1".to_string(), - base_url: "http://localhost:8080".to_string(), - protocol: "http".to_string(), - }, - debug: false, - email: crate::settings::EmailSettings::default(), - frontend_url: "http://localhost:3000".to_string(), - }; - - let meta: Meta = poem::web::Data(&settings).into(); - assert_eq!(meta.name, "test-app"); - assert_eq!(meta.version, "1.0.0"); - } } diff --git a/backend/src/route/mod.rs b/backend/src/route/mod.rs index f9950ea..8801189 100644 --- a/backend/src/route/mod.rs +++ b/backend/src/route/mod.rs @@ -1,18 +1,46 @@ -use poem_openapi::{OpenApi, Tags}; +//! API route handlers for the backend server. +//! +//! This module contains all the HTTP endpoint handlers organized by functionality: +//! - Contact form handling +//! - Health checks +//! - Application metadata +use poem_openapi::Tags; + +mod contact; mod health; -pub use health::HealthApi; - mod meta; -pub use meta::MetaApi; + +use crate::settings::Settings; #[derive(Tags)] enum ApiCategory { + Contact, Health, - Meta + Meta, } -pub(crate) struct Api; +pub(crate) struct Api { + contact: contact::ContactApi, + health: health::HealthApi, + meta: meta::MetaApi, +} -#[OpenApi] -impl Api {} +impl From<&Settings> for Api { + fn from(value: &Settings) -> Self { + let contact = contact::ContactApi::from(value.clone().email); + let health = health::HealthApi; + let meta = meta::MetaApi::from(&value.application); + Self { + contact, + health, + meta, + } + } +} + +impl Api { + pub fn apis(self) -> (contact::ContactApi, health::HealthApi, meta::MetaApi) { + (self.contact, self.health, self.meta) + } +} diff --git a/backend/src/settings.rs b/backend/src/settings.rs index f40a0cf..f9bdde9 100644 --- a/backend/src/settings.rs +++ b/backend/src/settings.rs @@ -1,12 +1,41 @@ +//! Application configuration settings. +//! +//! This module provides configuration structures that can be loaded from: +//! - YAML configuration files (base.yaml and environment-specific files) +//! - Environment variables (prefixed with APP__) +//! +//! Settings include application details, email server configuration, and environment settings. + +/// Application configuration settings. +/// +/// Loads configuration from YAML files and environment variables. #[derive(Debug, serde::Deserialize, Clone, Default)] pub struct Settings { + /// Application-specific settings (name, version, host, port, etc.) pub application: ApplicationSettings, + /// Debug mode flag pub debug: bool, + /// Email server configuration for contact form pub email: EmailSettings, + /// Frontend URL for CORS configuration pub frontend_url: String, } impl Settings { + /// Creates a new `Settings` instance by loading configuration from files and environment variables. + /// + /// # Errors + /// + /// Returns a `config::ConfigError` if: + /// - Configuration files cannot be read or parsed + /// - Required configuration values are missing + /// - Configuration values cannot be deserialized into the expected types + /// + /// # Panics + /// + /// Panics if: + /// - The current directory cannot be determined + /// - The `APP_ENVIRONMENT` variable contains an invalid value (not "dev", "development", "prod", or "production") pub fn new() -> Result { let base_path = std::env::current_dir().expect("Failed to determine the current directory"); let settings_directory = base_path.join("settings"); @@ -31,20 +60,30 @@ impl Settings { } } +/// Application-specific configuration settings. #[derive(Debug, serde::Deserialize, Clone, Default)] pub struct ApplicationSettings { + /// Application name pub name: String, + /// Application version pub version: String, + /// Port to bind to pub port: u16, + /// Host address to bind to pub host: String, + /// Base URL of the application pub base_url: String, + /// Protocol (http or https) pub protocol: String, } +/// Application environment. #[derive(Debug, PartialEq, Eq, Default)] pub enum Environment { + /// Development environment #[default] Development, + /// Production environment Production, } @@ -80,12 +119,116 @@ impl TryFrom<&str> for Environment { } } +/// Email server configuration for the contact form. #[derive(Debug, serde::Deserialize, Clone, Default)] pub struct EmailSettings { + /// SMTP server hostname pub host: String, + /// SMTP server port + pub port: u16, + /// SMTP authentication username pub user: String, - pub password: String, + /// Email address to send from pub from: String, + /// SMTP authentication password + pub password: String, + /// Email address to send contact form submissions to + pub recipient: String, + /// STARTTLS configuration + pub starttls: Starttls, + /// Whether to use implicit TLS (SMTPS) + pub tls: bool, +} + +/// STARTTLS configuration for SMTP connections. +#[derive(Debug, PartialEq, Eq, Default, Clone)] +pub enum Starttls { + /// Never use STARTTLS (unencrypted connection) + #[default] + Never, + /// Use STARTTLS if available (opportunistic encryption) + Opportunistic, + /// Always use STARTTLS (required encryption) + Always, +} + +impl TryFrom<&str> for Starttls { + type Error = String; + + fn try_from(value: &str) -> Result { + match value.to_lowercase().as_str() { + "off" | "no" | "never" => Ok(Self::Never), + "opportunistic" => Ok(Self::Opportunistic), + "yes" | "always" => Ok(Self::Always), + other => Err(format!( + "{other} is not a supported option. Use either `yes`, `no`, or `opportunistic`" + )), + } + } +} + +impl TryFrom for Starttls { + type Error = String; + fn try_from(value: String) -> Result { + value.as_str().try_into() + } +} + +impl From for Starttls { + fn from(value: bool) -> Self { + if value { Self::Always } else { Self::Never } + } +} + +impl std::fmt::Display for Starttls { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let self_str = match self { + Self::Never => "never", + Self::Opportunistic => "opportunistic", + Self::Always => "always", + }; + write!(f, "{self_str}") + } +} + +impl<'de> serde::Deserialize<'de> for Starttls { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct StartlsVisitor; + + impl serde::de::Visitor<'_> for StartlsVisitor { + type Value = Starttls; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a string or boolean representing STARTTLS setting (e.g., 'yes', 'no', 'opportunistic', true, false)") + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + Starttls::try_from(value).map_err(E::custom) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + Starttls::try_from(value.as_str()).map_err(E::custom) + } + + fn visit_bool(self, value: bool) -> Result + where + E: serde::de::Error, + { + Ok(Starttls::from(value)) + } + } + + deserializer.deserialize_any(StartlsVisitor) + } } #[cfg(test)] @@ -106,18 +249,42 @@ mod tests { #[test] fn environment_from_str_development() { - assert_eq!(Environment::try_from("development").unwrap(), Environment::Development); - assert_eq!(Environment::try_from("dev").unwrap(), Environment::Development); - assert_eq!(Environment::try_from("Development").unwrap(), Environment::Development); - assert_eq!(Environment::try_from("DEV").unwrap(), Environment::Development); + assert_eq!( + Environment::try_from("development").unwrap(), + Environment::Development + ); + assert_eq!( + Environment::try_from("dev").unwrap(), + Environment::Development + ); + assert_eq!( + Environment::try_from("Development").unwrap(), + Environment::Development + ); + assert_eq!( + Environment::try_from("DEV").unwrap(), + Environment::Development + ); } #[test] fn environment_from_str_production() { - assert_eq!(Environment::try_from("production").unwrap(), Environment::Production); - assert_eq!(Environment::try_from("prod").unwrap(), Environment::Production); - assert_eq!(Environment::try_from("Production").unwrap(), Environment::Production); - assert_eq!(Environment::try_from("PROD").unwrap(), Environment::Production); + assert_eq!( + Environment::try_from("production").unwrap(), + Environment::Production + ); + assert_eq!( + Environment::try_from("prod").unwrap(), + Environment::Production + ); + assert_eq!( + Environment::try_from("Production").unwrap(), + Environment::Production + ); + assert_eq!( + Environment::try_from("PROD").unwrap(), + Environment::Production + ); } #[test] @@ -154,4 +321,61 @@ mod tests { let env = Environment::default(); assert_eq!(env, Environment::Development); } + + #[test] + fn startls_deserialize_from_string_never() { + let json = r#""never""#; + let result: Starttls = serde_json::from_str(json).unwrap(); + assert_eq!(result, Starttls::Never); + + let json = r#""no""#; + let result: Starttls = serde_json::from_str(json).unwrap(); + assert_eq!(result, Starttls::Never); + + let json = r#""off""#; + let result: Starttls = serde_json::from_str(json).unwrap(); + assert_eq!(result, Starttls::Never); + } + + #[test] + fn startls_deserialize_from_string_always() { + let json = r#""always""#; + let result: Starttls = serde_json::from_str(json).unwrap(); + assert_eq!(result, Starttls::Always); + + let json = r#""yes""#; + let result: Starttls = serde_json::from_str(json).unwrap(); + assert_eq!(result, Starttls::Always); + } + + #[test] + fn startls_deserialize_from_string_opportunistic() { + let json = r#""opportunistic""#; + let result: Starttls = serde_json::from_str(json).unwrap(); + assert_eq!(result, Starttls::Opportunistic); + } + + #[test] + fn startls_deserialize_from_bool() { + let json = "true"; + let result: Starttls = serde_json::from_str(json).unwrap(); + assert_eq!(result, Starttls::Always); + + let json = "false"; + let result: Starttls = serde_json::from_str(json).unwrap(); + assert_eq!(result, Starttls::Never); + } + + #[test] + fn startls_deserialize_from_string_invalid() { + let json = r#""invalid""#; + let result: Result = serde_json::from_str(json); + assert!(result.is_err()); + } + + #[test] + fn startls_default_is_never() { + let startls = Starttls::default(); + assert_eq!(startls, Starttls::Never); + } } diff --git a/backend/src/startup.rs b/backend/src/startup.rs index cecb7ea..6bfdb7e 100644 --- a/backend/src/startup.rs +++ b/backend/src/startup.rs @@ -1,12 +1,22 @@ +//! Application startup and server configuration. +//! +//! This module handles: +//! - Building the application with routes and middleware +//! - Setting up the OpenAPI service and Swagger UI +//! - Configuring CORS +//! - Starting the HTTP server + use poem::middleware::{AddDataEndpoint, Cors, CorsEndpoint}; use poem::{EndpointExt, Route}; use poem_openapi::OpenApiService; -use crate::{settings::Settings, route::{Api, HealthApi, MetaApi}}; +use crate::{route::Api, settings::Settings}; type Server = poem::Server, std::convert::Infallible>; +/// The configured application with CORS and settings data. pub type App = AddDataEndpoint, Settings>; +/// Application builder that holds the server configuration before running. pub struct Application { server: Server, app: poem::Route, @@ -15,12 +25,19 @@ pub struct Application { settings: Settings, } +/// A fully configured application ready to run. pub struct RunnableApplication { server: Server, app: App, } impl RunnableApplication { + /// Runs the application server. + /// + /// # Errors + /// + /// Returns a `std::io::Error` if the server fails to start or encounters + /// an I/O error during runtime (e.g., port already in use, network issues). pub async fn run(self) -> Result<(), std::io::Error> { self.server.run(self.app).await } @@ -43,12 +60,16 @@ impl From for RunnableApplication { impl Application { fn setup_app(settings: &Settings) -> poem::Route { let api_service = OpenApiService::new( - (Api, HealthApi, MetaApi), + Api::from(settings).apis(), settings.application.clone().name, settings.application.clone().version, - ); + ) + .url_prefix("/api"); let ui = api_service.swagger_ui(); - poem::Route::new().nest("/", api_service).nest("/docs", ui) + poem::Route::new() + .nest("/api", api_service.clone()) + .nest("/specs", api_service.spec_endpoint_yaml()) + .nest("/", ui) } fn setup_server( @@ -65,6 +86,9 @@ impl Application { poem::Server::new(tcp_listener) } + /// Builds a new application with the given settings and optional TCP listener. + /// + /// If no listener is provided, one will be created based on the settings. #[must_use] pub fn build( settings: Settings, @@ -83,16 +107,19 @@ impl Application { } } + /// Converts the application into a runnable application. #[must_use] pub fn make_app(self) -> RunnableApplication { self.into() } + /// Returns the host address the application is configured to bind to. #[must_use] pub fn host(&self) -> String { self.host.clone() } + /// Returns the port the application is configured to bind to. #[must_use] pub const fn port(&self) -> u16 { self.port @@ -150,8 +177,8 @@ mod tests { #[test] fn application_with_custom_listener() { let settings = create_test_settings(); - let tcp_listener = std::net::TcpListener::bind("127.0.0.1:0") - .expect("Failed to bind random port"); + let tcp_listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port"); let port = tcp_listener.local_addr().unwrap().port(); let listener = poem::listener::TcpListener::bind(format!("127.0.0.1:{port}")); diff --git a/backend/src/telemetry.rs b/backend/src/telemetry.rs index 3ef132b..70c3c21 100644 --- a/backend/src/telemetry.rs +++ b/backend/src/telemetry.rs @@ -1,5 +1,14 @@ +//! Logging and tracing configuration. +//! +//! This module provides utilities for setting up structured logging using the tracing crate. +//! Supports both pretty-printed logs for development and JSON logs for production. + use tracing_subscriber::layer::SubscriberExt; +/// Creates a tracing subscriber configured for the given debug mode. +/// +/// In debug mode, logs are pretty-printed to stdout. +/// In production mode, logs are output as JSON. #[must_use] pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync { let env_filter = if debug { "debug" } else { "info" }.to_string(); @@ -17,6 +26,13 @@ pub fn get_subscriber(debug: bool) -> impl tracing::Subscriber + Send + Sync { subscriber.with(json_log) } +/// Initializes the global tracing subscriber. +/// +/// # Panics +/// +/// Panics if: +/// - A global subscriber has already been set +/// - The subscriber cannot be set as the global default pub fn init_subscriber(subscriber: impl tracing::Subscriber + Send + Sync) { tracing::subscriber::set_global_default(subscriber).expect("Failed to set subscriber"); }