Skip to content

Commit c16c513

Browse files
committed
feat(mail): add SMTP encryption support
1 parent f1174f7 commit c16c513

File tree

6 files changed

+152
-15
lines changed

6 files changed

+152
-15
lines changed

.env.example

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,7 @@ [email protected]
1212
# SMTP_HOST=
1313
# SMTP_PORT=587
1414
# SMTP_USER=
15-
# SMTP_PASS=
15+
# SMTP_PASS=
16+
# SMTP_ENCRYPTION: unset = auto (port 465 → tls/SMTPS, other ports → starttls).
17+
# starttls | tls | none — use none only for local mail sinks (e.g. Mailpit), not production.
18+
# SMTP_ENCRYPTION=

src/app/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ pub struct Config {
2929

3030
/// SMTP password. Optional for some servers.
3131
pub smtp_pass: Option<String>,
32+
33+
/// SMTP encryption: `starttls`, `tls`, or `none`. Unset uses auto (465 → tls, else starttls).
34+
pub smtp_encryption: Option<String>,
3235
}
3336

3437
impl Config {
@@ -54,6 +57,7 @@ impl Config {
5457
.map_err(|_| "SMTP_PORT must be a valid port number")?;
5558
let smtp_user = std::env::var("SMTP_USER").ok();
5659
let smtp_pass = std::env::var("SMTP_PASS").ok();
60+
let smtp_encryption = std::env::var("SMTP_ENCRYPTION").ok();
5761

5862
Ok(Self {
5963
database_url,
@@ -64,6 +68,7 @@ impl Config {
6468
smtp_port,
6569
smtp_user,
6670
smtp_pass,
71+
smtp_encryption,
6772
})
6873
}
6974

@@ -83,6 +88,7 @@ impl Config {
8388
smtp_port: 587,
8489
smtp_user: None,
8590
smtp_pass: None,
91+
smtp_encryption: None,
8692
}
8793
}
8894
}

src/app/mail/mod.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ pub enum EmailError {
5050

5151
// Re-export implementations
5252
pub use console::ConsoleMailer;
53-
pub use smtp::SmtpMailer;
53+
pub use smtp::{SmtpEncryption, SmtpMailer};
5454

5555
mod console;
5656
mod smtp;
@@ -65,12 +65,15 @@ pub fn from_config(config: &crate::app::config::Config) -> Result<Arc<dyn EmailS
6565
.clone()
6666
.ok_or_else(|| EmailError::Config("SMTP_HOST is required for SMTP adapter".to_string()))?;
6767

68+
let encryption = SmtpEncryption::resolve(config.smtp_encryption.as_deref(), config.smtp_port)?;
69+
6870
Ok(Arc::new(SmtpMailer::new(
6971
host,
7072
config.smtp_port,
7173
config.smtp_user.clone(),
7274
config.smtp_pass.clone(),
7375
config.mail_from.clone(),
76+
encryption,
7477
)?))
7578
}
7679
_ => Err(EmailError::Config(format!(

src/app/mail/smtp.rs

Lines changed: 120 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,64 @@ use lettre::{
77

88
use 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)]
1270
pub 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+
}

src/lib.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@ use std::path::PathBuf;
77
use axum::Router;
88
use tower_http::services::ServeDir;
99

10+
/// Root directory containing `js/` and `css/` (same layout as repo `public/`).
11+
/// Set `BOARDTASK_PUBLIC_DIR` when the binary runs without the compile-time crate path
12+
/// (e.g. container with only `public/` deployed beside the binary).
13+
fn static_public_root() -> PathBuf {
14+
std::env::var_os("BOARDTASK_PUBLIC_DIR")
15+
.map(PathBuf::from)
16+
.filter(|p| !p.as_os_str().is_empty())
17+
.unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("public"))
18+
}
19+
1020
/// Build the full application router. Used by main and by integration tests.
1121
pub fn create_router(state: app::AppState) -> Router {
12-
let public_js = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("public/js");
13-
let public_css = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("public/css");
22+
let root = static_public_root();
23+
let public_js = root.join("js");
24+
let public_css = root.join("css");
1425

1526
Router::new()
1627
.nest_service("/js", ServeDir::new(public_js))

tests/static_assets.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@ async fn get_js_app_js_returns_javascript_not_html_404() {
3535
"Expected JavaScript body, got: {:?}",
3636
prefix.chars().take(120).collect::<String>()
3737
);
38+
let s = String::from_utf8_lossy(&body);
39+
assert!(
40+
s.contains("Alpine.data('projectKanban'"),
41+
"Served app.js must register projectKanban (Kanban x-data); missing registration causes Alpine ReferenceError"
42+
);
3843
}
3944

4045
#[tokio::test]

0 commit comments

Comments
 (0)