Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions crates/temps-email/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

use thiserror::Error;

use crate::providers::EmailProviderType;

#[derive(Error, Debug)]
pub enum EmailError {
#[error("Database error: {0}")]
Expand Down Expand Up @@ -51,10 +53,33 @@ pub enum EmailError {

#[error("Tracking rewrite failed for email {email_id}: {reason}")]
TrackingRewrite { email_id: String, reason: String },

/// A provider's `send()` call failed. Unlike the flat per-provider
/// variants above (used for identity/domain management calls),
/// this carries a `retryable` classification derived from the
/// underlying transport error (HTTP status, SMTP reply code, or AWS SDK
/// error kind) so the send path knows whether retrying — same provider
/// or the next one in the failover chain — can plausibly succeed.
#[error("Failed to send email via {provider}: {reason}")]
SendFailed {
provider: EmailProviderType,
retryable: bool,
reason: String,
},
}

impl From<serde_json::Error> for EmailError {
fn from(err: serde_json::Error) -> Self {
EmailError::Serialization(err.to_string())
}
}

impl EmailError {
/// Whether this failure is transient and worth retrying (against the
/// same provider or the next one in a domain's failover chain). Every
/// variant besides `SendFailed { retryable: true, .. }` represents a
/// config, validation, or lookup problem that a retry cannot fix.
pub fn is_retryable(&self) -> bool {
matches!(self, EmailError::SendFailed { retryable: true, .. })
}
}
4 changes: 4 additions & 0 deletions crates/temps-email/src/handlers/domains.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ impl From<EmailError> for Problem {
.with_title("Internal Server Error")
.with_detail(error.to_string())
}

EmailError::SendFailed { .. } => problemdetails::new(StatusCode::BAD_GATEWAY)
.with_title("Provider Send Failed")
.with_detail(error.to_string()),
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions crates/temps-email/src/handlers/tracking_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ mod tests {
use crate::handlers::tracking::{public_routes, routes};
use crate::handlers::types::AppState;
use crate::services::{
DomainService, EmailService, ProviderService, TrackingService, ValidationConfig,
ValidationService,
DomainService, EmailService, ProviderService, SuppressionService, TrackingService,
ValidationConfig, ValidationService,
};

// ============================================
Expand Down Expand Up @@ -120,11 +120,13 @@ mod tests {
config_service,
"http://localhost:3000".to_string(),
));
let suppression_service = Arc::new(SuppressionService::new(db.db.clone()));
let email_service = Arc::new(EmailService::new(
db.db.clone(),
provider_service.clone(),
domain_service.clone(),
tracking_service.clone(),
suppression_service,
));
let validation_service = Arc::new(ValidationService::new(ValidationConfig::default()));

Expand Down
11 changes: 8 additions & 3 deletions crates/temps-email/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ use utoipa::OpenApi as OpenApiTrait;

use crate::handlers::{self, AppState, EmailApiDoc};
use crate::services::{
DomainService, EmailService, ProviderService, TrackingService, ValidationConfig,
ValidationService,
DomainService, EmailService, ProviderService, SuppressionService, TrackingService,
ValidationConfig, ValidationService,
};
use temps_dns::services::DnsProviderService;

Expand Down Expand Up @@ -61,12 +61,17 @@ impl TempsPlugin for EmailPlugin {
let tracking_service = Arc::new(TrackingService::new(db.clone(), config_service));
context.register_service(tracking_service.clone());

// Create EmailService with tracking support
// Create SuppressionService — bounce/complaint do-not-send list
let suppression_service = Arc::new(SuppressionService::new(db.clone()));
context.register_service(suppression_service.clone());

// Create EmailService with tracking + suppression support
let email_service = Arc::new(EmailService::new(
db.clone(),
provider_service.clone(),
domain_service.clone(),
tracking_service.clone(),
suppression_service.clone(),
));
context.register_service(email_service.clone());

Expand Down
37 changes: 27 additions & 10 deletions crates/temps-email/src/providers/scaleway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,14 @@ impl EmailProvider for ScalewayProvider {
.json(&request)
.send()
.await
.map_err(|e| EmailError::Scaleway(format!("Failed to send email: {}", e)))?;
.map_err(|e| EmailError::SendFailed {
provider: EmailProviderType::Scaleway,
// A request that never reached Scaleway (DNS/connect/timeout) is
// worth retrying — it says nothing about whether the message itself
// is deliverable.
retryable: true,
reason: format!("Failed to send email: {}", e),
})?;

if !response.status().is_success() {
let status = response.status();
Expand All @@ -407,23 +414,33 @@ impl EmailProvider for ScalewayProvider {
.await
.unwrap_or_else(|_| "Unknown error".to_string());
error!("Failed to send email via Scaleway ({}): {}", status, body);
return Err(EmailError::Scaleway(format!(
"Failed to send email ({}): {}",
status, body
)));
return Err(EmailError::SendFailed {
provider: EmailProviderType::Scaleway,
// 5xx and 429 are Scaleway-side transient conditions; any other
// 4xx (bad request, invalid recipient, auth failure) won't change
// on retry.
retryable: status.is_server_error() || status.as_u16() == 429,
reason: format!("Failed to send email ({}): {}", status, body),
});
}

let email_response: ScalewayEmailResponse = response
.json()
.await
.map_err(|e| EmailError::Scaleway(format!("Failed to parse email response: {}", e)))?;
let email_response: ScalewayEmailResponse =
response.json().await.map_err(|e| EmailError::SendFailed {
provider: EmailProviderType::Scaleway,
retryable: false,
reason: format!("Failed to parse email response: {}", e),
})?;

let message_id = email_response
.emails
.first()
.and_then(|e| e.message_id.clone())
.or_else(|| email_response.emails.first().map(|e| e.id.clone()))
.ok_or_else(|| EmailError::Scaleway("No message ID returned".to_string()))?;
.ok_or_else(|| EmailError::SendFailed {
provider: EmailProviderType::Scaleway,
retryable: false,
reason: "No message ID returned".to_string(),
})?;

debug!("Email sent successfully, message_id: {}", message_id);

Expand Down
65 changes: 62 additions & 3 deletions crates/temps-email/src/providers/ses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,52 @@ fn extract_ses_error_details<E: std::fmt::Display + std::fmt::Debug>(
}
}

/// Classify whether an SES `send_email` failure is worth retrying.
/// Network/timeout failures never reached AWS and are always worth another
/// attempt. Service errors are retryable only for AWS's own typed
/// throttling/capacity exceptions — a message-level rejection (bad
/// recipient, unverified sender, suspended/paused account) will fail
/// identically on retry. Matches on the concrete `SendEmailError` variants
/// rather than string-matching the error message, which is fragile against
/// SDK wording changes.
fn is_send_email_error_retryable(
e: &aws_sdk_sesv2::error::SdkError<aws_sdk_sesv2::operation::send_email::SendEmailError>,
) -> bool {
use aws_sdk_sesv2::error::SdkError;
use aws_sdk_sesv2::operation::send_email::SendEmailError;
use aws_sdk_sesv2::error::ProvideErrorMetadata;

match e {
SdkError::TimeoutError(_) => true,
SdkError::DispatchFailure(dispatch_err) => {
dispatch_err.is_io() || dispatch_err.is_timeout()
}
SdkError::ResponseError(_) => true,
SdkError::ConstructionFailure(_) => false,
SdkError::ServiceError(service_err) => match service_err.err() {
SendEmailError::TooManyRequestsException(_)
| SendEmailError::LimitExceededException(_) => true,
SendEmailError::AccountSuspendedException(_)
| SendEmailError::BadRequestException(_)
| SendEmailError::MailFromDomainNotVerifiedException(_)
| SendEmailError::MessageRejected(_)
| SendEmailError::NotFoundException(_)
| SendEmailError::SendingPausedException(_) => false,
// Any future/unhandled variant: fall back to the error code for
// AWS-side throttling/capacity conditions rather than assuming
// permanent.
other => matches!(
other.code(),
Some("ThrottlingException")
| Some("ServiceUnavailable")
| Some("InternalFailure")
| Some("InternalServerError")
),
},
_ => false,
}
}

/// AWS SES credentials configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SesCredentials {
Expand Down Expand Up @@ -468,13 +514,26 @@ impl EmailProvider for SesProvider {
let result = send_request.send().await.map_err(|e| {
// Extract detailed error information from AWS SDK error
let error_message = extract_ses_error_details(&e);
error!("Failed to send email via SES: {}", error_message);
EmailError::AwsSes(error_message)
let retryable = is_send_email_error_retryable(&e);
error!(
"Failed to send email via SES ({}): {}",
if retryable { "retryable" } else { "permanent" },
error_message
);
EmailError::SendFailed {
provider: EmailProviderType::Ses,
retryable,
reason: error_message,
}
})?;

let message_id = result
.message_id()
.ok_or_else(|| EmailError::AwsSes("No message ID returned".to_string()))?
.ok_or_else(|| EmailError::SendFailed {
provider: EmailProviderType::Ses,
retryable: false,
reason: "No message ID returned".to_string(),
})?
.to_string();

debug!("Email sent successfully, message_id: {}", message_id);
Expand Down
12 changes: 11 additions & 1 deletion crates/temps-email/src/providers/smtp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,17 @@ impl EmailProvider for SmtpProvider {

self.transport.send(message).await.map_err(|e| {
error!("Failed to send email via SMTP: {}", e);
EmailError::Smtp(format!("Failed to send email: {}", e))
// 4xx SMTP replies (is_transient) and connection-level failures
// (is_client/is_timeout/is_transport_shutdown) are worth retrying;
// a 5xx permanent reply from the relay will reject the same
// message again.
let retryable =
e.is_transient() || e.is_timeout() || e.is_client() || e.is_transport_shutdown();
EmailError::SendFailed {
provider: EmailProviderType::Smtp,
retryable,
reason: format!("Failed to send email: {}", e),
}
})?;

debug!("Email sent via SMTP, message_id: {}", message_id);
Expand Down
Loading