diff --git a/.github/workflows/README.md b/.github/workflows/README.md index b15d698..a23a873 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -7,7 +7,7 @@ The `publish-docker.yml` workflow automatically builds and publishes Docker imag ### Triggers and Tagging Strategy | Event | Condition | Published Tags | Example | -|--------------+-----------------------------+------------------------+-------------------| +|--------------|-----------------------------|------------------------|-------------------| | Tag push | Tag pushed to `main` branch | `latest` + version tag | `latest`, `1.0.0` | | Branch push | Push to `develop` branch | `develop` | `develop` | | Pull request | PR opened or updated | `pr` | `pr12` | @@ -18,7 +18,7 @@ The `publish-docker.yml` workflow automatically builds and publishes Docker imag Configure these secrets in your repository settings (`Settings` → `Secrets and variables` → `Actions`): | Secret Name | Description | Example Value | -|---------------------+---------------------------------------------+-----------------------------------------| +|---------------------|---------------------------------------------|-----------------------------------------| | `DOCKER_USERNAME` | Username for Docker registry authentication | `phundrak` | | `DOCKER_PASSWORD` | Password or token for Docker registry | Personal Access Token (PAT) or password | | `CACHIX_AUTH_TOKEN` | (Optional) Token for Cachix caching | Your Cachix auth token | diff --git a/.github/workflows/publish-docker.yml b/.github/workflows/publish-docker.yml index 2ad3e3c..d68dbb6 100644 --- a/.github/workflows/publish-docker.yml +++ b/.github/workflows/publish-docker.yml @@ -12,7 +12,6 @@ on: env: CACHIX_NAME: devenv - CACHIX_SKIP_PUSH: true DOCKER_REGISTRY: labs.phundrak.com # Override in repository settings if needed IMAGE_NAME: phundrak/phundrak-dot-com-backend @@ -38,7 +37,7 @@ jobs: with: name: '${{ env.CACHIX_NAME }}' authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}' - skipPush: ${{ env.CACHIX_SKIP_PUSH }} + skipPush: ${{ github.event_name == 'pull_request' }} - name: Build Docker image with Nix run: | diff --git a/.tarpaulin.local.toml b/.tarpaulin.local.toml index 1170b5c..97443d2 100644 --- a/.tarpaulin.local.toml +++ b/.tarpaulin.local.toml @@ -4,4 +4,4 @@ skip-clean = true target-dir = "coverage" output-dir = "coverage" fail-under = 60 -exclude-files = ["target/*"] +exclude-files = ["target/*", "private/*"] diff --git a/README.md b/README.md index 87c34e4..666a695 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,8 @@ +--- +include_toc: true +gitea: none +--- + # phundrak.com Backend The backend for [phundrak.com](https://phundrak.com), built with Rust and the [Poem](https://github.com/poem-web/poem) web framework. @@ -178,6 +183,7 @@ just coverage - 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 +- Email sending is tested using lettre's `StubTransport` for mocking SMTP operations ## Code Quality @@ -256,12 +262,15 @@ backend/ │ ├── startup.rs # Application builder, server setup │ ├── settings.rs # Configuration management │ ├── telemetry.rs # Logging and tracing setup +│ ├── errors.rs # Error type re-exports │ ├── middleware/ # Custom middleware │ │ ├── mod.rs # Middleware module │ │ └── rate_limit.rs # Rate limiting middleware │ └── route/ # API route handlers │ ├── mod.rs # Route organization -│ ├── contact.rs # Contact form endpoint +│ ├── contact/ # Contact form module +│ │ ├── mod.rs # Contact form endpoint +│ │ └── errors.rs # Contact form error types │ ├── health.rs # Health check endpoint │ └── meta.rs # Metadata endpoint ├── settings/ # Configuration files @@ -417,7 +426,7 @@ The workflow requires these GitHub secrets: - `DOCKER_PASSWORD` - Registry password or token - `CACHIX_AUTH_TOKEN` - (Optional) For Nix build caching -See [.github/workflows/README.md](../.github/workflows/README.md) for detailed setup instructions. +See [.github/workflows/README.md](./.github/workflows/README.md) for detailed setup instructions. ## License diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..6896e62 --- /dev/null +++ b/src/errors.rs @@ -0,0 +1 @@ +pub use crate::route::ContactError; diff --git a/src/lib.rs b/src/lib.rs index 16662b7..a070d89 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,8 @@ #![warn(missing_docs)] #![allow(clippy::unused_async)] +/// Custom errors +pub mod errors; /// Custom middleware implementations pub mod middleware; /// API route handlers and endpoints diff --git a/src/middleware/rate_limit.rs b/src/middleware/rate_limit.rs index 42aee34..f907b28 100644 --- a/src/middleware/rate_limit.rs +++ b/src/middleware/rate_limit.rs @@ -4,21 +4,14 @@ //! Algorithm (GCRA) via the governor crate. It stores rate limiters in memory //! without requiring external dependencies like Redis. -use std::{ - net::IpAddr, - num::NonZeroU32, - sync::Arc, - time::Duration, -}; +use std::{net::IpAddr, num::NonZeroU32, sync::Arc, time::Duration}; use governor::{ + Quota, RateLimiter, clock::DefaultClock, state::{InMemoryState, NotKeyed}, - Quota, RateLimiter, -}; -use poem::{ - Endpoint, Error, IntoResponse, Middleware, Request, Response, Result, }; +use poem::{Endpoint, Error, IntoResponse, Middleware, Request, Response, Result}; /// Rate limiting configuration. #[derive(Debug, Clone)] @@ -113,7 +106,9 @@ impl Endpoint for RateLimitEndpoint { "Rate limit exceeded" ); - return Err(Error::from_status(poem::http::StatusCode::TOO_MANY_REQUESTS)); + return Err(Error::from_status( + poem::http::StatusCode::TOO_MANY_REQUESTS, + )); } // Process the request @@ -125,7 +120,9 @@ impl Endpoint for RateLimitEndpoint { impl RateLimitEndpoint { /// Extracts the client IP address from the request. fn get_client_ip(req: &Request) -> Option { - req.remote_addr().as_socket_addr().map(std::net::SocketAddr::ip) + req.remote_addr() + .as_socket_addr() + .map(std::net::SocketAddr::ip) } } @@ -163,7 +160,7 @@ mod tests { #[tokio::test] async fn rate_limit_middleware_allows_within_limit() { - use poem::{handler, test::TestClient, EndpointExt, Route}; + use poem::{EndpointExt, Route, handler, test::TestClient}; #[handler] async fn index() -> String { @@ -185,7 +182,7 @@ mod tests { #[tokio::test] async fn rate_limit_middleware_blocks_over_limit() { - use poem::{handler, test::TestClient, EndpointExt, Route}; + use poem::{EndpointExt, Route, handler, test::TestClient}; #[handler] async fn index() -> String { diff --git a/src/route/contact.rs b/src/route/contact.rs deleted file mode 100644 index 9b082ec..0000000 --- a/src/route/contact.rs +++ /dev/null @@ -1,514 +0,0 @@ -//! 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)] - #[allow(dead_code)] - TooManyRequests, - /// 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/src/route/contact/errors.rs b/src/route/contact/errors.rs new file mode 100644 index 0000000..2eeee09 --- /dev/null +++ b/src/route/contact/errors.rs @@ -0,0 +1,418 @@ +use std::error::Error; + +use lettre::address::AddressError; +use poem_openapi::payload::Json; +use validator::ValidationErrors; + +use super::ContactResponse; + +/// Errors that can occur during contact form processing and email sending. +#[derive(Debug)] +pub enum ContactError { + /// The email address provided in the contact form request could not be parsed. + /// + /// This typically indicates the user submitted an invalid email address format. + CouldNotParseRequestEmailAddress(String), + /// The email address configured in application settings could not be parsed. + /// + /// This indicates a configuration error with the sender or recipient email addresses. + CouldNotParseSettingsEmail(String), + /// Failed to construct the email message. + /// + /// This can occur due to invalid message content or headers. + FailedToBuildMessage(String), + /// Failed to send the email through the SMTP server. + /// + /// This can occur due to network issues, authentication failures, or SMTP server errors. + CouldNotSendEmail(String), + /// A general validation error occurred that doesn't fit specific field validation. + /// + /// This is used for validation errors that don't map to a specific form field. + ValidationError(String), + /// The name field in the contact form failed validation. + /// + /// This typically occurs when the name is empty, too short, or contains invalid characters. + ValidationNameError(String), + /// The email field in the contact form failed validation. + /// + /// This typically occurs when the email address format is invalid. + ValidationEmailError(String), + /// The message field in the contact form failed validation. + /// + /// This typically occurs when the message is empty, too short. + ValidationMessageError(String), + /// An unspecified internal error occurred. + OtherError(String), +} + +impl Error for ContactError {} + +/// Converts a lettre SMTP transport error into a `ContactError`. +/// +/// SMTP errors are logged at ERROR level with full details, then +/// mapped to `OtherError` as they represent server-side or network +/// issues beyond the client's control. +impl From for ContactError { + fn from(value: lettre::transport::smtp::Error) -> Self { + tracing::event!(target: "contact", tracing::Level::ERROR, "SMTP Error details: {}", format!("{value:?}")); + Self::OtherError(value.to_string()) + } +} + +impl std::fmt::Display for ContactError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let message = match self { + Self::CouldNotParseRequestEmailAddress(e) => { + format!("Failed to parse requester's email address: {e:?}") + } + Self::CouldNotParseSettingsEmail(e) => { + format!("Failed to parse email address in settings: {e:?}") + } + Self::FailedToBuildMessage(e) => { + format!("Failed to build the message to be sent: {e:?}") + } + Self::CouldNotSendEmail(e) => format!("Failed to send the email: {e:?}"), + Self::ValidationError(e) => format!("Failed to validate request: {e:?}"), + Self::ValidationNameError(e) => format!("Failed to validate name: {e:?}"), + Self::ValidationEmailError(e) => format!("Failed to validate email: {e:?}"), + Self::ValidationMessageError(e) => format!("Failed to validate message: {e:?}"), + Self::OtherError(e) => format!("Other internal error: {e:?}"), + }; + write!(f, "{message}") + } +} + +/// Converts validation errors into a `ContactError`. +/// +/// This implementation inspects the validation errors to determine which specific field +/// failed validation (name, email, or message) and returns the appropriate variant. +/// If no specific field can be identified, returns a generic `ValidationError`. +impl From for ContactError { + fn from(value: ValidationErrors) -> Self { + if validator::ValidationErrors::has_error(&Err(value.clone()), "name") { + return Self::ValidationNameError("backend.contact.errors.validation.name".to_owned()); + } + if validator::ValidationErrors::has_error(&Err(value.clone()), "email") { + return Self::ValidationEmailError("backend.contact.errors.validation.email".to_owned()); + } + if validator::ValidationErrors::has_error(&Err(value), "message") { + return Self::ValidationMessageError("backend.contact.errors.validation.message".to_owned()); + } + Self::ValidationError("backend.contact.errors.validation.other".to_owned()) + } +} + +/// Converts a `ContactError` into a `ContactResponse`. +/// +/// This maps error variants to user-facing error message keys for internationalization. +/// Validation errors map to specific field error keys, while internal errors +/// (settings, email building, SMTP issues) all map to a generic internal error key. +impl From for ContactResponse { + fn from(value: ContactError) -> Self { + Self { + success: false, + message: match value { + ContactError::CouldNotParseRequestEmailAddress(_) + | ContactError::ValidationEmailError(_) => "backend.contact.errors.validation.email", + ContactError::ValidationNameError(_) => "backend.contact.errors.validation.name", + ContactError::ValidationMessageError(_) => "backend.contact.errors.validation.message", + ContactError::CouldNotParseSettingsEmail(_) + | ContactError::FailedToBuildMessage(_) + | ContactError::CouldNotSendEmail(_) + | ContactError::OtherError(_) => "backend.contact.errors.internal", + ContactError::ValidationError(_) => "backend.contact.errors.validation.other", + } + .to_string(), + } + } +} + +/// Converts validation errors directly into a `ContactResponse`. +/// +/// This is a convenience implementation that first converts `ValidationErrors` to +/// `ContactError`, then converts that to `ContactResponse`. This allows validation +/// errors to be returned directly from handlers as responses. +impl From for ContactResponse { + fn from(value: ValidationErrors) -> Self { + let error: ContactError = value.into(); + error.into() + } +} + +/// Converts a lettre `AddressError` into a `ContactError`. +/// +/// Address parsing errors from lettre are mapped to `CouldNotParseSettingsEmail` +/// as they typically occur when parsing email addresses from application settings. +impl From for ContactError { + fn from(value: AddressError) -> Self { + Self::CouldNotParseSettingsEmail(value.to_string()) + } +} + +/// Converts a lettre `Error` into a `ContactError`. +/// +/// Lettre errors during message construction are mapped to `FailedToBuildMessage`. +/// These errors typically occur when building email messages with invalid headers or content. +impl From for ContactError { + fn from(value: lettre::error::Error) -> Self { + Self::FailedToBuildMessage(value.to_string()) + } +} + +impl From for Json { + fn from(value: ContactError) -> Self { + let response: ContactResponse = value.into(); + response.into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lettre::address::AddressError; + + #[test] + fn contact_error_display_could_not_parse_request_email() { + let error = ContactError::CouldNotParseRequestEmailAddress("invalid".to_string()); + let display = format!("{error}"); + assert!(display.contains("Failed to parse requester's email address")); + assert!(display.contains("invalid")); + } + + #[test] + fn contact_error_display_could_not_parse_settings_email() { + let error = ContactError::CouldNotParseSettingsEmail("invalid".to_string()); + let display = format!("{error}"); + assert!(display.contains("Failed to parse email address in settings")); + assert!(display.contains("invalid")); + } + + #[test] + fn contact_error_display_failed_to_build_message() { + let error = ContactError::FailedToBuildMessage("build error".to_string()); + let display = format!("{error}"); + assert!(display.contains("Failed to build the message to be sent")); + assert!(display.contains("build error")); + } + + #[test] + fn contact_error_display_could_not_send_email() { + let error = ContactError::CouldNotSendEmail("send error".to_string()); + let display = format!("{error}"); + assert!(display.contains("Failed to send the email")); + assert!(display.contains("send error")); + } + + #[test] + fn contact_error_display_validation_error() { + let error = ContactError::ValidationError("validation error".to_string()); + let display = format!("{error}"); + assert!(display.contains("Failed to validate request")); + assert!(display.contains("validation error")); + } + + #[test] + fn contact_error_display_validation_name_error() { + let error = ContactError::ValidationNameError("name error".to_string()); + let display = format!("{error}"); + assert!(display.contains("Failed to validate name")); + assert!(display.contains("name error")); + } + + #[test] + fn contact_error_display_validation_email_error() { + let error = ContactError::ValidationEmailError("email error".to_string()); + let display = format!("{error}"); + assert!(display.contains("Failed to validate email")); + assert!(display.contains("email error")); + } + + #[test] + fn contact_error_display_validation_message_error() { + let error = ContactError::ValidationMessageError("message error".to_string()); + let display = format!("{error}"); + assert!(display.contains("Failed to validate message")); + assert!(display.contains("message error")); + } + + #[test] + fn contact_error_display_other_error() { + let error = ContactError::OtherError("other error".to_string()); + let display = format!("{error}"); + assert!(display.contains("Other internal error")); + assert!(display.contains("other error")); + } + + #[test] + fn from_address_error_creates_could_not_parse_settings_email() { + let address_error: Result = "invalid email".parse(); + let error: ContactError = address_error.unwrap_err().into(); + match error { + ContactError::CouldNotParseSettingsEmail(_) => (), + _ => panic!("Expected CouldNotParseSettingsEmail variant"), + } + } + + #[test] + fn from_lettre_error_creates_failed_to_build_message() { + // Create an invalid message to trigger a lettre error + let result = lettre::Message::builder().body(String::new()); + assert!(result.is_err()); + let lettre_error = result.unwrap_err(); + let error: ContactError = lettre_error.into(); + match error { + ContactError::FailedToBuildMessage(_) => (), + _ => panic!("Expected FailedToBuildMessage variant"), + } + } + + #[test] + fn from_validation_errors_with_name_error() { + use validator::{Validate, ValidationError}; + + #[derive(Validate)] + struct TestStruct { + #[validate(length(min = 1))] + name: String, + } + + let test = TestStruct { + name: String::new(), + }; + let validation_errors = test.validate().unwrap_err(); + let error: ContactError = validation_errors.into(); + match error { + ContactError::ValidationNameError(msg) => { + assert_eq!(msg, "backend.contact.errors.validation.name"); + } + _ => panic!("Expected ValidationNameError variant"), + } + } + + #[test] + fn from_validation_errors_with_email_error() { + use validator::Validate; + + #[derive(Validate)] + struct TestStruct { + #[validate(email)] + email: String, + } + + let test = TestStruct { + email: "invalid".to_string(), + }; + let validation_errors = test.validate().unwrap_err(); + let error: ContactError = validation_errors.into(); + match error { + ContactError::ValidationEmailError(msg) => { + assert_eq!(msg, "backend.contact.errors.validation.email"); + } + _ => panic!("Expected ValidationEmailError variant"), + } + } + + #[test] + fn from_validation_errors_with_message_error() { + use validator::Validate; + + #[derive(Validate)] + struct TestStruct { + #[validate(length(min = 10))] + message: String, + } + + let test = TestStruct { + message: "short".to_string(), + }; + let validation_errors = test.validate().unwrap_err(); + let error: ContactError = validation_errors.into(); + match error { + ContactError::ValidationMessageError(msg) => { + assert_eq!(msg, "backend.contact.errors.validation.message"); + } + _ => panic!("Expected ValidationMessageError variant"), + } + } + + #[test] + fn contact_error_to_response_email_validation() { + let error = ContactError::ValidationEmailError("test".to_string()); + let response: ContactResponse = error.into(); + assert!(!response.success); + assert_eq!(response.message, "backend.contact.errors.validation.email"); + } + + #[test] + fn contact_error_to_response_name_validation() { + let error = ContactError::ValidationNameError("test".to_string()); + let response: ContactResponse = error.into(); + assert!(!response.success); + assert_eq!(response.message, "backend.contact.errors.validation.name"); + } + + #[test] + fn contact_error_to_response_message_validation() { + let error = ContactError::ValidationMessageError("test".to_string()); + let response: ContactResponse = error.into(); + assert!(!response.success); + assert_eq!( + response.message, + "backend.contact.errors.validation.message" + ); + } + + #[test] + fn contact_error_to_response_internal_errors() { + let test_cases = vec![ + ContactError::CouldNotParseSettingsEmail("test".to_string()), + ContactError::FailedToBuildMessage("test".to_string()), + ContactError::CouldNotSendEmail("test".to_string()), + ContactError::OtherError("test".to_string()), + ]; + + for error in test_cases { + let response: ContactResponse = error.into(); + assert!(!response.success); + assert_eq!(response.message, "backend.contact.errors.internal"); + } + } + + #[test] + fn contact_error_to_response_other_validation() { + let error = ContactError::ValidationError("test".to_string()); + let response: ContactResponse = error.into(); + assert!(!response.success); + assert_eq!(response.message, "backend.contact.errors.validation.other"); + } + + #[test] + fn contact_error_to_json_response() { + let error = ContactError::ValidationEmailError("test".to_string()); + let json_response: Json = error.into(); + assert!(!json_response.0.success); + assert_eq!( + json_response.0.message, + "backend.contact.errors.validation.email" + ); + } + + #[test] + fn validation_errors_to_response() { + use validator::Validate; + + #[derive(Validate)] + struct TestStruct { + #[validate(email)] + email: String, + } + + let test = TestStruct { + email: "invalid".to_string(), + }; + let validation_errors = test.validate().unwrap_err(); + let response: ContactResponse = validation_errors.into(); + assert!(!response.success); + assert_eq!(response.message, "backend.contact.errors.validation.email"); + } +} diff --git a/src/route/contact/mod.rs b/src/route/contact/mod.rs new file mode 100644 index 0000000..959b9d7 --- /dev/null +++ b/src/route/contact/mod.rs @@ -0,0 +1,1002 @@ +//! 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}; + +pub mod errors; +use errors::ContactError; + +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", code = "name"))] + name: String, + #[validate(email(code = "email"))] + email: String, + #[validate(length(min = 10, max = 5000, code = "message"))] + message: String, + /// Honeypot field - should always be empty + #[oai(rename = "website")] + honeypot: Option, +} + +impl TryFrom<&ContactRequest> for lettre::message::Mailbox { + type Error = ContactError; + + fn try_from(value: &ContactRequest) -> Result { + value.email.parse().map_or_else( + |_| { + Err(ContactError::CouldNotParseRequestEmailAddress( + value.email.clone(), + )) + }, + |email| { + Ok(Self { + name: Some(value.name.clone()), + email, + }) + }, + ) + } +} + +#[derive(Debug, Object, serde::Deserialize)] +struct ContactResponse { + success: bool, + message: String, +} + +impl From for Json { + fn from(value: ContactResponse) -> Self { + Self(value) + } +} + +impl ContactResponse { + pub fn success() -> Self { + Self { + success: true, + message: "backend.contact.success".to_owned(), + } + } + + pub fn honeypot_response() -> Self { + Self { + success: true, + message: "backend.contact.honeypot".to_owned(), + } + } +} + +#[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)] + #[allow(dead_code)] + TooManyRequests, + /// 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::honeypot_response().into()); + } + if let Err(e) = body.validate() { + return ContactApiResponse::BadRequest( + >::into(e) + .into(), + ); + } + match self.send_emails(&body).await { + Ok(()) => { + tracing::event!( + target: "backend::contact", + tracing::Level::INFO, "Message from \"{} <{}>\" sent successfully", + body.name, + body.email + ); + ContactApiResponse::Ok(ContactResponse::success().into()) + } + Err(e) => { + tracing::event!(target: "backend::contact", tracing::Level::ERROR, "Failed to send email: {}", e); + ContactApiResponse::InternalServerError(e.into()) + } + } + } + + fn make_email_sender(&self, request: &ContactRequest) -> Result { + let email_body = format!( + "You submitted the following email:\n\nMessage:\n{}\n\nI’ll try to reply to it as soon as possible. Take care!\n\nBest\n\n***\nThis is an automated email. Please do not reply to it.\n***", + request.message + ); + tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Sending email content to sender: {}", email_body); + let email = Message::builder() + .from(self.settings.try_sender_into_mailbox()?) + .to(request.try_into()?) + .subject("You sent a contact request!".to_string()) + .header(ContentType::TEXT_PLAIN) + .body(email_body)?; + tracing::event!(target: "backend::contact", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}")); + Ok(email) + } + + fn make_email_recipient(&self, request: &ContactRequest) -> Result { + let email_body = format!( + "New contact form submission:\n\nName: {}\nEmail: {}\n\nMessage:\n{}", + request.name, request.email, request.message + ); + tracing::event!(target: "email", tracing::Level::DEBUG, "Sending email content to recipient: {}", email_body); + let email = Message::builder() + .from(self.settings.try_sender_into_mailbox()?) + .reply_to(request.try_into()?) + .to(self.settings.try_recpient_into_mailbox()?) + .subject(format!("Contact Form: {}", request.name)) + .header(ContentType::TEXT_PLAIN) + .body(email_body)?; + tracing::event!(target: "contact", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}")); + Ok(email) + } + + async fn send_emails(&self, request: &ContactRequest) -> Result<(), ContactError> { + let mailer = SmtpTransport::try_from(&self.settings)?; + let email_to_sender = self.make_email_sender(request)?; + let email_to_recipient = self.make_email_recipient(request)?; + mailer + .send(&email_to_sender) + .and_then(|_| mailer.send(&email_to_recipient))?; + Ok(()) + } + + /// Internal method for testing - sends emails using a provided transport + #[cfg(test)] + fn send_emails_with_transport( + &self, + request: &ContactRequest, + transport: &T, + ) -> Result<(), ContactError> + where + T::Error: std::fmt::Debug + std::fmt::Display, + { + let email_to_sender = self.make_email_sender(request)?; + let email_to_recipient = self.make_email_recipient(request)?; + + transport + .send(&email_to_sender) + .map_err(|e| ContactError::CouldNotSendEmail(format!("{e:?}")))?; + transport + .send(&email_to_recipient) + .map_err(|e| ContactError::CouldNotSendEmail(format!("{e:?}")))?; + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use lettre::transport::stub::StubTransport; + + // 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.eq("backend.contact.honeypot")); + } + + #[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.eq("backend.contact.errors.validation.name")); + } + + #[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.eq("backend.contact.errors.validation.email")); + } + + #[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.eq("backend.contact.errors.validation.message")); + } + + #[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.eq("backend.contact.errors.validation.name")); + } + + #[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.eq("backend.contact.errors.validation.message")); + } + + // Tests for ContactRequest TryFrom to Mailbox + #[test] + fn contact_request_to_mailbox_success() { + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "This is a test message.".to_string(), + honeypot: None, + }; + + let result: Result = (&request).try_into(); + assert!(result.is_ok()); + let mailbox = result.unwrap(); + assert_eq!(mailbox.name, Some("John Doe".to_string())); + assert_eq!(mailbox.email.to_string(), "john@example.com"); + } + + #[test] + fn contact_request_to_mailbox_invalid_email() { + let request = ContactRequest { + name: "John Doe".to_string(), + email: "not-an-email".to_string(), + message: "This is a test message.".to_string(), + honeypot: None, + }; + + let result: Result = (&request).try_into(); + assert!(result.is_err()); + match result.unwrap_err() { + ContactError::CouldNotParseRequestEmailAddress(email) => { + assert_eq!(email, "not-an-email"); + } + _ => panic!("Expected CouldNotParseRequestEmailAddress error"), + } + } + + // Tests for ContactResponse factory methods + #[test] + fn contact_response_success_creates_correct_response() { + let response = ContactResponse::success(); + assert!(response.success); + assert_eq!(response.message, "backend.contact.success"); + } + + #[test] + fn contact_response_honeypot_creates_correct_response() { + let response = ContactResponse::honeypot_response(); + assert!(response.success); + assert_eq!(response.message, "backend.contact.honeypot"); + } + + // Tests for ContactResponse to Json conversion + #[test] + fn contact_response_to_json() { + let response = ContactResponse::success(); + let json: Json = response.into(); + assert!(json.0.success); + assert_eq!(json.0.message, "backend.contact.success"); + } + + // Tests for email building methods + #[test] + fn make_email_sender_builds_correct_message() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Test message content".to_string(), + honeypot: None, + }; + + let result = api.make_email_sender(&request); + assert!(result.is_ok()); + + let message = result.unwrap(); + let message_str = format!("{message:?}"); + + // Check that the message contains key elements + assert!(message_str.contains("john@example.com")); + assert!(message_str.contains("John Doe")); + assert!(message_str.contains("noreply@example.com")); + } + + #[test] + fn make_email_sender_fails_with_invalid_from_address() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "invalid-email".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Test message".to_string(), + honeypot: None, + }; + + let result = api.make_email_sender(&request); + assert!(result.is_err()); + } + + #[test] + fn make_email_sender_fails_with_invalid_request_email() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "invalid-email".to_string(), + message: "Test message".to_string(), + honeypot: None, + }; + + let result = api.make_email_sender(&request); + assert!(result.is_err()); + match result.unwrap_err() { + ContactError::CouldNotParseRequestEmailAddress(_) => (), + _ => panic!("Expected CouldNotParseRequestEmailAddress error"), + } + } + + #[test] + fn make_email_recipient_builds_correct_message() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Test message content".to_string(), + honeypot: None, + }; + + let result = api.make_email_recipient(&request); + assert!(result.is_ok()); + + let message = result.unwrap(); + let message_str = format!("{message:?}"); + + // Check that the message contains key elements + assert!(message_str.contains("admin@example.com")); + assert!(message_str.contains("john@example.com")); // Reply-to + assert!(message_str.contains("noreply@example.com")); + assert!(message_str.contains("Contact Form: John Doe")); + } + + #[test] + fn make_email_recipient_fails_with_invalid_recipient() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "invalid-email".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Test message".to_string(), + honeypot: None, + }; + + let result = api.make_email_recipient(&request); + assert!(result.is_err()); + } + + #[test] + fn make_email_recipient_fails_with_invalid_from_address() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "invalid-email".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Test message".to_string(), + honeypot: None, + }; + + let result = api.make_email_recipient(&request); + assert!(result.is_err()); + } + + #[test] + fn make_email_recipient_includes_message_content() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "Jane Smith".to_string(), + email: "jane@example.com".to_string(), + message: "This is a unique test message with specific content".to_string(), + honeypot: None, + }; + + let result = api.make_email_recipient(&request); + assert!(result.is_ok()); + } + + #[test] + fn contact_api_from_email_settings() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Always, + tls: false, + }; + + let api = ContactApi::from(settings.clone()); + assert_eq!(api.settings.host, settings.host); + assert_eq!(api.settings.port, settings.port); + assert_eq!(api.settings.from, settings.from); + } + + // Tests for send_emails with mock transport + #[test] + fn send_emails_with_stub_transport_success() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Test message content".to_string(), + honeypot: None, + }; + + let transport = StubTransport::new_ok(); + let result = api.send_emails_with_transport(&request, &transport); + + assert!(result.is_ok()); + } + + #[test] + fn send_emails_with_stub_transport_sends_two_emails() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "Jane Smith".to_string(), + email: "jane@example.com".to_string(), + message: "Another test message".to_string(), + honeypot: None, + }; + + let transport = StubTransport::new_ok(); + api.send_emails_with_transport(&request, &transport) + .unwrap(); + + // StubTransport doesn't provide a way to count messages, but we verified it succeeded + // If either email failed to build or send, the test would fail + } + + #[test] + fn send_emails_with_stub_transport_fails_with_invalid_from() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "invalid-email".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Test message".to_string(), + honeypot: None, + }; + + let transport = StubTransport::new_ok(); + let result = api.send_emails_with_transport(&request, &transport); + + assert!(result.is_err()); + match result.unwrap_err() { + ContactError::CouldNotParseSettingsEmail(_) => (), + e => panic!("Expected CouldNotParseSettingsEmail, got {:?}", e), + } + } + + #[test] + fn send_emails_with_stub_transport_fails_with_invalid_request_email() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "not-an-email".to_string(), + message: "Test message".to_string(), + honeypot: None, + }; + + let transport = StubTransport::new_ok(); + let result = api.send_emails_with_transport(&request, &transport); + + assert!(result.is_err()); + match result.unwrap_err() { + ContactError::CouldNotParseRequestEmailAddress(_) => (), + e => panic!("Expected CouldNotParseRequestEmailAddress, got {:?}", e), + } + } + + #[test] + fn send_emails_with_failing_transport() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "noreply@example.com".to_string(), + password: "password".to_string(), + recipient: "admin@example.com".to_string(), + starttls: Starttls::Never, + tls: false, + }; + + let api = ContactApi::from(settings); + let request = ContactRequest { + name: "John Doe".to_string(), + email: "john@example.com".to_string(), + message: "Test message".to_string(), + honeypot: None, + }; + + // Create a transport that always fails + let transport = StubTransport::new_error(); + let result = api.send_emails_with_transport(&request, &transport); + + assert!(result.is_err()); + match result.unwrap_err() { + ContactError::CouldNotSendEmail(_) => (), + e => panic!("Expected CouldNotSendEmail, got {:?}", e), + } + } +} diff --git a/src/route/mod.rs b/src/route/mod.rs index 8801189..6efe248 100644 --- a/src/route/mod.rs +++ b/src/route/mod.rs @@ -8,6 +8,7 @@ use poem_openapi::Tags; mod contact; +pub use contact::errors::ContactError; mod health; mod meta; diff --git a/src/settings.rs b/src/settings.rs index 94c1a1b..a5a98ac 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -143,6 +143,38 @@ pub struct EmailSettings { pub tls: bool, } +impl EmailSettings { + /// Parses the sender email address into a `Mailbox` for use with lettre. + /// + /// # Errors + /// + /// Returns a `ContactError` if the email address in the `from` field cannot be parsed + /// into a valid mailbox. This can occur if: + /// - The email address format is invalid + /// - The email address contains invalid characters + /// - The email address structure is malformed + pub fn try_sender_into_mailbox( + &self, + ) -> Result { + Ok(self.from.parse::()?) + } + + /// Parses the recipient email address into a `Mailbox` for use with lettre. + /// + /// # Errors + /// + /// Returns a `ContactError` if the email address in the `from` field cannot be parsed + /// into a valid mailbox. This can occur if: + /// - The email address format is invalid + /// - The email address contains invalid characters + /// - The email address structure is malformed + pub fn try_recpient_into_mailbox( + &self, + ) -> Result { + Ok(self.recipient.parse::()?) + } +} + impl std::fmt::Debug for EmailSettings { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("EmailSettings") @@ -466,9 +498,7 @@ mod tests { fn startls_try_from_str_invalid() { let result = Starttls::try_from("invalid"); assert!(result.is_err()); - assert!(result - .unwrap_err() - .contains("not a supported option")); + assert!(result.unwrap_err().contains("not a supported option")); } #[test] @@ -616,4 +646,76 @@ mod tests { assert!(debug_output.contains("smtp.example.com")); assert!(debug_output.contains("user@example.com")); } + + #[test] + fn email_settings_try_sender_into_mailbox_success() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "sender@example.com".to_string(), + password: "password".to_string(), + recipient: "recipient@example.com".to_string(), + starttls: Starttls::Always, + tls: false, + }; + + let result = settings.try_sender_into_mailbox(); + assert!(result.is_ok()); + let mailbox = result.unwrap(); + assert_eq!(mailbox.email.to_string(), "sender@example.com"); + } + + #[test] + fn email_settings_try_sender_into_mailbox_invalid() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "invalid-email".to_string(), + password: "password".to_string(), + recipient: "recipient@example.com".to_string(), + starttls: Starttls::Always, + tls: false, + }; + + let result = settings.try_sender_into_mailbox(); + assert!(result.is_err()); + } + + #[test] + fn email_settings_try_recipient_into_mailbox_success() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "sender@example.com".to_string(), + password: "password".to_string(), + recipient: "recipient@example.com".to_string(), + starttls: Starttls::Always, + tls: false, + }; + + let result = settings.try_recpient_into_mailbox(); + assert!(result.is_ok()); + let mailbox = result.unwrap(); + assert_eq!(mailbox.email.to_string(), "recipient@example.com"); + } + + #[test] + fn email_settings_try_recipient_into_mailbox_invalid() { + let settings = EmailSettings { + host: "smtp.example.com".to_string(), + port: 587, + user: "user@example.com".to_string(), + from: "sender@example.com".to_string(), + password: "password".to_string(), + recipient: "invalid-email".to_string(), + starttls: Starttls::Always, + tls: false, + }; + + let result = settings.try_recpient_into_mailbox(); + assert!(result.is_err()); + } }