dead_man_switch/
email.rs

1//! Email sending capabilities of the Dead Man's Switch.
2
3use std::fs;
4use std::io::{Error as IoError, ErrorKind as IoErrorKind};
5
6use lettre::{
7    address::AddressError,
8    error::Error as LettreError,
9    message::{
10        header::{ContentType, ContentTypeErr},
11        Attachment, Mailbox, MultiPart, SinglePart,
12    },
13    transport::smtp::{
14        self,
15        authentication::Credentials,
16        client::{Tls, TlsParameters},
17    },
18    Message, SmtpTransport, Transport,
19};
20use thiserror::Error;
21
22use crate::config::{attachment_path, Config, ConfigError, Email};
23
24/// Errors that can occur when sending an email.
25#[derive(Error, Debug)]
26pub enum EmailError {
27    /// TLS error when sending the email.
28    #[error(transparent)]
29    TlsError(#[from] smtp::Error),
30
31    /// Error when parsing email addresses.
32    #[error(transparent)]
33    EmailError(#[from] AddressError),
34
35    /// Error when building the email.
36    #[error(transparent)]
37    BuilderError(#[from] LettreError),
38
39    /// Error when reading the attachment.
40    #[error(transparent)]
41    IoError(#[from] IoError),
42
43    /// Error when determining the content type of the attachment.
44    #[error(transparent)]
45    InvalidContent(#[from] ContentTypeErr),
46
47    /// Error when determining the content type of the attachment.
48    #[error(transparent)]
49    AttachmentPath(#[from] ConfigError),
50}
51
52impl Config {
53    /// Send the email using the provided configuration.
54    ///
55    /// # Errors
56    ///
57    /// - If the email fails to send.
58    /// - If the email cannot be created.
59    /// - If the attachment cannot be read.
60    ///
61    /// # Notes
62    ///
63    /// If the attachment MIME type cannot be determined, it will default to
64    /// `application/octet-stream`.
65    pub fn send_email(&self, email_type: Email) -> Result<(), EmailError> {
66        let email = self.create_email(email_type)?;
67
68        // SMTP client setup
69        let creds = Credentials::new(self.username.clone(), self.password.clone());
70        let tls = TlsParameters::new_rustls(self.smtp_server.clone())?;
71        let mailer = SmtpTransport::relay(&self.smtp_server)?
72            .port(self.smtp_port)
73            .credentials(creds)
74            .tls(Tls::Required(tls))
75            .build();
76
77        // Send the email
78        mailer.send(&email)?;
79        Ok(())
80    }
81    /// Create the email to send.
82    ///
83    /// If an attachment is provided, the email will be created with the attachment.
84    fn create_email(&self, email_type: Email) -> Result<Message, EmailError> {
85        // Guaranteed config values
86        let from = Mailbox::new(None, self.from.parse()?);
87        // Adjust the email to based on the email type
88        let to = match email_type {
89            Email::Warning => &self.from,
90            Email::DeadMan => &self.to,
91        };
92
93        // parse the comma‐separated list into a Vec<Mailbox>
94        let mailboxes: Result<Vec<Mailbox>, AddressError> = to
95            .split(',')
96            .map(str::trim)
97            .map(|addr| addr.parse::<Mailbox>())
98            .collect();
99        let mailboxes = mailboxes?;
100
101        // Adjust the email builder based on the email type
102        let mut email_builder = Message::builder().from(from);
103
104        // Add recipients
105        for mbox in mailboxes {
106            email_builder = email_builder.to(mbox);
107        }
108
109        let email_builder = match email_type {
110            Email::Warning => email_builder.subject(&self.subject_warning),
111            Email::DeadMan => email_builder.subject(&self.subject),
112        };
113
114        // Prepare the email body
115        let text_part =
116            SinglePart::builder()
117                .header(ContentType::TEXT_PLAIN)
118                .body(match email_type {
119                    Email::Warning => self.message_warning.clone(),
120                    Email::DeadMan => self.message.clone(),
121                });
122
123        // Conditionally add the attachment for DeadMan email type
124        if let Email::DeadMan = email_type {
125            if let Some(attachment) = &self.attachment {
126                let attachment_path = attachment_path(self)?;
127                let filename = attachment_path
128                    .file_name()
129                    .ok_or_else(|| IoError::new(IoErrorKind::NotFound, "Failed to get filename"))?
130                    .to_string_lossy();
131                let filebody = fs::read(attachment)?;
132                let content_type = ContentType::parse(
133                    mime_guess::from_path(attachment)
134                        .first_or_octet_stream()
135                        .as_ref(),
136                )?;
137
138                // Create the attachment part
139                let attachment_part =
140                    Attachment::new(filename.to_string()).body(filebody, content_type);
141
142                // Construct and return the email with the attachment
143                let email = email_builder.multipart(
144                    MultiPart::mixed()
145                        .singlepart(text_part)
146                        .singlepart(attachment_part),
147                )?;
148                return Ok(email);
149            }
150        }
151
152        // For Warning email type or DeadMan without an attachment
153        let email = email_builder.singlepart(text_part)?;
154        Ok(email)
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    fn get_test_config() -> Config {
163        Config {
164            username: "user@example.com".to_string(),
165            password: "password".to_string(),
166            smtp_server: "smtp.example.com".to_string(),
167            smtp_port: 587,
168            message: "This is a test message".to_string(),
169            message_warning: "This is a test warning message".to_string(),
170            subject: "Test Subject".to_string(),
171            subject_warning: "Test Warning Subject".to_string(),
172            to: "recipient@example.com, recipient2@example.com".to_string(),
173            from: "sender@example.com".to_string(),
174            attachment: None,
175            timer_warning: 60,
176            timer_dead_man: 120,
177            web_password: "password".to_string(),
178            cookie_exp_days: 7,
179            log_level: None,
180        }
181    }
182
183    #[test]
184    fn test_create_email_without_attachment() {
185        let config = get_test_config();
186        let email_result = config.create_email(Email::Warning);
187        assert!(email_result.is_ok());
188        let email_result = config.create_email(Email::DeadMan);
189        assert!(email_result.is_ok());
190    }
191
192    #[test]
193    fn test_create_email_with_attachment() {
194        let mut config = get_test_config();
195        // Assuming there's a test file at this path
196        config.attachment = Some("Cargo.toml".into());
197        let email_result = config.create_email(Email::Warning);
198        assert!(email_result.is_ok());
199        let email_result = config.create_email(Email::DeadMan);
200        assert!(email_result.is_ok());
201    }
202}