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<T>` implementations of various errors for new error type
  `ContactError`.
- Errors now return a translation identifier for the frontend.
This commit is contained in:
2025-11-15 14:08:37 +01:00
parent 71c4cf1061
commit 797ab461ab
12 changed files with 1556 additions and 539 deletions
+418
View File
@@ -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<lettre::transport::smtp::Error> 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<ValidationErrors> 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<ContactError> 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<ValidationErrors> 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<AddressError> 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<lettre::error::Error> for ContactError {
fn from(value: lettre::error::Error) -> Self {
Self::FailedToBuildMessage(value.to_string())
}
}
impl From<ContactError> for Json<ContactResponse> {
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<lettre::Address, AddressError> = "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<ContactResponse> = 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");
}
}
File diff suppressed because it is too large Load Diff