Skip to main content

pylon_plugin/builtin/
email.rs

1use crate::Plugin;
2use std::sync::Mutex;
3
4use super::net_guard::is_private_ip;
5
6/// Email delivery method.
7pub enum EmailTransport {
8    /// Log to console (dev mode).
9    Log,
10    /// Send via SMTP.
11    Smtp(SmtpConfig),
12}
13
14/// SMTP server configuration.
15pub struct SmtpConfig {
16    pub host: String,
17    pub port: u16,
18    pub username: String,
19    pub password: String,
20    pub from: String,
21}
22
23/// An email to be sent.
24pub struct EmailMessage {
25    pub to: String,
26    pub subject: String,
27    pub body: String,
28}
29
30/// Record of a sent email.
31#[derive(Debug, Clone)]
32pub struct SentEmail {
33    pub to: String,
34    pub subject: String,
35    pub timestamp: String,
36    pub success: bool,
37}
38
39/// Email transport plugin. Sends emails via SMTP or logs them in dev mode.
40pub 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
53/// Send an email via a minimal SMTP client using raw TCP.
54fn 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    // SSRF protection: block connections to private/reserved IP ranges.
62    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    // We need a mutable reference to write; use try_clone so reader owns its own handle.
76    let mut writer = stream;
77
78    let mut line = String::new();
79
80    // Helper: read one SMTP response line.
81    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 server greeting.
90    read_line(&mut reader, &mut line)?;
91
92    // EHLO
93    writer
94        .write_all(b"EHLO localhost\r\n")
95        .map_err(|e| format!("SMTP write failed: {e}"))?;
96    // Read EHLO response (may be multi-line; read until we get a line not starting with "250-").
97    loop {
98        read_line(&mut reader, &mut line)?;
99        if !line.starts_with("250-") {
100            break;
101        }
102    }
103
104    // MAIL FROM
105    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    // RCPT TO
110    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    // DATA
114    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    // Message headers + body, terminated by CRLF.CRLF
120    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    // QUIT
129    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    /// Create a new email plugin with the given transport.
138    pub fn new(transport: EmailTransport) -> Self {
139        Self {
140            transport,
141            sent: Mutex::new(Vec::new()),
142        }
143    }
144
145    /// Create a dev-mode plugin that only logs emails.
146    pub fn dev() -> Self {
147        Self::new(EmailTransport::Log)
148    }
149
150    /// Send an email via the configured transport.
151    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    /// Return the history of all emails sent through this plugin.
177    pub fn sent_history(&self) -> Vec<SentEmail> {
178        self.sent.lock().unwrap().clone()
179    }
180
181    /// Convenience: send a magic-code authentication email.
182    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    /// Convenience: send a welcome email.
194    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        // Even on failure, the attempt is recorded.
327        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}