commit
bc5fbd6ba9
180 files changed
+63750
No files matched your search
@@ -0,0 +1,157 @@
|
||||
//! SMTP client abstraction and real implementation.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
// Import and re-export types for backward compatibility
|
||||
pub use crate::smtp::error::SmtpError;
|
||||
pub use crate::smtp::types::{AttachmentData, SendableMessage, SmtpCredentials, SmtpSendResult};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Trait definition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Abstraction over SMTP operations.
|
||||
///
|
||||
/// Every method receives explicit connection parameters so that the trait
|
||||
/// remains stateless — no persistent connections are held.
|
||||
///
|
||||
/// The `Send + Sync` bounds allow implementations to be shared across
|
||||
/// Tokio tasks and stored in `Arc`.
|
||||
#[allow(clippy::double_must_use)]
|
||||
#[async_trait]
|
||||
pub trait SmtpClient: Send + Sync {
|
||||
/// Send an email message. Returns the generated Message-ID and exact RFC822 bytes.
|
||||
async fn send_message(
|
||||
&self,
|
||||
creds: &SmtpCredentials,
|
||||
message: &SendableMessage,
|
||||
) -> Result<SmtpSendResult, SmtpError>;
|
||||
|
||||
/// Verify that the configured SMTP endpoint accepts an authenticated NOOP.
|
||||
async fn check_connection(&self, _creds: &SmtpCredentials) -> Result<(), SmtpError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real implementation backed by lettre
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Production SMTP client that uses `lettre`.
|
||||
///
|
||||
/// This is a stateless unit struct — every method creates a fresh connection,
|
||||
/// performs the operation, and disconnects.
|
||||
pub struct RealSmtpClient;
|
||||
|
||||
#[async_trait]
|
||||
impl SmtpClient for RealSmtpClient {
|
||||
async fn send_message(
|
||||
&self,
|
||||
creds: &SmtpCredentials,
|
||||
message: &SendableMessage,
|
||||
) -> Result<SmtpSendResult, SmtpError> {
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Tokio1Executor};
|
||||
|
||||
let message_id = format!(
|
||||
"<{}.{}@{}>",
|
||||
uuid::Uuid::new_v4(),
|
||||
uuid::Uuid::new_v4(),
|
||||
creds.host
|
||||
);
|
||||
let email = crate::smtp::builder::build_message(message, &message_id)
|
||||
.map_err(SmtpError::SendFailed)?;
|
||||
let rfc822 = email.formatted();
|
||||
|
||||
let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone());
|
||||
let transport: AsyncSmtpTransport<Tokio1Executor> = if creds.tls && creds.port == 587 {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&creds.host)
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
} else if creds.tls {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&creds.host)
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&creds.host)
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
};
|
||||
|
||||
transport.send(email).await.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
let lower = msg.to_lowercase();
|
||||
if lower.contains("authentication")
|
||||
|| lower.contains("credentials")
|
||||
|| lower.contains("auth")
|
||||
{
|
||||
SmtpError::AuthenticationFailed
|
||||
} else if lower.contains("connect")
|
||||
|| lower.contains("dns")
|
||||
|| lower.contains("resolve")
|
||||
|| lower.contains("timeout")
|
||||
|| lower.contains("tls")
|
||||
{
|
||||
SmtpError::ConnectionFailed(msg)
|
||||
} else {
|
||||
SmtpError::SendFailed(msg)
|
||||
}
|
||||
})?;
|
||||
|
||||
Ok(SmtpSendResult { message_id, rfc822 })
|
||||
}
|
||||
|
||||
async fn check_connection(&self, creds: &SmtpCredentials) -> Result<(), SmtpError> {
|
||||
use lettre::transport::smtp::authentication::Credentials;
|
||||
use lettre::{AsyncSmtpTransport, Tokio1Executor};
|
||||
|
||||
let smtp_creds = Credentials::new(creds.email.clone(), creds.password.clone());
|
||||
let transport: AsyncSmtpTransport<Tokio1Executor> = if creds.tls && creds.port == 587 {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&creds.host)
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
} else if creds.tls {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&creds.host)
|
||||
.map_err(|e| SmtpError::ConnectionFailed(e.to_string()))?
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
} else {
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&creds.host)
|
||||
.port(creds.port)
|
||||
.credentials(smtp_creds)
|
||||
.build()
|
||||
};
|
||||
|
||||
let accepted = transport.test_connection().await.map_err(|e| {
|
||||
let message = e.to_string();
|
||||
if message.to_ascii_lowercase().contains("auth") {
|
||||
SmtpError::AuthenticationFailed
|
||||
} else {
|
||||
SmtpError::ConnectionFailed(message)
|
||||
}
|
||||
})?;
|
||||
if accepted {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SmtpError::ConnectionFailed(
|
||||
"SMTP NOOP was not accepted".to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock implementation (test-only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mock.rs"]
|
||||
pub mod mock;
|
||||
Reference in new issue
Block a user