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..2c63786 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,43 @@ # phundrak.com Backend + +- [phundrak.com Backend](#phundrakcom-backend) + - [Features](#features) + - [API Endpoints](#api-endpoints) + - [Configuration](#configuration) + - [Configuration Example](#configuration-example) + - [Rate Limiting](#rate-limiting) + - [Development](#development) + - [Prerequisites](#prerequisites) + - [Running the Server](#running-the-server) + - [Building](#building) + - [Testing](#testing) + - [Testing Notes](#testing-notes) + - [Code Quality](#code-quality) + - [Linting](#linting) + - [Continuous Checking with Bacon](#continuous-checking-with-bacon) + - [Code Style](#code-style) + - [Error Handling](#error-handling) + - [Logging](#logging) + - [Imports](#imports) + - [Testing Conventions](#testing-conventions) + - [Project Structure](#project-structure) + - [Architecture](#architecture) + - [Application Initialization Flow](#application-initialization-flow) + - [Email Handling](#email-handling) + - [Docker Deployment](#docker-deployment) + - [Using Pre-built Images](#using-pre-built-images) + - [Available Image Tags](#available-image-tags) + - [Building Images Locally](#building-images-locally) + - [Docker Compose Example](#docker-compose-example) + - [CI/CD Pipeline](#cicd-pipeline) + - [Automated Docker Publishing](#automated-docker-publishing) + - [Workflow Details](#workflow-details) + - [Registry Configuration](#registry-configuration) + - [Required Secrets](#required-secrets) + - [License](#license) + + The backend for [phundrak.com](https://phundrak.com), built with Rust and the [Poem](https://github.com/poem-web/poem) web framework. ## Features @@ -417,7 +455,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/errors.rs b/src/route/contact/errors.rs new file mode 100644 index 0000000..40d3bdb --- /dev/null +++ b/src/route/contact/errors.rs @@ -0,0 +1,167 @@ +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() + } +} diff --git a/src/route/contact.rs b/src/route/contact/mod.rs similarity index 78% rename from src/route/contact.rs rename to src/route/contact/mod.rs index 9b082ec..cc9b4fc 100644 --- a/src/route/contact.rs +++ b/src/route/contact/mod.rs @@ -15,6 +15,9 @@ 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; @@ -58,25 +61,37 @@ impl TryFrom<&EmailSettings> for SmtpTransport { #[derive(Debug, Object, Validate)] struct ContactRequest { - #[validate(length( - min = 1, - max = "100", - message = "Name must be between 1 and 100 characters" - ))] + #[validate(length(min = 1, max = "100", code = "name"))] name: String, - #[validate(email(message = "Invalid email address"))] + #[validate(email(code = "email"))] email: String, - #[validate(length( - min = 10, - max = 5000, - message = "Message must be between 10 and 5000 characters" - ))] + #[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, @@ -89,6 +104,22 @@ impl From for Json { } } +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 @@ -131,71 +162,77 @@ impl ContactApi { ) -> 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(), + 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( - ContactResponse { - success: false, - message: format!("Validation error: {e}"), - } - .into(), + >::into(e) + .into(), ); } - match self.send_email(&body).await { + match self.send_emails(&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(), - ) + 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( - ContactResponse { - success: false, - message: "Failed to send message. Please try again later.".to_owned(), - } - .into(), - ) + ContactApiResponse::InternalServerError(e.into()) } } } - async fn send_email(&self, request: &ContactRequest) -> Result<(), Box> { + fn make_email_sender(&self, request: &ContactRequest) -> Result { let email_body = format!( - r"New contact form submission: + "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) + } -Name: {} -Email: {}, - -Message: -{}", + 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: {}", email_body); + tracing::event!(target: "email", tracing::Level::DEBUG, "Sending email content to recipient: {}", email_body); let email = Message::builder() - .from(self.settings.from.parse()?) - .reply_to(format!("{} <{}>", request.name, request.email).parse()?) - .to(self.settings.recipient.parse()?) + .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: "email", tracing::Level::DEBUG, "Email to be sent: {}", format!("{email:?}")); + 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)?; - mailer.send(&email)?; + 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(()) } } @@ -409,7 +446,7 @@ mod tests { 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")); + assert!(json.message.eq("backend.contact.honeypot")); } #[tokio::test] @@ -429,7 +466,7 @@ mod tests { 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")); + assert!(json.message.eq("backend.contact.errors.validation.name")); } #[tokio::test] @@ -449,7 +486,7 @@ mod tests { 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")); + assert!(json.message.eq("backend.contact.errors.validation.email")); } #[tokio::test] @@ -469,7 +506,7 @@ mod tests { 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")); + assert!(json.message.eq("backend.contact.errors.validation.message")); } #[tokio::test] @@ -489,7 +526,7 @@ mod tests { 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")); + assert!(json.message.eq("backend.contact.errors.validation.name")); } #[tokio::test] @@ -509,6 +546,6 @@ mod tests { 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")); + assert!(json.message.eq("backend.contact.errors.validation.message")); } } 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..83cc627 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]