1use std::sync::atomic::{AtomicU64, Ordering};
25use std::sync::RwLock;
26use std::time::{Duration, SystemTime, UNIX_EPOCH};
27
28use lettre::message::header::ContentType;
29use lettre::message::{Attachment as LettreAttachment, Mailbox, MultiPart, SinglePart};
30use lettre::transport::smtp::authentication::Credentials;
31use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
32
33type PasswordResolver = Box<dyn Fn() -> Option<String> + Send + Sync>;
38static PASSWORD_RESOLVER: RwLock<Option<PasswordResolver>> = RwLock::new(None);
39
40pub fn set_password_resolver<F>(resolver: F)
43where
44 F: Fn() -> Option<String> + Send + Sync + 'static,
45{
46 if let Ok(mut guard) = PASSWORD_RESOLVER.write() {
47 *guard = Some(Box::new(resolver));
48 }
49}
50
51fn resolve_password() -> Option<String> {
54 let guard = PASSWORD_RESOLVER.read().ok()?;
55 let resolver = guard.as_ref()?;
56 resolver()
57}
58
59const SEND_TIMEOUT: Duration = Duration::from_secs(30);
62
63pub const SMTP_TRANSPORT_PREF_KEY: &str = "smtp-transport";
68
69#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
72pub struct TransportPrefs {
73 pub host: String,
74 #[serde(default = "default_port")]
75 pub port: u16,
76 #[serde(default)]
77 pub username: String,
78 #[serde(default)]
79 pub from: String,
80 #[serde(default = "default_starttls")]
81 pub starttls: bool,
82}
83
84fn default_port() -> u16 {
85 587
86}
87
88fn default_starttls() -> bool {
89 true
90}
91
92pub fn apply_transport_prefs_json(json: &str) {
95 match serde_json::from_str::<TransportPrefs>(json) {
96 Ok(t) => set_transport(&t.host, t.port, &t.username, &t.from, t.starttls),
97 Err(_) => set_transport("", 0, "", "", true),
98 }
99}
100
101pub fn current_transport_prefs() -> Option<TransportPrefs> {
103 let guard = TRANSPORT.read().ok()?;
104 let t = guard.as_ref()?;
105 Some(TransportPrefs {
106 host: t.host.clone(),
107 port: t.port,
108 username: t.username.clone(),
109 from: t.from.clone(),
110 starttls: t.starttls,
111 })
112}
113
114#[derive(Debug, Clone)]
117pub struct EmailTransportConfig {
118 pub host: String,
119 pub port: u16,
120 pub username: String,
121 pub password: String,
122 pub from: String,
124 pub starttls: bool,
126}
127
128#[derive(Debug, Clone)]
130pub struct Attachment {
131 pub filename: String,
132 pub content_type: String,
133 pub bytes: Vec<u8>,
134}
135
136#[derive(Debug, Clone, Default)]
138pub struct OutboundEmail {
139 pub from: Option<String>,
141 pub to: Vec<String>,
142 pub cc: Vec<String>,
143 pub bcc: Vec<String>,
144 pub reply_to: Option<String>,
145 pub subject: String,
146 pub text: Option<String>,
147 pub html: Option<String>,
148 pub in_reply_to: Option<String>,
150 pub references: Option<String>,
151 pub attachments: Vec<Attachment>,
152}
153
154#[derive(Debug)]
155pub enum EmailError {
156 NotConfigured,
158 InvalidAddress(String),
160 Build(String),
162 Transport(String),
164 Send(String),
166 Timeout,
168}
169
170impl std::fmt::Display for EmailError {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 match self {
173 Self::NotConfigured => write!(f, "email transport is not configured"),
174 Self::InvalidAddress(a) => write!(f, "invalid email address: {a}"),
175 Self::Build(e) => write!(f, "failed to build email: {e}"),
176 Self::Transport(e) => write!(f, "failed to build SMTP transport: {e}"),
177 Self::Send(e) => write!(f, "SMTP send failed: {e}"),
178 Self::Timeout => write!(f, "SMTP send timed out"),
179 }
180 }
181}
182
183impl std::error::Error for EmailError {}
184
185static TRANSPORT: RwLock<Option<StoredTransport>> = RwLock::new(None);
189
190#[derive(Debug, Clone)]
193struct StoredTransport {
194 host: String,
195 port: u16,
196 username: String,
197 from: String,
198 starttls: bool,
199}
200
201pub fn set_transport(host: &str, port: u16, username: &str, from: &str, starttls: bool) {
205 let host = host.trim();
206 if let Ok(mut guard) = TRANSPORT.write() {
207 *guard = if host.is_empty() {
208 None
209 } else {
210 Some(StoredTransport {
211 host: host.to_string(),
212 port,
213 username: username.trim().to_string(),
214 from: from.trim().to_string(),
215 starttls,
216 })
217 };
218 }
219}
220
221pub fn resolve_transport() -> Option<EmailTransportConfig> {
224 let password = resolve_password()?;
225
226 if let Ok(guard) = TRANSPORT.read() {
227 if let Some(t) = guard.as_ref() {
228 return Some(EmailTransportConfig {
229 host: t.host.clone(),
230 port: t.port,
231 username: t.username.clone(),
232 password,
233 from: t.from.clone(),
234 starttls: t.starttls,
235 });
236 }
237 }
238
239 let host = std::env::var("RYU_SMTP_HOST").ok()?;
242 let host = host.trim();
243 if host.is_empty() {
244 return None;
245 }
246 let port = std::env::var("RYU_SMTP_PORT")
247 .ok()
248 .and_then(|p| p.trim().parse::<u16>().ok())
249 .unwrap_or(587);
250 let username = std::env::var("RYU_SMTP_USERNAME").unwrap_or_default();
251 let from = std::env::var("RYU_SMTP_FROM").unwrap_or_else(|_| username.clone());
252 let starttls = std::env::var("RYU_SMTP_STARTTLS")
253 .map(|v| v != "0" && !v.eq_ignore_ascii_case("false"))
254 .unwrap_or(true);
255 Some(EmailTransportConfig {
256 host: host.to_string(),
257 port,
258 username: username.trim().to_string(),
259 password,
260 from: from.trim().to_string(),
261 starttls,
262 })
263}
264
265fn parse_mailbox(addr: &str) -> Result<Mailbox, EmailError> {
266 addr.trim()
267 .parse::<Mailbox>()
268 .map_err(|e| EmailError::InvalidAddress(format!("{addr}: {e}")))
269}
270
271fn generate_message_id(from: &str) -> String {
273 static COUNTER: AtomicU64 = AtomicU64::new(0);
274 let nanos = SystemTime::now()
275 .duration_since(UNIX_EPOCH)
276 .map(|d| d.as_nanos())
277 .unwrap_or(0);
278 let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
279 let domain = from
281 .rsplit_once('@')
282 .map(|(_, d)| d.trim_end_matches('>').trim())
283 .filter(|d| !d.is_empty())
284 .unwrap_or("ryu.local");
285 format!("<{nanos}.{seq}@{domain}>")
286}
287
288fn build_body(msg: &OutboundEmail) -> Result<MultiPartOrSingle, EmailError> {
290 let content = match (msg.text.as_ref(), msg.html.as_ref()) {
291 (Some(text), Some(html)) => {
292 MultiPartOrSingle::Multi(MultiPart::alternative_plain_html(text.clone(), html.clone()))
293 }
294 (Some(text), None) => MultiPartOrSingle::Single(SinglePart::plain(text.clone())),
295 (None, Some(html)) => MultiPartOrSingle::Single(SinglePart::html(html.clone())),
296 (None, None) => MultiPartOrSingle::Single(SinglePart::plain(String::new())),
297 };
298
299 if msg.attachments.is_empty() {
300 return Ok(content);
301 }
302
303 let mut mixed = MultiPart::mixed().multipart(match content {
305 MultiPartOrSingle::Multi(m) => m,
306 MultiPartOrSingle::Single(s) => MultiPart::mixed().singlepart(s),
307 });
308 for att in &msg.attachments {
309 let ct = ContentType::parse(&att.content_type)
310 .unwrap_or(ContentType::parse("application/octet-stream").unwrap());
311 mixed = mixed.singlepart(
312 LettreAttachment::new(att.filename.clone()).body(att.bytes.clone(), ct),
313 );
314 }
315 Ok(MultiPartOrSingle::Multi(mixed))
316}
317
318enum MultiPartOrSingle {
319 Multi(MultiPart),
320 Single(SinglePart),
321}
322
323pub async fn send_email(
327 cfg: &EmailTransportConfig,
328 msg: &OutboundEmail,
329) -> Result<String, EmailError> {
330 if msg.to.is_empty() {
331 return Err(EmailError::InvalidAddress("no recipients".to_string()));
332 }
333 let from_addr = msg.from.as_deref().unwrap_or(cfg.from.as_str());
334 let message_id = generate_message_id(from_addr);
335
336 let mut builder = Message::builder()
337 .from(parse_mailbox(from_addr)?)
338 .subject(msg.subject.clone())
339 .message_id(Some(message_id.clone()));
340
341 for to in &msg.to {
342 builder = builder.to(parse_mailbox(to)?);
343 }
344 for cc in &msg.cc {
345 builder = builder.cc(parse_mailbox(cc)?);
346 }
347 for bcc in &msg.bcc {
348 builder = builder.bcc(parse_mailbox(bcc)?);
349 }
350 if let Some(reply_to) = msg.reply_to.as_ref() {
351 builder = builder.reply_to(parse_mailbox(reply_to)?);
352 }
353 if let Some(in_reply_to) = msg.in_reply_to.as_ref() {
354 builder = builder.in_reply_to(in_reply_to.clone());
355 }
356 if let Some(references) = msg.references.as_ref() {
357 builder = builder.references(references.clone());
358 }
359
360 let body = build_body(msg)?;
361 let email = match body {
362 MultiPartOrSingle::Multi(m) => builder.multipart(m),
363 MultiPartOrSingle::Single(s) => builder.singlepart(s),
364 }
365 .map_err(|e| EmailError::Build(e.to_string()))?;
366
367 let creds = Credentials::new(cfg.username.clone(), cfg.password.clone());
368 let transport = if cfg.starttls {
369 AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&cfg.host)
370 } else {
371 AsyncSmtpTransport::<Tokio1Executor>::relay(&cfg.host)
372 }
373 .map_err(|e| EmailError::Transport(e.to_string()))?
374 .port(cfg.port)
375 .credentials(creds)
376 .build();
377
378 match tokio::time::timeout(SEND_TIMEOUT, transport.send(email)).await {
379 Err(_) => Err(EmailError::Timeout),
380 Ok(Err(e)) => Err(EmailError::Send(e.to_string())),
381 Ok(Ok(_response)) => Ok(message_id),
382 }
383}
384
385pub async fn send_email_alert(
387 cfg: &EmailTransportConfig,
388 to: &str,
389 subject: &str,
390 body: &str,
391) -> Result<String, EmailError> {
392 send_email(
393 cfg,
394 &OutboundEmail {
395 to: vec![to.to_string()],
396 subject: subject.to_string(),
397 text: Some(body.to_string()),
398 ..Default::default()
399 },
400 )
401 .await
402}