1use 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
19pub struct EmailClient {
41 transport: AsyncSmtpTransport<Tokio1Executor>,
42 config: EmailConfig,
43 renderer: Arc<TemplateRenderer>,
44}
45
46impl EmailClient {
47 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 pub fn from_env() -> Result<Self> {
76 let config = EmailConfig::from_env()?;
77 Self::new(config)
78 }
79
80 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 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 #[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 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 #[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 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 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 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 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 #[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 pub fn config(&self) -> &EmailConfig {
243 &self.config
244 }
245
246 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 let config = EmailConfig::builder()
261 .smtp_host("localhost")
262 .smtp_port(1025) .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}