Skip to main content

gatekpr_email/
client.rs

1//! Email client for sending emails via SMTP
2//!
3//! Uses [lettre](https://github.com/lettre/lettre) for SMTP transport with:
4//! - Connection pooling for efficiency
5//! - Async sending with Tokio
6//! - TLS support (STARTTLS and native TLS)
7
8use crate::config::{EmailConfig, TlsMode};
9use crate::error::{EmailError, Result};
10use crate::templates::{EmailTemplate, TemplateRenderer};
11use lettre::message::header::ContentType;
12use lettre::message::{Mailbox, MultiPart, SinglePart};
13use lettre::transport::smtp::authentication::Credentials;
14use lettre::transport::smtp::client::{Tls, TlsParameters};
15use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
16use std::sync::Arc;
17use tracing::{debug, error, info, instrument};
18
19/// Email client for sending templated emails
20///
21/// Provides a high-level interface for sending emails using SMTP.
22/// Handles template rendering, connection pooling, and error handling.
23///
24/// # Example
25///
26/// ```rust,ignore
27/// use gatekpr_email::{EmailClient, EmailConfig};
28///
29/// let config = EmailConfig::from_env()?;
30/// let client = EmailClient::new(config)?;
31///
32/// // Send a simple email
33/// client.send_simple(
34///     "user@example.com",
35///     "Welcome!",
36///     "<h1>Hello!</h1>",
37///     "Hello!",
38/// ).await?;
39/// ```
40pub struct EmailClient {
41    transport: AsyncSmtpTransport<Tokio1Executor>,
42    config: EmailConfig,
43    renderer: Arc<TemplateRenderer>,
44}
45
46impl EmailClient {
47    /// Create a new email client
48    ///
49    /// # Arguments
50    ///
51    /// * `config` - Email configuration
52    ///
53    /// # Errors
54    ///
55    /// Returns an error if the SMTP transport cannot be created.
56    pub fn new(config: EmailConfig) -> Result<Self> {
57        let transport = Self::build_transport(&config)?;
58        let renderer = Arc::new(TemplateRenderer::new()?);
59
60        info!(
61            host = %config.smtp_host,
62            port = config.smtp_port,
63            tls = ?config.tls_mode,
64            "Email client initialized"
65        );
66
67        Ok(Self {
68            transport,
69            config,
70            renderer,
71        })
72    }
73
74    /// Create from environment variables
75    pub fn from_env() -> Result<Self> {
76        let config = EmailConfig::from_env()?;
77        Self::new(config)
78    }
79
80    /// Build the SMTP transport
81    fn build_transport(config: &EmailConfig) -> Result<AsyncSmtpTransport<Tokio1Executor>> {
82        let mut builder = match config.tls_mode {
83            TlsMode::None => {
84                AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp_host)
85            }
86            TlsMode::StartTls => {
87                let tls_params = TlsParameters::new(config.smtp_host.clone())
88                    .map_err(|e| EmailError::SmtpConnection(e.to_string()))?;
89                AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp_host)
90                    .map_err(|e| EmailError::SmtpConnection(e.to_string()))?
91                    .tls(Tls::Required(tls_params))
92            }
93            TlsMode::Required => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp_host)
94                .map_err(|e| EmailError::SmtpConnection(e.to_string()))?,
95        };
96
97        builder = builder
98            .port(config.smtp_port)
99            .timeout(Some(config.send_timeout));
100
101        // Add credentials if provided
102        if let (Some(username), Some(password)) = (&config.smtp_username, &config.smtp_password) {
103            let credentials = Credentials::new(username.clone(), password.clone());
104            builder = builder.credentials(credentials);
105        }
106
107        Ok(builder.build())
108    }
109
110    /// Send a templated email
111    ///
112    /// # Arguments
113    ///
114    /// * `template` - The email template to send
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if template rendering or sending fails.
119    #[instrument(skip(self, template), fields(to = %template.recipient(), template = %template.template_name()))]
120    pub async fn send<T: EmailTemplate>(&self, template: T) -> Result<()> {
121        let to = template.recipient();
122        let subject = template.subject();
123        let template_name = template.template_name();
124
125        debug!("Rendering email template: {}", template_name);
126
127        // Render HTML and plain text
128        let html = self.renderer.render_html(&template)?;
129        let text = self.renderer.render_text(&template)?;
130
131        self.send_multipart(&to, &subject, &html, &text).await
132    }
133
134    /// Send a simple email with HTML and text content
135    ///
136    /// # Arguments
137    ///
138    /// * `to` - Recipient email address
139    /// * `subject` - Email subject
140    /// * `html` - HTML content
141    /// * `text` - Plain text content
142    #[instrument(skip(self, html, text))]
143    pub async fn send_simple(&self, to: &str, subject: &str, html: &str, text: &str) -> Result<()> {
144        self.send_multipart(to, subject, html, text).await
145    }
146
147    /// Send a multipart email (HTML + plain text)
148    async fn send_multipart(&self, to: &str, subject: &str, html: &str, text: &str) -> Result<()> {
149        let from_mailbox: Mailbox =
150            format!("{} <{}>", self.config.from_name, self.config.from_address)
151                .parse()
152                .map_err(|e: lettre::address::AddressError| {
153                    EmailError::InvalidAddress(e.to_string())
154                })?;
155
156        let to_mailbox: Mailbox = to.parse().map_err(|e: lettre::address::AddressError| {
157            EmailError::InvalidAddress(e.to_string())
158        })?;
159
160        let mut message_builder = Message::builder()
161            .from(from_mailbox)
162            .to(to_mailbox)
163            .subject(subject);
164
165        // Add reply-to if configured
166        if let Some(ref reply_to) = self.config.reply_to {
167            let reply_mailbox: Mailbox =
168                reply_to
169                    .parse()
170                    .map_err(|e: lettre::address::AddressError| {
171                        EmailError::InvalidAddress(e.to_string())
172                    })?;
173            message_builder = message_builder.reply_to(reply_mailbox);
174        }
175
176        // Build multipart message (HTML + plain text)
177        let multipart = MultiPart::alternative()
178            .singlepart(
179                SinglePart::builder()
180                    .header(ContentType::TEXT_PLAIN)
181                    .body(text.to_string()),
182            )
183            .singlepart(
184                SinglePart::builder()
185                    .header(ContentType::TEXT_HTML)
186                    .body(html.to_string()),
187            );
188
189        let message = message_builder
190            .multipart(multipart)
191            .map_err(|e| EmailError::SendFailed(e.to_string()))?;
192
193        // Send the email
194        match self.transport.send(message).await {
195            Ok(response) => {
196                info!(
197                    to = %to,
198                    subject = %subject,
199                    code = ?response.code(),
200                    "Email sent successfully"
201                );
202                Ok(())
203            }
204            Err(e) => {
205                error!(
206                    to = %to,
207                    subject = %subject,
208                    error = %e,
209                    "Failed to send email"
210                );
211                Err(EmailError::SendFailed(e.to_string()))
212            }
213        }
214    }
215
216    /// Test the SMTP connection
217    ///
218    /// # Errors
219    ///
220    /// Returns an error if the connection test fails.
221    #[instrument(skip(self))]
222    pub async fn test_connection(&self) -> Result<()> {
223        match self.transport.test_connection().await {
224            Ok(true) => {
225                info!("SMTP connection test successful");
226                Ok(())
227            }
228            Ok(false) => {
229                error!("SMTP connection test failed");
230                Err(EmailError::SmtpConnection(
231                    "Connection test returned false".to_string(),
232                ))
233            }
234            Err(e) => {
235                error!(error = %e, "SMTP connection test error");
236                Err(EmailError::SmtpConnection(e.to_string()))
237            }
238        }
239    }
240
241    /// Get the underlying configuration
242    pub fn config(&self) -> &EmailConfig {
243        &self.config
244    }
245
246    /// Get the template renderer
247    pub fn renderer(&self) -> &TemplateRenderer {
248        &self.renderer
249    }
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255
256    #[tokio::test]
257    async fn test_client_creation_without_server() {
258        // This test just verifies the client can be built with config
259        // Actual sending requires a real SMTP server
260        let config = EmailConfig::builder()
261            .smtp_host("localhost")
262            .smtp_port(1025) // MailHog default port
263            .tls_mode(TlsMode::None)
264            .from_address("test@example.com")
265            .from_name("Test")
266            .build();
267
268        let client = EmailClient::new(config);
269        assert!(client.is_ok());
270    }
271}