1use crate::Plugin;
2use std::sync::Mutex;
3
4use super::net_guard::is_private_ip;
5
6pub enum EmailTransport {
8 Log,
10 Smtp(SmtpConfig),
12}
13
14pub struct SmtpConfig {
16 pub host: String,
17 pub port: u16,
18 pub username: String,
19 pub password: String,
20 pub from: String,
21}
22
23pub struct EmailMessage {
25 pub to: String,
26 pub subject: String,
27 pub body: String,
28}
29
30#[derive(Debug, Clone)]
32pub struct SentEmail {
33 pub to: String,
34 pub subject: String,
35 pub timestamp: String,
36 pub success: bool,
37}
38
39pub struct EmailPlugin {
41 transport: EmailTransport,
42 sent: Mutex<Vec<SentEmail>>,
43}
44
45fn now() -> String {
46 use std::time::{SystemTime, UNIX_EPOCH};
47 let ts = SystemTime::now()
48 .duration_since(UNIX_EPOCH)
49 .unwrap_or_default();
50 format!("{}.{:03}", ts.as_secs(), ts.subsec_millis())
51}
52
53fn smtp_send(config: &SmtpConfig, msg: &EmailMessage) -> Result<(), String> {
55 use std::io::{BufRead, BufReader, Write};
56 use std::net::TcpStream;
57 use std::time::Duration;
58
59 let addr = format!("{}:{}", config.host, config.port);
60
61 if is_private_ip(&addr) {
63 return Err("SMTP connection to private/reserved IP addresses is not allowed".into());
64 }
65
66 let stream = TcpStream::connect(&addr).map_err(|e| format!("SMTP connect failed: {e}"))?;
67 stream.set_read_timeout(Some(Duration::from_secs(10))).ok();
68 stream.set_write_timeout(Some(Duration::from_secs(10))).ok();
69
70 let mut reader = BufReader::new(
71 stream
72 .try_clone()
73 .map_err(|e| format!("Stream clone failed: {e}"))?,
74 );
75 let mut writer = stream;
77
78 let mut line = String::new();
79
80 let read_line = |reader: &mut BufReader<TcpStream>, buf: &mut String| -> Result<(), String> {
82 buf.clear();
83 reader
84 .read_line(buf)
85 .map_err(|e| format!("SMTP read failed: {e}"))?;
86 Ok(())
87 };
88
89 read_line(&mut reader, &mut line)?;
91
92 writer
94 .write_all(b"EHLO localhost\r\n")
95 .map_err(|e| format!("SMTP write failed: {e}"))?;
96 loop {
98 read_line(&mut reader, &mut line)?;
99 if !line.starts_with("250-") {
100 break;
101 }
102 }
103
104 write!(writer, "MAIL FROM:<{}>\r\n", config.from)
106 .map_err(|e| format!("SMTP write failed: {e}"))?;
107 read_line(&mut reader, &mut line)?;
108
109 write!(writer, "RCPT TO:<{}>\r\n", msg.to).map_err(|e| format!("SMTP write failed: {e}"))?;
111 read_line(&mut reader, &mut line)?;
112
113 writer
115 .write_all(b"DATA\r\n")
116 .map_err(|e| format!("SMTP write failed: {e}"))?;
117 read_line(&mut reader, &mut line)?;
118
119 write!(
121 writer,
122 "Subject: {}\r\nFrom: {}\r\nTo: {}\r\n\r\n{}\r\n.\r\n",
123 msg.subject, config.from, msg.to, msg.body
124 )
125 .map_err(|e| format!("SMTP write failed: {e}"))?;
126 read_line(&mut reader, &mut line)?;
127
128 writer
130 .write_all(b"QUIT\r\n")
131 .map_err(|e| format!("SMTP write failed: {e}"))?;
132
133 Ok(())
134}
135
136impl EmailPlugin {
137 pub fn new(transport: EmailTransport) -> Self {
139 Self {
140 transport,
141 sent: Mutex::new(Vec::new()),
142 }
143 }
144
145 pub fn dev() -> Self {
147 Self::new(EmailTransport::Log)
148 }
149
150 pub fn send(&self, msg: EmailMessage) -> Result<(), String> {
152 let result = match &self.transport {
153 EmailTransport::Log => {
154 eprintln!(
155 "[email:dev] to={} subject=\"{}\" body_len={}",
156 msg.to,
157 msg.subject,
158 msg.body.len()
159 );
160 Ok(())
161 }
162 EmailTransport::Smtp(config) => smtp_send(config, &msg),
163 };
164
165 let success = result.is_ok();
166 self.sent.lock().unwrap().push(SentEmail {
167 to: msg.to,
168 subject: msg.subject,
169 timestamp: now(),
170 success,
171 });
172
173 result
174 }
175
176 pub fn sent_history(&self) -> Vec<SentEmail> {
178 self.sent.lock().unwrap().clone()
179 }
180
181 pub fn send_magic_code(&self, email: &str, code: &str) -> Result<(), String> {
183 self.send(EmailMessage {
184 to: email.to_string(),
185 subject: "Your login code".to_string(),
186 body: format!(
187 "Your verification code is: {}\n\nThis code expires in 10 minutes.\nIf you did not request this, please ignore this email.",
188 code
189 ),
190 })
191 }
192
193 pub fn send_welcome(&self, email: &str, name: &str) -> Result<(), String> {
195 self.send(EmailMessage {
196 to: email.to_string(),
197 subject: "Welcome!".to_string(),
198 body: format!(
199 "Hi {},\n\nWelcome! Your account has been created successfully.\n\nBest regards,\nThe Team",
200 name
201 ),
202 })
203 }
204}
205
206impl Plugin for EmailPlugin {
207 fn name(&self) -> &str {
208 "email"
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn dev_mode_logs_and_records() {
218 let plugin = EmailPlugin::dev();
219 let result = plugin.send(EmailMessage {
220 to: "user@example.com".into(),
221 subject: "Test".into(),
222 body: "Hello".into(),
223 });
224 assert!(result.is_ok());
225 assert_eq!(plugin.sent_history().len(), 1);
226 assert!(plugin.sent_history()[0].success);
227 }
228
229 #[test]
230 fn send_magic_code_formats_correctly() {
231 let plugin = EmailPlugin::dev();
232 plugin
233 .send_magic_code("user@example.com", "123456")
234 .unwrap();
235
236 let history = plugin.sent_history();
237 assert_eq!(history.len(), 1);
238 assert_eq!(history[0].to, "user@example.com");
239 assert_eq!(history[0].subject, "Your login code");
240 assert!(history[0].success);
241 }
242
243 #[test]
244 fn send_welcome_formats_correctly() {
245 let plugin = EmailPlugin::dev();
246 plugin.send_welcome("user@example.com", "Alice").unwrap();
247
248 let history = plugin.sent_history();
249 assert_eq!(history.len(), 1);
250 assert_eq!(history[0].to, "user@example.com");
251 assert_eq!(history[0].subject, "Welcome!");
252 assert!(history[0].success);
253 }
254
255 #[test]
256 fn sent_history_tracks_multiple() {
257 let plugin = EmailPlugin::dev();
258 plugin
259 .send(EmailMessage {
260 to: "a@example.com".into(),
261 subject: "First".into(),
262 body: "1".into(),
263 })
264 .unwrap();
265 plugin
266 .send(EmailMessage {
267 to: "b@example.com".into(),
268 subject: "Second".into(),
269 body: "2".into(),
270 })
271 .unwrap();
272 plugin
273 .send(EmailMessage {
274 to: "c@example.com".into(),
275 subject: "Third".into(),
276 body: "3".into(),
277 })
278 .unwrap();
279
280 let history = plugin.sent_history();
281 assert_eq!(history.len(), 3);
282 assert_eq!(history[0].to, "a@example.com");
283 assert_eq!(history[1].to, "b@example.com");
284 assert_eq!(history[2].to, "c@example.com");
285 }
286
287 #[test]
288 fn multiple_sends_accumulate() {
289 let plugin = EmailPlugin::dev();
290 for i in 0..5 {
291 plugin
292 .send(EmailMessage {
293 to: format!("user{}@example.com", i),
294 subject: format!("Email {}", i),
295 body: "body".into(),
296 })
297 .unwrap();
298 }
299 assert_eq!(plugin.sent_history().len(), 5);
300 }
301
302 #[test]
303 fn plugin_name() {
304 let plugin = EmailPlugin::dev();
305 assert_eq!(plugin.name(), "email");
306 }
307
308 #[test]
309 fn smtp_transport_blocks_private_ip() {
310 let plugin = EmailPlugin::new(EmailTransport::Smtp(SmtpConfig {
311 host: "127.0.0.1".into(),
312 port: 19998,
313 username: "user".into(),
314 password: "pass".into(),
315 from: "noreply@example.com".into(),
316 }));
317
318 let result = plugin.send(EmailMessage {
319 to: "user@example.com".into(),
320 subject: "Test".into(),
321 body: "Hello".into(),
322 });
323
324 assert!(result.is_err());
325 assert!(result.unwrap_err().contains("private/reserved"));
326 let history = plugin.sent_history();
328 assert_eq!(history.len(), 1);
329 assert!(!history[0].success);
330 }
331
332 #[test]
333 fn smtp_blocks_10_network() {
334 let plugin = EmailPlugin::new(EmailTransport::Smtp(SmtpConfig {
335 host: "10.0.0.1".into(),
336 port: 25,
337 username: "user".into(),
338 password: "pass".into(),
339 from: "noreply@example.com".into(),
340 }));
341
342 let result = plugin.send(EmailMessage {
343 to: "user@example.com".into(),
344 subject: "Test".into(),
345 body: "Hello".into(),
346 });
347
348 assert!(result.is_err());
349 assert!(result.unwrap_err().contains("private/reserved"));
350 }
351
352 #[test]
353 fn smtp_blocks_metadata_endpoint() {
354 let plugin = EmailPlugin::new(EmailTransport::Smtp(SmtpConfig {
355 host: "169.254.169.254".into(),
356 port: 25,
357 username: "user".into(),
358 password: "pass".into(),
359 from: "noreply@example.com".into(),
360 }));
361
362 let result = plugin.send(EmailMessage {
363 to: "user@example.com".into(),
364 subject: "Test".into(),
365 body: "Hello".into(),
366 });
367
368 assert!(result.is_err());
369 assert!(result.unwrap_err().contains("private/reserved"));
370 }
371}