1use std::sync::Arc;
16
17use chrono::Utc;
18use lettre::message::header::ContentType;
19use lettre::message::MultiPart;
20use lettre::message::SinglePart;
21use lettre::transport::smtp::authentication::Credentials;
22use lettre::AsyncTransport;
23use lettre::Message;
24use lettre::Tokio1Executor;
25use serde::{Deserialize, Serialize};
26
27use crate::config::EmailConfig;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "lowercase")]
32pub enum SmtpTls {
33 Tls,
35 StartTls,
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41#[serde(rename_all = "lowercase")]
42pub enum SmtpProvider {
43 Gmail,
45 Icloud,
47 Fastmail,
49 Resend,
52 Custom,
54}
55
56impl SmtpProvider {
57 pub fn defaults(&self) -> (&'static str, u16, SmtpTls) {
59 match self {
60 SmtpProvider::Gmail => ("smtp.gmail.com", 465, SmtpTls::Tls),
61 SmtpProvider::Icloud => ("smtp.mail.me.com", 587, SmtpTls::StartTls),
62 SmtpProvider::Fastmail => ("smtp.fastmail.com", 465, SmtpTls::Tls),
63 SmtpProvider::Resend => ("smtp.resend.com", 587, SmtpTls::StartTls),
64 SmtpProvider::Custom => ("", 0, SmtpTls::Tls),
65 }
66 }
67}
68
69type SmtpTransport = lettre::AsyncSmtpTransport<Tokio1Executor>;
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct SendReceipt {
75 pub message_id: String,
77 pub sent_at: chrono::DateTime<Utc>,
79}
80
81pub struct SmtpClient {
86 transport: Arc<SmtpTransport>,
87 from: String,
88 default_to: String,
89}
90
91impl std::fmt::Debug for SmtpClient {
92 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
93 f.debug_struct("SmtpClient")
94 .field("from", &self.from)
95 .field("default_to", &self.default_to)
96 .finish()
97 }
98}
99
100impl SmtpClient {
101 pub fn from_config(config: &EmailConfig, password: &str) -> anyhow::Result<Self> {
106 let (default_host, default_port, default_tls) = config.provider().defaults();
107
108 let host = if config.host.is_empty() {
109 default_host
110 } else {
111 &config.host
112 };
113 let port = if config.port == 0 {
114 default_port
115 } else {
116 config.port
117 };
118 let tls_mode = config.tls.unwrap_or(default_tls);
119
120 let user = match config.provider {
121 SmtpProvider::Resend => "resend".to_string(),
122 _ => {
123 if config.user.is_empty() {
124 config.my_email.clone()
125 } else {
126 config.user.clone()
127 }
128 }
129 };
130
131 anyhow::ensure!(!host.is_empty(), "SMTP host is required");
132 anyhow::ensure!(port > 0, "SMTP port is required");
133
134 let creds = Credentials::new(user.clone(), password.to_string());
135
136 let transport = match tls_mode {
137 SmtpTls::Tls => {
138 lettre::AsyncSmtpTransport::<Tokio1Executor>::relay(host)?
140 .port(port)
141 .credentials(creds)
142 .build()
143 }
144 SmtpTls::StartTls => {
145 lettre::AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(host)?
147 .port(port)
148 .credentials(creds)
149 .build()
150 }
151 };
152
153 Ok(Self {
154 transport: Arc::new(transport),
155 from: config.my_email.clone(),
156 default_to: config.my_email.clone(),
157 })
158 }
159
160 pub async fn send(
165 &self,
166 _to: &str,
167 subject: &str,
168 html: &str,
169 text: Option<&str>,
170 ) -> anyhow::Result<SendReceipt> {
171 let text_body = text
172 .map(|s| s.to_string())
173 .unwrap_or_else(|| subject.to_string());
174
175 let email = Message::builder()
176 .from(self.from.parse()?)
177 .to(self.default_to.parse()?)
178 .subject(subject)
179 .multipart(
180 MultiPart::alternative()
181 .singlepart(
182 SinglePart::builder()
183 .header(ContentType::TEXT_PLAIN)
184 .body(text_body),
185 )
186 .singlepart(
187 SinglePart::builder()
188 .header(ContentType::TEXT_HTML)
189 .body(html.to_string()),
190 ),
191 )?;
192
193 let _response = self.transport.send(email).await?;
194 let message_id = format!("<{}>", uuid::Uuid::new_v4());
195
196 Ok(SendReceipt {
197 message_id,
198 sent_at: Utc::now(),
199 })
200 }
201
202 pub async fn test_connection(&self) -> anyhow::Result<()> {
204 let email = Message::builder()
205 .from(self.from.parse()?)
206 .to(self.default_to.parse()?)
207 .subject("Oxios Email Test")
208 .body("If you see this, Oxios email is working.".to_string())?;
209
210 self.transport.send(email).await?;
211 Ok(())
212 }
213
214 pub fn from_addr(&self) -> &str {
216 &self.from
217 }
218
219 pub fn default_to(&self) -> &str {
221 &self.default_to
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_provider_defaults() {
231 let (host, port, tls) = SmtpProvider::Gmail.defaults();
232 assert_eq!(host, "smtp.gmail.com");
233 assert_eq!(port, 465);
234 assert_eq!(tls, SmtpTls::Tls);
235
236 let (host, port, tls) = SmtpProvider::Icloud.defaults();
237 assert_eq!(host, "smtp.mail.me.com");
238 assert_eq!(port, 587);
239 assert_eq!(tls, SmtpTls::StartTls);
240
241 let (host, port, tls) = SmtpProvider::Fastmail.defaults();
242 assert_eq!(host, "smtp.fastmail.com");
243 assert_eq!(port, 465);
244 assert_eq!(tls, SmtpTls::Tls);
245
246 let (host, port, tls) = SmtpProvider::Resend.defaults();
247 assert_eq!(host, "smtp.resend.com");
248 assert_eq!(port, 587);
249 assert_eq!(tls, SmtpTls::StartTls);
250
251 let (host, port, _) = SmtpProvider::Custom.defaults();
252 assert!(host.is_empty());
253 assert_eq!(port, 0);
254 }
255}