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:
3
.github/workflows/publish-docker.yml
vendored
3
.github/workflows/publish-docker.yml
vendored
@@ -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: |
|
||||
|
||||
@@ -4,4 +4,4 @@ skip-clean = true
|
||||
target-dir = "coverage"
|
||||
output-dir = "coverage"
|
||||
fail-under = 60
|
||||
exclude-files = ["target/*"]
|
||||
exclude-files = ["target/*", "private/*"]
|
||||
|
||||
40
README.md
40
README.md
@@ -1,5 +1,43 @@
|
||||
# phundrak.com Backend
|
||||
|
||||
<!--toc:start-->
|
||||
- [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)
|
||||
<!--toc:end-->
|
||||
|
||||
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
|
||||
|
||||
|
||||
1
src/errors.rs
Normal file
1
src/errors.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub use crate::route::ContactError;
|
||||
@@ -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
|
||||
|
||||
@@ -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<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
|
||||
"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<E: Endpoint> Endpoint for RateLimitEndpoint<E> {
|
||||
impl<E> RateLimitEndpoint<E> {
|
||||
/// Extracts the client IP address from the request.
|
||||
fn get_client_ip(req: &Request) -> Option<IpAddr> {
|
||||
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 {
|
||||
|
||||
167
src/route/contact/errors.rs
Normal file
167
src/route/contact/errors.rs
Normal file
@@ -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<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()
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl TryFrom<&ContactRequest> for lettre::message::Mailbox {
|
||||
type Error = ContactError;
|
||||
|
||||
fn try_from(value: &ContactRequest) -> Result<Self, Self::Error> {
|
||||
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<ContactResponse> for Json<ContactResponse> {
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
<validator::ValidationErrors as std::convert::Into<ContactResponse>>::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<dyn std::error::Error>> {
|
||||
fn make_email_sender(&self, request: &ContactRequest) -> Result<Message, ContactError> {
|
||||
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<Message, ContactError> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@
|
||||
use poem_openapi::Tags;
|
||||
|
||||
mod contact;
|
||||
pub use contact::errors::ContactError;
|
||||
mod health;
|
||||
mod meta;
|
||||
|
||||
|
||||
@@ -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<lettre::message::Mailbox, crate::errors::ContactError> {
|
||||
Ok(self.from.parse::<lettre::message::Mailbox>()?)
|
||||
}
|
||||
|
||||
/// 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<lettre::message::Mailbox, crate::errors::ContactError> {
|
||||
Ok(self.recipient.parse::<lettre::message::Mailbox>()?)
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user