ravenrs_report_server/
deliver.rs1use std::fmt;
4use std::path::Path;
5use std::time::Duration;
6
7use lettre::message::header::ContentType;
8use lettre::message::{Attachment as MailAttachment, Mailbox, MultiPart, SinglePart};
9use lettre::transport::smtp::authentication::Credentials;
10use lettre::{Message, SmtpTransport, Transport};
11
12use crate::config::{SmtpConfig, SmtpSecurity};
13use crate::job::safe_relative_path;
14use crate::render::OutputFile;
15
16const SMTP_TIMEOUT: Duration = Duration::from_secs(120);
18
19#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct Mail {
22 pub to: Vec<String>,
24 pub subject: String,
26 pub body: String,
28 pub attachments: Vec<OutputFile>,
30}
31
32pub trait Mailer: Send + Sync {
34 fn send(&self, mail: &Mail) -> Result<(), String>;
36}
37
38pub struct SmtpMailer {
40 from: Mailbox,
41 transport: SmtpTransport,
42}
43
44impl fmt::Debug for SmtpMailer {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 f.debug_struct("SmtpMailer").field("from", &self.from).finish_non_exhaustive()
47 }
48}
49
50impl SmtpMailer {
51 pub fn new(config: &SmtpConfig) -> Result<Self, String> {
53 let from = config.from.parse().map_err(|_| format!("'{}' is not a valid sender", config.from))?;
54 let relay_error = |e: lettre::transport::smtp::Error| format!("mail server {}: {e}", config.host);
55 let mut builder = match config.security {
56 SmtpSecurity::Starttls => SmtpTransport::starttls_relay(&config.host).map_err(relay_error)?,
57 SmtpSecurity::Tls => SmtpTransport::relay(&config.host).map_err(relay_error)?,
58 SmtpSecurity::None => SmtpTransport::builder_dangerous(&config.host),
60 };
61 if let Some(port) = config.port {
62 builder = builder.port(port);
63 }
64 let from_env = |variable: &str| {
65 std::env::var(variable).map_err(|_| format!("the environment variable {variable} is not set"))
66 };
67 if let (Some(username), Some(password)) = (&config.username_env, &config.password_env) {
68 builder = builder.credentials(Credentials::new(from_env(username)?, from_env(password)?));
69 }
70 Ok(Self { from, transport: builder.timeout(Some(SMTP_TIMEOUT)).build() })
71 }
72}
73
74impl Mailer for SmtpMailer {
75 fn send(&self, mail: &Mail) -> Result<(), String> {
76 let message = build_message(&self.from, mail)?;
77 self.transport.send(&message).map(|_| ()).map_err(|e| e.to_string())
78 }
79}
80
81fn build_message(from: &Mailbox, mail: &Mail) -> Result<Message, String> {
82 let mut builder = Message::builder().from(from.clone()).subject(mail.subject.clone());
83 for address in &mail.to {
84 let mailbox: Mailbox = address.parse().map_err(|_| format!("'{address}' is not a valid address"))?;
85 builder = builder.to(mailbox);
86 }
87 let mut parts = MultiPart::mixed().singlepart(SinglePart::plain(mail.body.clone()));
88 for file in &mail.attachments {
89 let kind = ContentType::parse(file.mime).map_err(|e| e.to_string())?;
90 parts = parts.singlepart(MailAttachment::new(file.name.clone()).body(file.bytes.clone(), kind));
91 }
92 builder.multipart(parts).map_err(|e| e.to_string())
93}
94
95pub fn write_to_folder(output_dir: &Path, path: &str, files: &[OutputFile]) -> Result<(), String> {
97 let parts = safe_relative_path(path)?;
98 let mut folder = output_dir.to_path_buf();
99 folder.extend(parts);
100 std::fs::create_dir_all(&folder).map_err(|e| format!("{path}: {e}"))?;
101 let base = output_dir.canonicalize().map_err(|e| format!("output folder: {e}"))?;
103 let real = folder.canonicalize().map_err(|e| format!("{path}: {e}"))?;
104 if !real.starts_with(&base) {
105 return Err(format!("'{path}' leads outside the output folder"));
106 }
107 for file in files {
108 std::fs::write(real.join(&file.name), &file.bytes).map_err(|e| format!("{}: {e}", file.name))?;
109 }
110 Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use crate::job::Format;
117
118 fn file() -> OutputFile {
119 OutputFile {
120 format: Format::Csv,
121 name: "a.csv".to_owned(),
122 mime: "text/csv; charset=utf-8",
123 bytes: b"x".to_vec(),
124 }
125 }
126
127 #[test]
128 fn folder_deliveries_stay_inside_the_output_folder() -> Result<(), String> {
129 let dir = tempfile::tempdir().map_err(|e| e.to_string())?;
130 write_to_folder(dir.path(), "a/b", &[file()])?;
131 assert!(dir.path().join("a/b/a.csv").exists());
132 assert!(write_to_folder(dir.path(), "../x", &[file()]).is_err());
133 Ok(())
134 }
135
136 #[cfg(unix)]
137 #[test]
138 fn a_link_cannot_lead_out_of_the_output_folder() -> Result<(), String> {
139 let dir = tempfile::tempdir().map_err(|e| e.to_string())?;
140 let outside = tempfile::tempdir().map_err(|e| e.to_string())?;
141 std::os::unix::fs::symlink(outside.path(), dir.path().join("link")).map_err(|e| e.to_string())?;
142 assert!(write_to_folder(dir.path(), "link", &[file()]).is_err());
143 assert!(!outside.path().join("a.csv").exists());
144 Ok(())
145 }
146
147 #[test]
148 fn messages_carry_the_files_and_keep_headers_safe() -> Result<(), String> {
149 let from: Mailbox = "Reports <r@example.com>".parse().map_err(|_| "from")?;
150 let mail = Mail {
151 to: vec!["Ana <ana@example.com>".to_owned(), "b@example.com".to_owned()],
152 subject: "Oi\r\nBcc: evil@example.com".to_owned(),
153 body: "Segue".to_owned(),
154 attachments: vec![file()],
155 };
156 let message = build_message(&from, &mail)?;
157 let raw = String::from_utf8_lossy(&message.formatted()).into_owned();
158 assert!(raw.contains("a.csv"));
159 assert!(!raw.contains("\r\nBcc: evil"), "{raw}");
160 assert_eq!(message.envelope().to().len(), 2);
161 Ok(())
162 }
163
164 #[test]
165 fn a_bad_address_is_an_error() {
166 let from: Result<Mailbox, _> = "r@example.com".parse();
167 let Ok(from) = from else { return };
168 let mail = Mail {
169 to: vec!["nope".to_owned()],
170 subject: String::new(),
171 body: String::new(),
172 attachments: vec![],
173 };
174 assert!(build_message(&from, &mail).is_err());
175 }
176}