@@ -7,6 +7,64 @@ use lettre::{
77
88use super :: { EmailError , EmailMessage , EmailSender } ;
99
10+ /// How to encrypt the connection to the SMTP server.
11+ #[ derive( Debug , Clone , Copy , PartialEq , Eq ) ]
12+ pub enum SmtpEncryption {
13+ /// STARTTLS after plain connect (typical port 587).
14+ StartTls ,
15+ /// Implicit TLS / SMTPS (typical port 465).
16+ Tls ,
17+ /// Cleartext only — for local tools (e.g. Mailpit). Not for production.
18+ None ,
19+ }
20+
21+ impl SmtpEncryption {
22+ /// Resolve from optional `SMTP_ENCRYPTION` value and port.
23+ /// When `override_` is `None` or empty: port 465 → `Tls`, otherwise `StartTls`.
24+ pub fn resolve ( override_ : Option < & str > , port : u16 ) -> Result < Self , EmailError > {
25+ match override_. map ( str:: trim) . filter ( |s| !s. is_empty ( ) ) {
26+ None => Ok ( if port == 465 { Self :: Tls } else { Self :: StartTls } ) ,
27+ Some ( "starttls" ) => Ok ( Self :: StartTls ) ,
28+ Some ( "tls" ) => Ok ( Self :: Tls ) ,
29+ Some ( "none" ) => Ok ( Self :: None ) ,
30+ Some ( other) => Err ( EmailError :: Config ( format ! (
31+ "Invalid SMTP_ENCRYPTION: expected starttls, tls, or none, got {:?}" ,
32+ other
33+ ) ) ) ,
34+ }
35+ }
36+ }
37+
38+ fn build_transport (
39+ host : & str ,
40+ port : u16 ,
41+ user : Option < String > ,
42+ pass : Option < String > ,
43+ encryption : SmtpEncryption ,
44+ ) -> Result < AsyncSmtpTransport < Tokio1Executor > , EmailError > {
45+ let mut builder = match encryption {
46+ SmtpEncryption :: StartTls => AsyncSmtpTransport :: < Tokio1Executor > :: starttls_relay ( host)
47+ . map_err ( |e| EmailError :: Config ( format ! ( "SMTP STARTTLS relay: {}" , e) ) ) ?,
48+ SmtpEncryption :: Tls => AsyncSmtpTransport :: < Tokio1Executor > :: relay ( host)
49+ . map_err ( |e| EmailError :: Config ( format ! ( "SMTP TLS relay: {}" , e) ) ) ?,
50+ SmtpEncryption :: None => {
51+ tracing:: warn!(
52+ "SMTP_ENCRYPTION=none: using cleartext SMTP without TLS (not for production)"
53+ ) ;
54+ AsyncSmtpTransport :: < Tokio1Executor > :: builder_dangerous ( host)
55+ }
56+ } ;
57+
58+ builder = builder. port ( port) ;
59+
60+ if let ( Some ( user) , Some ( pass) ) = ( user, pass) {
61+ let creds = Credentials :: new ( user, pass) ;
62+ builder = builder. credentials ( creds) ;
63+ }
64+
65+ Ok ( builder. build ( ) )
66+ }
67+
1068/// SMTP email sender for production use.
1169#[ derive( Debug ) ]
1270pub struct SmtpMailer {
@@ -23,23 +81,16 @@ impl SmtpMailer {
2381 /// * `user` - SMTP username (optional for some servers)
2482 /// * `pass` - SMTP password (optional for some servers)
2583 /// * `from` - Default from address
84+ /// * `encryption` - TLS mode (see [`SmtpEncryption::resolve`])
2685 pub fn new (
2786 host : String ,
2887 port : u16 ,
2988 user : Option < String > ,
3089 pass : Option < String > ,
3190 from : String ,
91+ encryption : SmtpEncryption ,
3292 ) -> Result < Self , EmailError > {
33- let mut transport = AsyncSmtpTransport :: < Tokio1Executor > :: builder_dangerous ( & host)
34- . port ( port) ;
35-
36- // Add authentication if provided
37- if let ( Some ( user) , Some ( pass) ) = ( user, pass) {
38- let creds = Credentials :: new ( user, pass) ;
39- transport = transport. credentials ( creds) ;
40- }
41-
42- let transport = transport. build ( ) ;
93+ let transport = build_transport ( & host, port, user, pass, encryption) ?;
4394
4495 Ok ( Self { transport, from } )
4596 }
@@ -68,4 +119,62 @@ impl EmailSender for SmtpMailer {
68119
69120 Ok ( ( ) )
70121 }
71- }
122+ }
123+
124+ #[ cfg( test) ]
125+ mod tests {
126+ use super :: SmtpEncryption ;
127+
128+ #[ test]
129+ fn resolve_auto_465_is_tls ( ) {
130+ assert_eq ! (
131+ SmtpEncryption :: resolve( None , 465 ) . unwrap( ) ,
132+ SmtpEncryption :: Tls
133+ ) ;
134+ }
135+
136+ #[ test]
137+ fn resolve_auto_587_is_starttls ( ) {
138+ assert_eq ! (
139+ SmtpEncryption :: resolve( None , 587 ) . unwrap( ) ,
140+ SmtpEncryption :: StartTls
141+ ) ;
142+ }
143+
144+ #[ test]
145+ fn resolve_explicit_starttls_on_465 ( ) {
146+ assert_eq ! (
147+ SmtpEncryption :: resolve( Some ( "starttls" ) , 465 ) . unwrap( ) ,
148+ SmtpEncryption :: StartTls
149+ ) ;
150+ }
151+
152+ #[ test]
153+ fn resolve_explicit_tls_on_587 ( ) {
154+ assert_eq ! (
155+ SmtpEncryption :: resolve( Some ( "tls" ) , 587 ) . unwrap( ) ,
156+ SmtpEncryption :: Tls
157+ ) ;
158+ }
159+
160+ #[ test]
161+ fn resolve_explicit_none ( ) {
162+ assert_eq ! (
163+ SmtpEncryption :: resolve( Some ( "none" ) , 1025 ) . unwrap( ) ,
164+ SmtpEncryption :: None
165+ ) ;
166+ }
167+
168+ #[ test]
169+ fn resolve_empty_string_uses_auto ( ) {
170+ assert_eq ! (
171+ SmtpEncryption :: resolve( Some ( "" ) , 2525 ) . unwrap( ) ,
172+ SmtpEncryption :: StartTls
173+ ) ;
174+ }
175+
176+ #[ test]
177+ fn resolve_invalid_returns_err ( ) {
178+ assert ! ( SmtpEncryption :: resolve( Some ( "wss" ) , 587 ) . is_err( ) ) ;
179+ }
180+ }
0 commit comments