1use std::future::Future;
26use std::pin::Pin;
27use std::sync::Arc;
28
29use lettre::message::{Mailbox, MultiPart, SinglePart, header::ContentType};
30use lettre::transport::smtp::authentication::Credentials;
31use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
32
33use crate::email::{EmailMessage, EmailSender};
34use crate::email_render::{EmailBranding, render};
35use crate::error::AuthError;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum SmtpTls {
45 None,
47 StartTls,
49 ImplicitTls,
51}
52
53#[derive(Debug, Clone)]
55pub struct SmtpConfig {
56 pub host: String,
57 pub port: u16,
58 pub username: Option<String>,
60 pub password: Option<String>,
62 pub from_address: String,
64 pub from_name: Option<String>,
66 pub tls: SmtpTls,
67}
68
69pub struct SmtpEmailSender<T = AsyncSmtpTransport<Tokio1Executor>> {
74 transport: T,
75 from: Mailbox,
76 branding: Arc<EmailBranding>,
77}
78
79impl SmtpEmailSender {
80 pub fn new(config: SmtpConfig, branding: EmailBranding) -> Result<Self, AuthError> {
82 let is_localhost = config.host == "localhost" || config.host == "127.0.0.1";
83 if config.tls == SmtpTls::None && !is_localhost {
84 return Err(AuthError::Email(
85 "SmtpTls::None is only allowed for localhost hosts".to_owned(),
86 ));
87 }
88 if config.tls == SmtpTls::None {
89 tracing::warn!(
90 host = %config.host,
91 "SmtpEmailSender: using unencrypted SMTP (dev only)"
92 );
93 }
94
95 let mut builder = match config.tls {
96 SmtpTls::StartTls => AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.host)
97 .map_err(|e| AuthError::Email(e.to_string()))?,
98 SmtpTls::ImplicitTls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.host)
99 .map_err(|e| AuthError::Email(e.to_string()))?,
100 SmtpTls::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.host),
101 };
102
103 builder = builder.port(config.port);
104
105 if let (Some(user), Some(pass)) = (config.username, config.password) {
106 builder = builder.credentials(Credentials::new(user, pass));
107 }
108
109 let transport = builder.build();
110 let from = build_mailbox(config.from_name, &config.from_address)?;
111
112 Ok(Self {
113 transport,
114 from,
115 branding: Arc::new(branding),
116 })
117 }
118}
119
120impl<T> SmtpEmailSender<T> {
121 #[cfg(test)]
123 pub fn new_with_transport(transport: T, branding: EmailBranding) -> Self {
124 Self {
125 transport,
126 from: "Test Sender <test@example.com>"
127 .parse()
128 .expect("hardcoded mailbox is valid"),
129 branding: Arc::new(branding),
130 }
131 }
132}
133
134impl<T> EmailSender for SmtpEmailSender<T>
135where
136 T: AsyncTransport + Send + Sync,
137 T::Error: std::fmt::Display,
138{
139 fn send<'a>(
140 &'a self,
141 message: &'a EmailMessage,
142 ) -> Pin<Box<dyn Future<Output = Result<(), AuthError>> + Send + 'a>> {
143 Box::pin(async move {
144 let rendered = render(&message.template, &self.branding);
145
146 let to: Mailbox = message
147 .to
148 .parse()
149 .map_err(|e: lettre::address::AddressError| AuthError::Email(e.to_string()))?;
150
151 let email = Message::builder()
152 .from(self.from.clone())
153 .to(to)
154 .subject(&message.subject)
155 .multipart(
156 MultiPart::alternative()
157 .singlepart(
158 SinglePart::builder()
159 .header(ContentType::TEXT_PLAIN)
160 .body(rendered.text),
161 )
162 .singlepart(
163 SinglePart::builder()
164 .header(ContentType::TEXT_HTML)
165 .body(rendered.html),
166 ),
167 )
168 .map_err(|e| AuthError::Email(e.to_string()))?;
169
170 self.transport
171 .send(email)
172 .await
173 .map(|_| ())
174 .map_err(|e| AuthError::Email(e.to_string()))
175 })
176 }
177}
178
179fn build_mailbox(name: Option<String>, address: &str) -> Result<Mailbox, AuthError> {
180 let addr: lettre::Address = address
181 .parse()
182 .map_err(|e: lettre::address::AddressError| AuthError::Email(e.to_string()))?;
183 Ok(Mailbox::new(name, addr))
184}
185
186#[cfg(test)]
187mod tests {
188 use lettre::transport::stub::AsyncStubTransport;
189
190 use crate::email::EmailTemplate;
191
192 use super::*;
193
194 fn make_sender() -> SmtpEmailSender<AsyncStubTransport> {
195 let stub = AsyncStubTransport::new_ok();
196 SmtpEmailSender::new_with_transport(stub, EmailBranding::default())
197 }
198
199 fn make_sender_with_branding(branding: EmailBranding) -> SmtpEmailSender<AsyncStubTransport> {
200 let stub = AsyncStubTransport::new_ok();
201 SmtpEmailSender::new_with_transport(stub, branding)
202 }
203
204 fn reset_message() -> EmailMessage {
205 EmailMessage {
206 to: "alice@example.com".to_owned(),
207 subject: "Reset your password".to_owned(),
208 template: EmailTemplate::PasswordReset {
209 url: "https://example.com/reset?t=abc".to_owned(),
210 username: "alice".to_owned(),
211 },
212 }
213 }
214
215 #[tokio::test]
216 async fn send_captures_message_with_correct_headers() {
217 let sender = make_sender();
218 sender.send(&reset_message()).await.unwrap();
219
220 let msgs = sender.transport.messages().await;
221 assert_eq!(msgs.len(), 1);
222 let (envelope, raw) = &msgs[0];
223 let to_addrs: Vec<_> = envelope.to().iter().map(|a| a.as_ref()).collect();
225 assert!(to_addrs.contains(&"alice@example.com"));
226 assert!(raw.contains("Reset your password"));
228 assert!(raw.contains("text/plain"));
229 assert!(raw.contains("text/html"));
230 }
231
232 #[tokio::test]
233 async fn send_includes_subject_and_rendered_url_in_body() {
234 let sender = make_sender();
238 sender.send(&reset_message()).await.unwrap();
239
240 let msgs = sender.transport.messages().await;
241 let raw = &msgs[0].1;
242 assert!(raw.contains("Subject: Reset your password"));
243 assert!(
248 raw.contains("example.com/reset"),
249 "rendered URL must reach the SMTP transport"
250 );
251 assert!(
252 raw.contains("alice"),
253 "rendered username must reach the SMTP transport"
254 );
255 }
256
257 #[tokio::test]
258 async fn branding_app_name_propagates_to_smtp_body() {
259 let branding = EmailBranding {
263 app_name: "Acme Inc".to_owned(),
264 logo_url: None,
265 footer_line: None,
266 };
267 let sender = make_sender_with_branding(branding);
268 sender.send(&reset_message()).await.unwrap();
269
270 let msgs = sender.transport.messages().await;
271 let raw = &msgs[0].1;
272 assert!(
273 raw.contains("Acme Inc"),
274 "branding.app_name must appear in the rendered body sent over SMTP"
275 );
276 }
277
278 #[tokio::test]
279 async fn address_parse_failure_returns_email_error() {
280 let sender = make_sender();
281 let msg = EmailMessage {
282 to: "not-an-email".to_owned(),
283 subject: "Subject".to_owned(),
284 template: EmailTemplate::PasswordReset {
285 url: "https://example.com".to_owned(),
286 username: "x".to_owned(),
287 },
288 };
289 let err = sender.send(&msg).await.unwrap_err();
290 assert!(matches!(err, AuthError::Email(_)));
291 }
292
293 #[test]
294 fn tls_none_refused_for_non_localhost() {
295 let cfg = SmtpConfig {
296 host: "smtp.example.com".to_owned(),
297 port: 25,
298 username: None,
299 password: None,
300 from_address: "x@example.com".to_owned(),
301 from_name: None,
302 tls: SmtpTls::None,
303 };
304 let result = SmtpEmailSender::new(cfg, EmailBranding::default());
305 assert!(matches!(result, Err(AuthError::Email(_))));
306 }
307
308 #[test]
309 fn tls_none_allowed_for_localhost() {
310 let cfg = SmtpConfig {
311 host: "localhost".to_owned(),
312 port: 1025,
313 username: None,
314 password: None,
315 from_address: "x@example.com".to_owned(),
316 from_name: None,
317 tls: SmtpTls::None,
318 };
319 assert!(SmtpEmailSender::new(cfg, EmailBranding::default()).is_ok());
321 }
322
323 #[test]
324 fn no_auth_config_succeeds() {
325 let cfg = SmtpConfig {
327 host: "localhost".to_owned(),
328 port: 1025,
329 username: None,
330 password: None,
331 from_address: "x@example.com".to_owned(),
332 from_name: None,
333 tls: SmtpTls::None,
334 };
335 assert!(SmtpEmailSender::new(cfg, EmailBranding::default()).is_ok());
336 }
337}