Skip to main content

ravenrs_report_server/
deliver.rs

1//! Sends a run's files: by email, or to a folder under the server's output folder.
2
3use 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
16/// How long the mail server may take.
17const SMTP_TIMEOUT: Duration = Duration::from_secs(120);
18
19/// A message to send.
20#[derive(Clone, Debug, PartialEq, Eq)]
21pub struct Mail {
22    /// The recipients.
23    pub to: Vec<String>,
24    /// The subject.
25    pub subject: String,
26    /// The text.
27    pub body: String,
28    /// The attached files.
29    pub attachments: Vec<OutputFile>,
30}
31
32/// Sends email. Blocking: it runs off the async threads.
33pub trait Mailer: Send + Sync {
34    /// Sends one message.
35    fn send(&self, mail: &Mail) -> Result<(), String>;
36}
37
38/// Sends email through an SMTP server.
39pub 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    /// Prepares the connection; credentials come from the environment.
52    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            // Chosen explicitly in the configuration, for a relay on the same machine.
59            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
95/// Writes files to `path` under `output_dir`, refusing to leave it.
96pub 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    // A symbolic link inside the output folder could still point outside it.
102    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}