feat: send confirmation email to sender
All checks were successful
Publish Docker Images / build-and-publish (push) Successful in 8m13s
All checks were successful
Publish Docker Images / build-and-publish (push) Successful in 8m13s
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:
4
.github/workflows/README.md
vendored
4
.github/workflows/README.md
vendored
@@ -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<number>` | `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 |
|
||||
|
||||
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/*"]
|
||||
|
||||
13
README.md
13
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
|
||||
|
||||
|
||||
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 {
|
||||
|
||||
@@ -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<Self, Self::Error> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Object, serde::Deserialize)]
|
||||
struct ContactResponse {
|
||||
success: bool,
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl From<ContactResponse> for Json<ContactResponse> {
|
||||
fn from(value: ContactResponse) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(ApiResponse)]
|
||||
enum ContactApiResponse {
|
||||
/// Success
|
||||
#[oai(status = 200)]
|
||||
Ok(Json<ContactResponse>),
|
||||
/// Bad Request - validation failed
|
||||
#[oai(status = 400)]
|
||||
BadRequest(Json<ContactResponse>),
|
||||
/// Too Many Requests - rate limit exceeded
|
||||
#[oai(status = 429)]
|
||||
#[allow(dead_code)]
|
||||
TooManyRequests,
|
||||
/// Internal Server Error
|
||||
#[oai(status = 500)]
|
||||
InternalServerError(Json<ContactResponse>),
|
||||
}
|
||||
|
||||
/// API for handling contact form submissions and sending emails.
|
||||
#[derive(Clone)]
|
||||
pub struct ContactApi {
|
||||
settings: EmailSettings,
|
||||
}
|
||||
|
||||
impl From<EmailSettings> 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<ContactRequest>,
|
||||
remote_addr: Option<poem::web::Data<&poem::web::RemoteAddr>>,
|
||||
) -> 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<dyn std::error::Error>> {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
418
src/route/contact/errors.rs
Normal file
418
src/route/contact/errors.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
1002
src/route/contact/mod.rs
Normal file
1002
src/route/contact/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
use poem_openapi::Tags;
|
||||
|
||||
mod contact;
|
||||
pub use contact::errors::ContactError;
|
||||
mod health;
|
||||
mod meta;
|
||||
|
||||
|
||||
108
src/settings.rs
108
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<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]
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user