From 0b60e67e193e15ee0d455ec36990bc0dd2b49fbf Mon Sep 17 00:00:00 2001 From: Lucien Cartier-Tilet Date: Sat, 15 Nov 2025 14:08:37 +0100 Subject: [PATCH] feat: send confirmation email to sender When users submit a contact form, they now receive a confirmation email acknowlledging receipt of their message. The backend also continues to send a notification email to the configured recipient. If the backend fails to send the acknowledgement email to the sender, it will assume the email is not valid and will therefore not transmit the contact request to the configured recipient. Changes: - Refactor `send_email()` to `send_emails()` that sends two emails: - Confirmation email from the submitter - Notification email to the configured recipient - Add `From` implementations of various errors for new error type `ContactError`. - Errors now return a translation identifier for the frontend. --- .github/workflows/README.md | 4 +- .github/workflows/publish-docker.yml | 3 +- .tarpaulin.local.toml | 2 +- README.md | 13 +- src/errors.rs | 1 + src/lib.rs | 2 + src/middleware/rate_limit.rs | 25 +- src/route/contact.rs | 514 ------------- src/route/contact/errors.rs | 418 +++++++++++ src/route/contact/mod.rs | 1002 ++++++++++++++++++++++++++ src/route/mod.rs | 1 + src/settings.rs | 108 ++- 12 files changed, 1555 insertions(+), 538 deletions(-) create mode 100644 src/errors.rs delete mode 100644 src/route/contact.rs create mode 100644 src/route/contact/errors.rs create mode 100644 src/route/contact/mod.rs 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()); + } }