public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate
@ 2024-12-02 12:58 Shannon Sterz
  2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 2/4] notify: switch sendmail endpoint over to new crate Shannon Sterz
                   ` (4 more replies)
  0 siblings, 5 replies; 7+ messages in thread
From: Shannon Sterz @ 2024-12-02 12:58 UTC (permalink / raw)
  To: pbs-devel

add the `proxmox-sendmail` crate that makes it easier to send mails via
the `sendmail` utility. features include:

- multipart/alternative support for html+plain text mails
- multipart/mixed support for mails with attachments
- automatic nesting of multipart/alternative and multipart/mixed parts
- masks multiple receivers by default, can be disabled
- encoding Subject, To, From, and attachment file names correctly
- adding an `Auto-Submitted` header to avoid triggering automated mails

also includes several tests to ensure that mails are formatted
correctly.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---

changes since v1 (thanks @ Lukas Wagner <l.wagner@proxmox.com>):
    - make it possible to disable receiver redaction
    - re-structure the mal formatting code; mainly split it into
      multiple functions (`format_header`, `format_body`,
      `format_attachment` etc.)
    - fix multiple typos

 Cargo.toml                  |   2 +
 proxmox-sendmail/Cargo.toml |  16 +
 proxmox-sendmail/src/lib.rs | 769 ++++++++++++++++++++++++++++++++++++
 3 files changed, 787 insertions(+)
 create mode 100644 proxmox-sendmail/Cargo.toml
 create mode 100644 proxmox-sendmail/src/lib.rs

diff --git a/Cargo.toml b/Cargo.toml
index 84fbe979..b62fcd50 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -33,6 +33,7 @@ members = [
     "proxmox-rrd-api-types",
     "proxmox-schema",
     "proxmox-section-config",
+    "proxmox-sendmail",
     "proxmox-serde",
     "proxmox-shared-cache",
     "proxmox-shared-memory",
@@ -138,6 +139,7 @@ proxmox-rest-server = { version = "0.8.0", path = "proxmox-rest-server" }
 proxmox-router = { version = "3.0.0", path = "proxmox-router" }
 proxmox-schema = { version = "3.1.2", path = "proxmox-schema" }
 proxmox-section-config = { version = "2.1.0", path = "proxmox-section-config" }
+proxmox-sendmail = { version = "0.1.0", path = "proxmox-sendmail" }
 proxmox-serde = { version = "0.1.1", path = "proxmox-serde", features = [ "serde_json" ] }
 proxmox-shared-memory = { version = "0.3.0", path = "proxmox-shared-memory" }
 proxmox-sortable-macro = { version = "0.1.3", path = "proxmox-sortable-macro" }
diff --git a/proxmox-sendmail/Cargo.toml b/proxmox-sendmail/Cargo.toml
new file mode 100644
index 00000000..790b324b
--- /dev/null
+++ b/proxmox-sendmail/Cargo.toml
@@ -0,0 +1,16 @@
+[package]
+name = "proxmox-sendmail"
+version = "0.1.0"
+authors.workspace = true
+edition.workspace = true
+license.workspace = true
+repository.workspace = true
+homepage.workspace = true
+exclude.workspace = true
+rust-version.workspace = true
+
+[dependencies]
+anyhow = { workspace = true }
+base64 = { workspace = true }
+percent-encoding = { workspace = true }
+proxmox-time = { workspace = true }
diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs
new file mode 100644
index 00000000..a42bb0cd
--- /dev/null
+++ b/proxmox-sendmail/src/lib.rs
@@ -0,0 +1,769 @@
+//!
+//! This library implements the [`Mail`] trait which makes it easy to send emails with attachments
+//! and alternative html parts to one or multiple receivers via ``sendmail``.
+//!
+
+use std::io::Write;
+use std::process::{Command, Stdio};
+
+use anyhow::{bail, Context, Error};
+use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
+
+// Characters in this set will be encoded, so reproduce the inverse of the set described by RFC5987
+// Section 3.2.1 `attr-char`, as that describes all characters that **don't** need encoding:
+//
+// https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1
+//
+// `CONTROLS` contains all control characters 0x00 - 0x1f and 0x7f as well as all non-ascii
+// characters, so we need to add all characters here that aren't described in `attr-char` that are
+// in the range 0x20-0x7e
+const RFC5987SET: &AsciiSet = &CONTROLS
+    .add(b' ')
+    .add(b'"')
+    .add(b'%')
+    .add(b'&')
+    .add(b'\'')
+    .add(b'(')
+    .add(b')')
+    .add(b'*')
+    .add(b',')
+    .add(b'/')
+    .add(b':')
+    .add(b';')
+    .add(b'<')
+    .add(b'=')
+    .add(b'>')
+    .add(b'?')
+    .add(b'@')
+    .add(b'[')
+    .add(b'\\')
+    .add(b']')
+    .add(b'{')
+    .add(b'}');
+
+struct Recipient {
+    name: Option<String>,
+    email: String,
+}
+
+impl Recipient {
+    // Returns true if the name of the recipient is undefined or contains only ascii characters
+    fn is_ascii(&self) -> bool {
+        self.name.as_ref().map(|n| n.is_ascii()).unwrap_or(true)
+    }
+
+    fn format_recipient(&self) -> String {
+        if let Some(name) = &self.name {
+            if !name.is_ascii() {
+                format!("=?utf-8?B?{}?= <{}>", base64::encode(name), self.email)
+            } else {
+                format!("{} <{}>", name, self.email)
+            }
+        } else {
+            self.email.to_string()
+        }
+    }
+}
+
+struct Attachment<'a> {
+    filename: String,
+    mime: String,
+    content: &'a [u8],
+}
+
+impl<'a> Attachment<'a> {
+    fn format_attachment(&self, file_boundary: &str) -> String {
+        let mut attachment = String::new();
+
+        attachment.push_str(&format!("\n--{file_boundary}\n"));
+        attachment.push_str(&format!(
+            "Content-Type: {}; name=\"{}\"\n",
+            self.mime, self.filename
+        ));
+
+        // both `filename` and `filename*` are included for additional compatability
+        attachment.push_str(&format!(
+            "Content-Disposition: attachment; filename=\"{}\"; filename*=UTF-8''{}\n",
+            self.filename,
+            utf8_percent_encode(&self.filename, RFC5987SET)
+        ));
+        attachment.push_str("Content-Transfer-Encoding: base64\n\n");
+
+        // base64 encode the attachment and hard-wrap the base64 encoded string every 72
+        // characters. this improves compatability.
+        attachment.push_str(
+            &base64::encode(self.content)
+                .chars()
+                .enumerate()
+                .flat_map(|(i, c)| {
+                    if i != 0 && i % 72 == 0 {
+                        Some('\n')
+                    } else {
+                        None
+                    }
+                    .into_iter()
+                    .chain(std::iter::once(c))
+                })
+                .collect::<String>(),
+        );
+
+        attachment
+    }
+}
+
+/// This struct is used to define mails that are to be sent via the `sendmail` command.
+pub struct Mail<'a> {
+    mail_author: String,
+    mail_from: String,
+    subject: String,
+    to: Vec<Recipient>,
+    body_txt: String,
+    body_html: Option<String>,
+    attachments: Vec<Attachment<'a>>,
+    mask_participants: bool,
+}
+
+impl<'a> Mail<'a> {
+    /// Creates a new mail with a mail author, from address, subject line and a plain text body.
+    ///
+    /// Note: If the author's name or the subject line contains UTF-8 characters they will be
+    /// appropriately encoded.
+    pub fn new(mail_author: &str, mail_from: &str, subject: &str, body_txt: &str) -> Self {
+        Self {
+            mail_author: mail_author.to_string(),
+            mail_from: mail_from.to_string(),
+            subject: subject.to_string(),
+            to: Vec::new(),
+            body_txt: body_txt.to_string(),
+            body_html: None,
+            attachments: Vec::new(),
+            mask_participants: true,
+        }
+    }
+
+    /// Adds a recipient to the mail without specifying a name separately.
+    ///
+    /// Note: No formatting or encoding will be done here, the value will be passed to the `To:`
+    /// header directly.
+    pub fn add_recipient(&mut self, email: &str) {
+        self.to.push(Recipient {
+            name: None,
+            email: email.to_string(),
+        });
+    }
+
+    /// Builder-pattern method to conveniently add a recipient to an email without specifying a
+    /// name separately.
+    ///
+    /// Note: No formatting or encoding will be done here, the value will be passed to the `To:`
+    /// header directly.
+    pub fn with_recipient(mut self, email: &str) -> Self {
+        self.add_recipient(email);
+        self
+    }
+
+    /// Adds a recipient to the mail with a name.
+    ///
+    /// Notes:
+    ///
+    /// - If the name contains UTF-8 characters it will be encoded. Then the possibly encoded name
+    ///   and non-encoded email address will be passed to the `To:` header in this format:
+    ///   `{encoded_name} <{email}>`
+    /// - If multiple receivers are specified, they will be masked so as not to disclose them to
+    ///   other receivers. This can be disabled via [`Mail::unmask_recipients`] or
+    ///   [`Mail::with_unmasked_recipients`].
+    pub fn add_recipient_and_name(&mut self, name: &str, email: &str) {
+        self.to.push(Recipient {
+            name: Some(name.to_string()),
+            email: email.to_string(),
+        });
+    }
+
+    /// Builder-style method to conveniently add a recipient with a name to an email.
+    ///
+    /// Notes:
+    ///
+    /// - If the name contains UTF-8 characters it will be encoded. Then the possibly encoded name
+    ///   and non-encoded email address will be passed to the `To:` header in this format:
+    ///   `{encoded_name} <{email}>`
+    /// - If multiple receivers are specified, they will be masked so as not to disclose them to
+    ///   other receivers. This can be disabled via [`Mail::unmask_recipients`] or
+    ///   [`Mail::with_unmasked_recipients`].
+    pub fn with_recipient_and_name(mut self, name: &str, email: &str) -> Self {
+        self.add_recipient_and_name(name, email);
+        self
+    }
+
+    /// Adds an attachment with a specified file name and mime-type to an email.
+    ///
+    /// Note: Adding attachments triggers `multipart/mixed` mode.
+    pub fn add_attachment(&mut self, filename: &str, mime_type: &str, content: &'a [u8]) {
+        self.attachments.push(Attachment {
+            filename: filename.to_string(),
+            mime: mime_type.to_string(),
+            content,
+        });
+    }
+
+    /// Builder-style method to conveniently add an attachment with a specific filename and
+    /// mime-type to an email.
+    ///
+    /// Note: Adding attachements triggers `multipart/mixed` mode.
+    pub fn with_attachment(mut self, filename: &str, mime_type: &str, content: &'a [u8]) -> Self {
+        self.add_attachment(filename, mime_type, content);
+        self
+    }
+
+    /// Set an alternative HTML part.
+    ///
+    /// Note: This triggers `multipart/alternative` mode. If both an HTML part and at least one
+    /// attachment are specified, the `multipart/alternative` part will be nested within the first
+    /// `multipart/mixed` part. This should ensure that the HTML is displayed properly by client's
+    /// that prioritize it over the plain text part (should be the default for most clients) while
+    /// also properly displaying the attachments.
+    pub fn set_html_alt(&mut self, body_html: &str) {
+        self.body_html.replace(body_html.to_string());
+    }
+
+    /// Builder-style method to add an alternative HTML part.
+    ///
+    /// Note: This triggers `multipart/alternative` mode. If both an HTML part and at least one
+    /// attachment are specified, the `multipart/alternative` part will be nested within the first
+    /// `multipart/mixed` part. This should ensure that the HTML is displayed properly by client's
+    /// that prioritize it over the plain text part (should be the default for most clients) while
+    /// also properly displaying the attachments.
+    pub fn with_html_alt(mut self, body_html: &str) -> Self {
+        self.set_html_alt(body_html);
+        self
+    }
+
+    /// This function ensures that recipients of the mail are not masked. Being able to see all
+    /// recipients of a mail can be helpful in, for example, notification scenarios.
+    pub fn unmask_recipients(&mut self) {
+        self.mask_participants = false;
+    }
+
+    /// Builder-style function that ensures that recipients of the mail are not masked. Being able
+    /// to see all recipients of a mail can be helpful in, for example, notification scenarios.
+    pub fn with_unmasked_recipients(mut self) -> Self {
+        self.unmask_recipients();
+        self
+    }
+
+    /// Sends the email. This will fail if no recipients have been added.
+    ///
+    /// Note: An `Auto-Submitted: auto-generated` header is added to avoid triggering OOO and
+    /// similar mails.
+    pub fn send(&self) -> Result<(), Error> {
+        if self.to.is_empty() {
+            bail!("no recipients provided for the mail, cannot send it.");
+        }
+
+        let now = proxmox_time::epoch_i64();
+        let body = self.format_mail(now)?;
+
+        let mut sendmail_process = Command::new("/usr/sbin/sendmail")
+            .arg("-B")
+            .arg("8BITMIME")
+            .arg("-f")
+            .arg(&self.mail_from)
+            .arg("--")
+            .args(self.to.iter().map(|p| &p.email).collect::<Vec<&String>>())
+            .stdin(Stdio::piped())
+            .spawn()
+            .with_context(|| "could not spawn sendmail process")?;
+
+        sendmail_process
+            .stdin
+            .as_ref()
+            .unwrap()
+            .write_all(body.as_bytes())
+            .with_context(|| "couldn't write to sendmail stdin")?;
+
+        sendmail_process
+            .wait()
+            .with_context(|| "sendmail did not exit successfully")?;
+
+        Ok(())
+    }
+
+    fn format_mail(&self, now: i64) -> Result<String, Error> {
+        let file_boundary = format!("----_=_NextPart_001_{}", now);
+        let html_boundary = format!("----_=_NextPart_002_{}", now);
+
+        let mut mail = self.format_header(now, &file_boundary, &html_boundary)?;
+
+        mail.push_str(&self.format_body(&file_boundary, &html_boundary)?);
+
+        if !self.attachments.is_empty() {
+            mail.push_str(
+                &self
+                    .attachments
+                    .iter()
+                    .map(|a| a.format_attachment(&file_boundary))
+                    .collect::<String>(),
+            );
+            mail.push_str(&format!("\n--{file_boundary}--"));
+        }
+
+        Ok(mail)
+    }
+
+    fn format_header(
+        &self,
+        now: i64,
+        file_boundary: &str,
+        html_boundary: &str,
+    ) -> Result<String, Error> {
+        let mut header = String::new();
+
+        let encoded_to = if self.to.len() > 1 && self.mask_participants {
+            // if the receivers are masked, we know that they don't need to be encoded
+            false
+        } else {
+            // check if there is a recipient that needs encoding
+            self.to.iter().any(|r| !r.is_ascii())
+        };
+
+        if !self.attachments.is_empty() {
+            header.push_str("Content-Type: multipart/mixed;\n");
+            header.push_str(&format!("\tboundary=\"{file_boundary}\"\n"));
+            header.push_str("MIME-Version: 1.0\n");
+        } else if self.body_html.is_some() {
+            header.push_str("Content-Type: multipart/alternative;\n");
+            header.push_str(&format!("\tboundary=\"{html_boundary}\"\n"));
+            header.push_str("MIME-Version: 1.0\n");
+        } else if !self.subject.is_ascii() || !self.mail_author.is_ascii() || encoded_to {
+            header.push_str("MIME-Version: 1.0\n");
+        }
+
+        let subject = if !self.subject.is_ascii() {
+            format!("Subject: =?utf-8?B?{}?=\n", base64::encode(&self.subject))
+        } else {
+            format!("Subject: {}\n", self.subject)
+        };
+
+        header.push_str(&subject);
+
+        let from = if !self.mail_author.is_ascii() {
+            format!(
+                "From: =?utf-8?B?{}?= <{}>\n",
+                base64::encode(&self.mail_author),
+                self.mail_from
+            )
+        } else {
+            format!("From: {} <{}>\n", self.mail_author, self.mail_from)
+        };
+
+        header.push_str(&from);
+
+        let to = if self.to.len() > 1 && self.mask_participants {
+            // don't disclose all recipients if the mail goes out to multiple
+            let recipient = Recipient {
+                name: Some("Undisclosed".to_string()),
+                email: "noreply".to_string(),
+            };
+
+            recipient.format_recipient()
+        } else {
+            self.to
+                .iter()
+                .map(Recipient::format_recipient)
+                .collect::<Vec<String>>()
+                .join(", ")
+        };
+
+        header.push_str("To: ");
+        header.push_str(&to);
+        header.push('\n');
+
+        let rfc2822_date = proxmox_time::epoch_to_rfc2822(now)
+            .with_context(|| "could not convert epoch to rfc2822 date")?;
+        header.push_str(&format!("Date: {rfc2822_date}\n"));
+        header.push_str("Auto-Submitted: auto-generated;\n");
+
+        Ok(header)
+    }
+
+    fn format_body(&self, file_boundary: &str, html_boundary: &str) -> Result<String, Error> {
+        let mut body = String::new();
+
+        if self.body_html.is_some() && !self.attachments.is_empty() {
+            body.push_str("\nThis is a multi-part message in MIME format.\n");
+            body.push_str(&format!("\n--{file_boundary}\n"));
+            body.push_str(&format!(
+                "Content-Type: multipart/alternative; boundary=\"{html_boundary}\"\n"
+            ));
+            body.push_str("MIME-Version: 1.0\n");
+            body.push_str(&format!("\n--{html_boundary}\n"));
+        } else if self.body_html.is_some() {
+            body.push_str("\nThis is a multi-part message in MIME format.\n");
+            body.push_str(&format!("\n--{html_boundary}\n"));
+        } else if self.body_html.is_none() && !self.attachments.is_empty() {
+            body.push_str("\nThis is a multi-part message in MIME format.\n");
+            body.push_str(&format!("\n--{file_boundary}\n"));
+        }
+
+        body.push_str("Content-Type: text/plain;\n");
+        body.push_str("\tcharset=\"UTF-8\"\n");
+        body.push_str("Content-Transfer-Encoding: 8bit\n\n");
+        body.push_str(&self.body_txt);
+
+        if let Some(html) = &self.body_html {
+            body.push_str(&format!("\n--{html_boundary}\n"));
+            body.push_str("Content-Type: text/html;\n");
+            body.push_str("\tcharset=\"UTF-8\"\n");
+            body.push_str("Content-Transfer-Encoding: 8bit\n\n");
+            body.push_str(html);
+            body.push_str(&format!("\n--{html_boundary}--"));
+        }
+
+        Ok(body)
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn email_without_recipients_fails() {
+        let result = Mail::new("Sender", "mail@example.com", "hi", "body").send();
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn simple_ascii_text_mail() {
+        let mail = Mail::new(
+            "Sender Name",
+            "mailfrom@example.com",
+            "Subject Line",
+            "This is just ascii text.\nNothing too special.",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com");
+
+        let body = mail.format_mail(0).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"Subject: Subject Line
+From: Sender Name <mailfrom@example.com>
+To: Receiver Name <receiver@example.com>
+Date: Thu, 01 Jan 1970 01:00:00 +0100
+Auto-Submitted: auto-generated;
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This is just ascii text.
+Nothing too special."#
+        )
+    }
+
+    #[test]
+    fn multiple_receiver_masked() {
+        let mail = Mail::new(
+            "Sender Name",
+            "mailfrom@example.com",
+            "Subject Line",
+            "This is just ascii text.\nNothing too special.",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com")
+        .with_recipient("two@example.com")
+        .with_recipient_and_name("mäx müstermänn", "mm@example.com");
+
+        let body = mail.format_mail(0).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"Subject: Subject Line
+From: Sender Name <mailfrom@example.com>
+To: Undisclosed <noreply>
+Date: Thu, 01 Jan 1970 01:00:00 +0100
+Auto-Submitted: auto-generated;
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This is just ascii text.
+Nothing too special."#
+        )
+    }
+
+    #[test]
+    fn multiple_receiver_unmasked() {
+        let mail = Mail::new(
+            "Sender Name",
+            "mailfrom@example.com",
+            "Subject Line",
+            "This is just ascii text.\nNothing too special.",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com")
+        .with_recipient("two@example.com")
+        .with_recipient_and_name("mäx müstermänn", "mm@example.com")
+        .with_unmasked_recipients();
+
+        let body = mail.format_mail(0).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"MIME-Version: 1.0
+Subject: Subject Line
+From: Sender Name <mailfrom@example.com>
+To: Receiver Name <receiver@example.com>, two@example.com, =?utf-8?B?bcOkeCBtw7xzdGVybcOkbm4=?= <mm@example.com>
+Date: Thu, 01 Jan 1970 01:00:00 +0100
+Auto-Submitted: auto-generated;
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This is just ascii text.
+Nothing too special."#
+        )
+    }
+
+    #[test]
+    fn simple_utf8_text_mail() {
+        let mail = Mail::new(
+            "UTF-8 Sender Name 📧",
+            "differentfrom@example.com",
+            "Subject Line 🧑",
+            "This utf-8 email should handle emojis\n🧑📧\nand weird german characters: öäüß\nand more.",
+        )
+        .with_recipient_and_name("Receiver Name📩", "receiver@example.com");
+
+        let body = mail.format_mail(1732806251).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"MIME-Version: 1.0
+Subject: =?utf-8?B?U3ViamVjdCBMaW5lIPCfp5E=?=
+From: =?utf-8?B?VVRGLTggU2VuZGVyIE5hbWUg8J+Tpw==?= <differentfrom@example.com>
+To: =?utf-8?B?UmVjZWl2ZXIgTmFtZfCfk6k=?= <receiver@example.com>
+Date: Thu, 28 Nov 2024 16:04:11 +0100
+Auto-Submitted: auto-generated;
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This utf-8 email should handle emojis
+🧑📧
+and weird german characters: öäüß
+and more."#
+        )
+    }
+
+    #[test]
+    fn multipart_html_alternative() {
+        let mail = Mail::new(
+            "Sender Name",
+            "from@example.com",
+            "Subject Line",
+            "Lorem Ipsum Dolor Sit\nAmet",
+        )
+        .with_recipient("receiver@example.com")
+        .with_html_alt("<html lang=\"de-at\"><head></head><body>\n\t<pre>\n\t\tLorem Ipsum Dolor Sit Amet\n\t</pre>\n</body></html>");
+        let body = mail.format_mail(1732806251).expect("could not format mail");
+        assert_eq!(
+            body,
+            r#"Content-Type: multipart/alternative;
+	boundary="----_=_NextPart_002_1732806251"
+MIME-Version: 1.0
+Subject: Subject Line
+From: Sender Name <from@example.com>
+To: receiver@example.com
+Date: Thu, 28 Nov 2024 16:04:11 +0100
+Auto-Submitted: auto-generated;
+
+This is a multi-part message in MIME format.
+
+------_=_NextPart_002_1732806251
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+Lorem Ipsum Dolor Sit
+Amet
+------_=_NextPart_002_1732806251
+Content-Type: text/html;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+<html lang="de-at"><head></head><body>
+	<pre>
+		Lorem Ipsum Dolor Sit Amet
+	</pre>
+</body></html>
+------_=_NextPart_002_1732806251--"#
+        )
+    }
+
+    #[test]
+    fn multipart_plain_text_attachments_mixed() {
+        let bin: [u8; 62] = [
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
+        ];
+
+        let mail = Mail::new(
+            "Sender Name",
+            "from@example.com",
+            "Subject Line",
+            "Lorem Ipsum Dolor Sit\nAmet",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com")
+        .with_attachment("deadbeef.bin", "application/octet-stream", &bin);
+
+        let body = mail.format_mail(1732806251).expect("could not format mail");
+        assert_eq!(
+            body,
+            r#"Content-Type: multipart/mixed;
+	boundary="----_=_NextPart_001_1732806251"
+MIME-Version: 1.0
+Subject: Subject Line
+From: Sender Name <from@example.com>
+To: Receiver Name <receiver@example.com>
+Date: Thu, 28 Nov 2024 16:04:11 +0100
+Auto-Submitted: auto-generated;
+
+This is a multi-part message in MIME format.
+
+------_=_NextPart_001_1732806251
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+Lorem Ipsum Dolor Sit
+Amet
+------_=_NextPart_001_1732806251
+Content-Type: application/octet-stream; name="deadbeef.bin"
+Content-Disposition: attachment; filename="deadbeef.bin"; filename*=UTF-8''deadbeef.bin
+Content-Transfer-Encoding: base64
+
+3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
+3q2+796tvu8=
+------_=_NextPart_001_1732806251--"#
+        )
+    }
+
+    #[test]
+    fn multipart_plain_text_html_alternative_attachments() {
+        let bin: [u8; 62] = [
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
+        ];
+
+        let mail = Mail::new(
+            "Sender Name",
+            "from@example.com",
+            "Subject Line",
+            "Lorem Ipsum Dolor Sit\nAmet",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com")
+        .with_attachment("deadbeef.bin", "application/octet-stream", &bin)
+        .with_attachment("🐄💀.bin", "image/bmp", &bin)
+        .with_html_alt("<html lang=\"de-at\"><head></head><body>\n\t<pre>\n\t\tLorem Ipsum Dolor Sit Amet\n\t</pre>\n</body></html>");
+
+        let body = mail.format_mail(1732806251).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"Content-Type: multipart/mixed;
+	boundary="----_=_NextPart_001_1732806251"
+MIME-Version: 1.0
+Subject: Subject Line
+From: Sender Name <from@example.com>
+To: Receiver Name <receiver@example.com>
+Date: Thu, 28 Nov 2024 16:04:11 +0100
+Auto-Submitted: auto-generated;
+
+This is a multi-part message in MIME format.
+
+------_=_NextPart_001_1732806251
+Content-Type: multipart/alternative; boundary="----_=_NextPart_002_1732806251"
+MIME-Version: 1.0
+
+------_=_NextPart_002_1732806251
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+Lorem Ipsum Dolor Sit
+Amet
+------_=_NextPart_002_1732806251
+Content-Type: text/html;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+<html lang="de-at"><head></head><body>
+	<pre>
+		Lorem Ipsum Dolor Sit Amet
+	</pre>
+</body></html>
+------_=_NextPart_002_1732806251--
+------_=_NextPart_001_1732806251
+Content-Type: application/octet-stream; name="deadbeef.bin"
+Content-Disposition: attachment; filename="deadbeef.bin"; filename*=UTF-8''deadbeef.bin
+Content-Transfer-Encoding: base64
+
+3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
+3q2+796tvu8=
+------_=_NextPart_001_1732806251
+Content-Type: image/bmp; name="🐄💀.bin"
+Content-Disposition: attachment; filename="🐄💀.bin"; filename*=UTF-8''%F0%9F%90%84%F0%9F%92%80.bin
+Content-Transfer-Encoding: base64
+
+3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
+3q2+796tvu8=
+------_=_NextPart_001_1732806251--"#
+        )
+    }
+
+    #[test]
+    fn test_format_mail_multipart() {
+        let mail = Mail::new(
+            "Fred Oobar",
+            "foobar@example.com",
+            "This is the subject",
+            "This is the plain body",
+        )
+        .with_recipient_and_name("Tony Est", "test@example.com")
+        .with_html_alt("<body>This is the HTML body</body>");
+
+        let body = mail.format_mail(1718977850).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"Content-Type: multipart/alternative;
+	boundary="----_=_NextPart_002_1718977850"
+MIME-Version: 1.0
+Subject: This is the subject
+From: Fred Oobar <foobar@example.com>
+To: Tony Est <test@example.com>
+Date: Fri, 21 Jun 2024 15:50:50 +0200
+Auto-Submitted: auto-generated;
+
+This is a multi-part message in MIME format.
+
+------_=_NextPart_002_1718977850
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This is the plain body
+------_=_NextPart_002_1718977850
+Content-Type: text/html;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+<body>This is the HTML body</body>
+------_=_NextPart_002_1718977850--"#
+        );
+    }
+}
--
2.39.5



_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel

^ permalink raw reply	[flat|nested] 7+ messages in thread

* [pbs-devel] [PATCH proxmox v2 2/4] notify: switch sendmail endpoint over to new crate
  2024-12-02 12:58 [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Shannon Sterz
@ 2024-12-02 12:58 ` Shannon Sterz
  2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 3/4] sendmail: add mail-forwarder feature Shannon Sterz
                   ` (3 subsequent siblings)
  4 siblings, 0 replies; 7+ messages in thread
From: Shannon Sterz @ 2024-12-02 12:58 UTC (permalink / raw)
  To: pbs-devel

use the new `proxmox-sendmail` crate instead of the bespoke
implementation in `proxmox-notify`.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 proxmox-notify/Cargo.toml                |   3 +-
 proxmox-notify/src/endpoints/sendmail.rs | 172 ++---------------------
 2 files changed, 10 insertions(+), 165 deletions(-)

diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index b5b3719e..6e94930a 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -32,6 +32,7 @@ proxmox-human-byte.workspace = true
 proxmox-schema = { workspace = true, features = ["api-macro", "api-types"] }
 proxmox-section-config = { workspace = true }
 proxmox-serde.workspace = true
+proxmox-sendmail = { workspace = true, optional = true }
 proxmox-sys = { workspace = true, optional = true }
 proxmox-time.workspace = true
 proxmox-uuid = { workspace = true, features = ["serde"] }
@@ -39,7 +40,7 @@ proxmox-uuid = { workspace = true, features = ["serde"] }
 [features]
 default = ["sendmail", "gotify", "smtp", "webhook"]
 mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys"]
-sendmail = ["dep:proxmox-sys", "dep:base64"]
+sendmail = ["dep:proxmox-sys", "dep:base64", "dep:proxmox-sendmail"]
 gotify = ["dep:proxmox-http"]
 pve-context = ["dep:proxmox-sys"]
 pbs-context = ["dep:proxmox-sys"]
diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs
index d31b9672..c5e16fe4 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -137,15 +137,14 @@ impl Endpoint for SendmailEndpoint {
                     .clone()
                     .unwrap_or_else(|| context().default_sendmail_author());
 
-                sendmail(
-                    &recipients_str,
-                    &subject,
-                    &text_part,
-                    &html_part,
-                    &mailfrom,
-                    &author,
-                )
-                .map_err(|err| Error::NotifyFailed(self.config.name.clone(), err.into()))
+                let mut mail = Mail::new(&author, &mailfrom, &subject, &text_part)
+                    .with_html_alt(&html_part)
+                    .with_unmasked_recipients();
+
+                recipients_str.iter().for_each(|r| mail.add_recipient(r));
+
+                mail.send()
+                    .map_err(|err| Error::NotifyFailed(self.config.name.clone(), err.into()))
             }
             #[cfg(feature = "mail-forwarder")]
             Content::ForwardedMail { raw, uid, .. } => {
@@ -165,107 +164,6 @@ impl Endpoint for SendmailEndpoint {
     }
 }
 
-/// Sends multi-part mail with text and/or html to a list of recipients
-///
-/// Includes the header `Auto-Submitted: auto-generated`, so that auto-replies
-/// (i.e. OOO replies) won't trigger.
-/// ``sendmail`` is used for sending the mail.
-fn sendmail(
-    mailto: &[&str],
-    subject: &str,
-    text: &str,
-    html: &str,
-    mailfrom: &str,
-    author: &str,
-) -> Result<(), Error> {
-    if mailto.is_empty() {
-        return Err(Error::Generic(
-            "At least one recipient has to be specified!".into(),
-        ));
-    }
-    let now = proxmox_time::epoch_i64();
-    let body = format_mail(mailto, mailfrom, author, subject, text, html, now)?;
-
-    let mut sendmail_process = Command::new("/usr/sbin/sendmail")
-        .arg("-B")
-        .arg("8BITMIME")
-        .arg("-f")
-        .arg(mailfrom)
-        .arg("--")
-        .args(mailto)
-        .stdin(Stdio::piped())
-        .spawn()
-        .map_err(|err| Error::Generic(format!("could not spawn sendmail process: {err}")))?;
-
-    sendmail_process
-        .stdin
-        .take()
-        .expect("stdin already taken")
-        .write_all(body.as_bytes())
-        .map_err(|err| Error::Generic(format!("couldn't write to sendmail stdin: {err}")))?;
-
-    sendmail_process
-        .wait()
-        .map_err(|err| Error::Generic(format!("sendmail did not exit successfully: {err}")))?;
-
-    Ok(())
-}
-
-fn format_mail(
-    mailto: &[&str],
-    mailfrom: &str,
-    author: &str,
-    subject: &str,
-    text: &str,
-    html: &str,
-    timestamp: i64,
-) -> Result<String, Error> {
-    use std::fmt::Write as _;
-
-    let recipients = mailto.join(",");
-    let boundary = format!("----_=_NextPart_001_{timestamp}");
-
-    let mut body = String::new();
-
-    // Format email header
-    body.push_str("Content-Type: multipart/alternative;\n");
-    let _ = writeln!(body, "\tboundary=\"{boundary}\"");
-    body.push_str("MIME-Version: 1.0\n");
-
-    if !subject.is_ascii() {
-        let _ = writeln!(body, "Subject: =?utf-8?B?{}?=", base64::encode(subject));
-    } else {
-        let _ = writeln!(body, "Subject: {subject}");
-    }
-    let _ = writeln!(body, "From: {author} <{mailfrom}>");
-    let _ = writeln!(body, "To: {recipients}");
-    let rfc2822_date = proxmox_time::epoch_to_rfc2822(timestamp)
-        .map_err(|err| Error::Generic(format!("failed to format time: {err}")))?;
-    let _ = writeln!(body, "Date: {rfc2822_date}");
-    body.push_str("Auto-Submitted: auto-generated;\n");
-    body.push('\n');
-
-    // Format email body
-    body.push_str("This is a multi-part message in MIME format.\n");
-    let _ = write!(body, "\n--{boundary}\n");
-
-    body.push_str("Content-Type: text/plain;\n");
-    body.push_str("\tcharset=\"UTF-8\"\n");
-    body.push_str("Content-Transfer-Encoding: 8bit\n");
-    body.push('\n');
-    body.push_str(text);
-    let _ = write!(body, "\n--{boundary}\n");
-
-    body.push_str("Content-Type: text/html;\n");
-    body.push_str("\tcharset=\"UTF-8\"\n");
-    body.push_str("Content-Transfer-Encoding: 8bit\n");
-    body.push('\n');
-    body.push_str(html);
-    let _ = write!(body, "\n--{boundary}--");
-
-    Ok(body)
-}
-
 /// Forwards an email message to a given list of recipients.
 ///
 /// ``sendmail`` is used for sending the mail, thus `message` must be
@@ -313,57 +211,3 @@ fn forward(mailto: &[&str], mailfrom: &str, message: &[u8], uid: Option<u32>) ->
 
     Ok(())
 }
-
-#[cfg(test)]
-mod test {
-    use super::*;
-
-    #[test]
-    fn email_without_recipients() {
-        let result = sendmail(&[], "Subject2", "", "<b>HTML</b>", "root", "Proxmox");
-        assert!(result.is_err());
-    }
-
-    #[test]
-    fn test_format_mail_multipart() {
-        let message = format_mail(
-            &["Tony Est <test@example.com>"],
-            "foobar@example.com",
-            "Fred Oobar",
-            "This is the subject",
-            "This is the plain body",
-            "<body>This is the HTML body</body>",
-            1718977850,
-        )
-        .expect("format_message failed");
-
-        assert_eq!(
-            message,
-            r#"Content-Type: multipart/alternative;
-	boundary="----_=_NextPart_001_1718977850"
-MIME-Version: 1.0
-Subject: This is the subject
-From: Fred Oobar <foobar@example.com>
-To: Tony Est <test@example.com>
-Date: Fri, 21 Jun 2024 15:50:50 +0200
-Auto-Submitted: auto-generated;
-
-This is a multi-part message in MIME format.
-
-------_=_NextPart_001_1718977850
-Content-Type: text/plain;
-	charset="UTF-8"
-Content-Transfer-Encoding: 8bit
-
-This is the plain body
-------_=_NextPart_001_1718977850
-Content-Type: text/html;
-	charset="UTF-8"
-Content-Transfer-Encoding: 8bit
-
-<body>This is the HTML body</body>
-------_=_NextPart_001_1718977850--"#
-                .to_owned()
-        );
-    }
-}
-- 
2.39.5



_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel


^ permalink raw reply	[flat|nested] 7+ messages in thread

* [pbs-devel] [PATCH proxmox v2 3/4] sendmail: add mail-forwarder feature
  2024-12-02 12:58 [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Shannon Sterz
  2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 2/4] notify: switch sendmail endpoint over to new crate Shannon Sterz
@ 2024-12-02 12:58 ` Shannon Sterz
  2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 4/4] notify: use proxmox-sendmail forward implementation Shannon Sterz
                   ` (2 subsequent siblings)
  4 siblings, 0 replies; 7+ messages in thread
From: Shannon Sterz @ 2024-12-02 12:58 UTC (permalink / raw)
  To: pbs-devel

this moves the mail forwarding implementation from `proxmox-notify` into
`proxmox-sendmail` to cover more `sendmail` related use-cases in one
place.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 proxmox-sendmail/Cargo.toml |  4 +++
 proxmox-sendmail/src/lib.rs | 57 +++++++++++++++++++++++++++++++++++++
 2 files changed, 61 insertions(+)

diff --git a/proxmox-sendmail/Cargo.toml b/proxmox-sendmail/Cargo.toml
index 790b324b..e04e2595 100644
--- a/proxmox-sendmail/Cargo.toml
+++ b/proxmox-sendmail/Cargo.toml
@@ -14,3 +14,7 @@ anyhow = { workspace = true }
 base64 = { workspace = true }
 percent-encoding = { workspace = true }
 proxmox-time = { workspace = true }
+
+[features]
+default = []
+mail-forwarder = []
diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs
index a42bb0cd..e1ddc63d 100644
--- a/proxmox-sendmail/src/lib.rs
+++ b/proxmox-sendmail/src/lib.rs
@@ -287,6 +287,56 @@ impl<'a> Mail<'a> {
         Ok(())
     }
 
+    /// Forwards an email message to a given list of recipients.
+    ///
+    /// `message` must be compatible with ``sendmail`` (the message is piped into stdin unmodified).
+    #[cfg(feature = "mail-forwarder")]
+    pub fn forward(
+        mailto: &[&str],
+        mailfrom: &str,
+        message: &[u8],
+        uid: Option<u32>,
+    ) -> Result<(), Error> {
+        use std::os::unix::process::CommandExt;
+
+        if mailto.is_empty() {
+            bail!("At least one recipient has to be specified!");
+        }
+
+        let mut builder = Command::new("/usr/sbin/sendmail");
+
+        builder
+            .args([
+                "-N", "never", // never send DSN (avoid mail loops)
+                "-f", mailfrom, "--",
+            ])
+            .args(mailto)
+            .stdin(Stdio::piped())
+            .stdout(Stdio::null())
+            .stderr(Stdio::null());
+
+        if let Some(uid) = uid {
+            builder.uid(uid);
+        }
+
+        let mut sendmail_process = builder
+            .spawn()
+            .with_context(|| "could not spawn sendmail process")?;
+
+        sendmail_process
+            .stdin
+            .take()
+            .unwrap()
+            .write_all(message)
+            .with_context(|| "couldn't write to sendmail stdin")?;
+
+        sendmail_process
+            .wait()
+            .with_context(|| "sendmail did not exit successfully")?;
+
+        Ok(())
+    }
+
     fn format_mail(&self, now: i64) -> Result<String, Error> {
         let file_boundary = format!("----_=_NextPart_001_{}", now);
         let html_boundary = format!("----_=_NextPart_002_{}", now);
@@ -432,6 +482,13 @@ mod test {
         assert!(result.is_err());
     }
 
+    #[test]
+    #[cfg(feature = "mail-forwarder")]
+    fn forwarding_without_recipients_fails() {
+        let result = Mail::forward(&[], "me@example.com", String::from("text").as_bytes(), None);
+        assert!(result.is_err());
+    }
+
     #[test]
     fn simple_ascii_text_mail() {
         let mail = Mail::new(
-- 
2.39.5



_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel


^ permalink raw reply	[flat|nested] 7+ messages in thread

* [pbs-devel] [PATCH proxmox v2 4/4] notify: use proxmox-sendmail forward implementation
  2024-12-02 12:58 [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Shannon Sterz
  2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 2/4] notify: switch sendmail endpoint over to new crate Shannon Sterz
  2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 3/4] sendmail: add mail-forwarder feature Shannon Sterz
@ 2024-12-02 12:58 ` Shannon Sterz
  2024-12-02 13:30 ` [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Lukas Wagner
  2024-12-02 14:18 ` Shannon Sterz
  4 siblings, 0 replies; 7+ messages in thread
From: Shannon Sterz @ 2024-12-02 12:58 UTC (permalink / raw)
  To: pbs-devel

moves to depending on `proxmox-sendmail` for forwarding mails via
`sendmail` too.

Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
---
 proxmox-notify/Cargo.toml                |  2 +-
 proxmox-notify/src/endpoints/sendmail.rs | 54 +-----------------------
 2 files changed, 3 insertions(+), 53 deletions(-)

diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml
index 6e94930a..725bd210 100644
--- a/proxmox-notify/Cargo.toml
+++ b/proxmox-notify/Cargo.toml
@@ -39,7 +39,7 @@ proxmox-uuid = { workspace = true, features = ["serde"] }
 
 [features]
 default = ["sendmail", "gotify", "smtp", "webhook"]
-mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys"]
+mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys", "proxmox-sendmail/mail-forwarder"]
 sendmail = ["dep:proxmox-sys", "dep:base64", "dep:proxmox-sendmail"]
 gotify = ["dep:proxmox-http"]
 pve-context = ["dep:proxmox-sys"]
diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs
index c5e16fe4..70b0f111 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -1,6 +1,4 @@
-use std::io::Write;
-use std::process::{Command, Stdio};
-
+use proxmox_sendmail::Mail;
 use serde::{Deserialize, Serialize};
 
 use proxmox_schema::api_types::COMMENT_SCHEMA;
@@ -148,7 +146,7 @@ impl Endpoint for SendmailEndpoint {
             }
             #[cfg(feature = "mail-forwarder")]
             Content::ForwardedMail { raw, uid, .. } => {
-                forward(&recipients_str, &mailfrom, raw, *uid)
+                Mail::forward(&recipients_str, &mailfrom, raw, *uid)
                     .map_err(|err| Error::NotifyFailed(self.config.name.clone(), err.into()))
             }
         }
@@ -163,51 +161,3 @@ impl Endpoint for SendmailEndpoint {
         self.config.disable.unwrap_or_default()
     }
 }
-
-/// Forwards an email message to a given list of recipients.
-///
-/// ``sendmail`` is used for sending the mail, thus `message` must be
-/// compatible with that (the message is piped into stdin unmodified).
-#[cfg(feature = "mail-forwarder")]
-fn forward(mailto: &[&str], mailfrom: &str, message: &[u8], uid: Option<u32>) -> Result<(), Error> {
-    use std::os::unix::process::CommandExt;
-
-    if mailto.is_empty() {
-        return Err(Error::Generic(
-            "At least one recipient has to be specified!".into(),
-        ));
-    }
-
-    let mut builder = Command::new("/usr/sbin/sendmail");
-
-    builder
-        .args([
-            "-N", "never", // never send DSN (avoid mail loops)
-            "-f", mailfrom, "--",
-        ])
-        .args(mailto)
-        .stdin(Stdio::piped())
-        .stdout(Stdio::null())
-        .stderr(Stdio::null());
-
-    if let Some(uid) = uid {
-        builder.uid(uid);
-    }
-
-    let mut process = builder
-        .spawn()
-        .map_err(|err| Error::Generic(format!("could not spawn sendmail process: {err}")))?;
-
-    process
-        .stdin
-        .take()
-        .unwrap()
-        .write_all(message)
-        .map_err(|err| Error::Generic(format!("couldn't write to sendmail stdin: {err}")))?;
-
-    process
-        .wait()
-        .map_err(|err| Error::Generic(format!("sendmail did not exit successfully: {err}")))?;
-
-    Ok(())
-}
-- 
2.39.5



_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel


^ permalink raw reply	[flat|nested] 7+ messages in thread

* Re: [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate
  2024-12-02 12:58 [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Shannon Sterz
                   ` (2 preceding siblings ...)
  2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 4/4] notify: use proxmox-sendmail forward implementation Shannon Sterz
@ 2024-12-02 13:30 ` Lukas Wagner
  2024-12-02 14:17   ` Shannon Sterz
  2024-12-02 14:18 ` Shannon Sterz
  4 siblings, 1 reply; 7+ messages in thread
From: Lukas Wagner @ 2024-12-02 13:30 UTC (permalink / raw)
  To: Proxmox Backup Server development discussion, Shannon Sterz

Thanks for the quick v2, Shannon!

Two comment inline


On  2024-12-02 13:58, Shannon Sterz wrote:
> add the `proxmox-sendmail` crate that makes it easier to send mails via
> the `sendmail` utility. features include:
> 
> - multipart/alternative support for html+plain text mails
> - multipart/mixed support for mails with attachments
> - automatic nesting of multipart/alternative and multipart/mixed parts
> - masks multiple receivers by default, can be disabled
> - encoding Subject, To, From, and attachment file names correctly
> - adding an `Auto-Submitted` header to avoid triggering automated mails
> 
> also includes several tests to ensure that mails are formatted
> correctly.
> 
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
> 
> changes since v1 (thanks @ Lukas Wagner <l.wagner@proxmox.com>):
>     - make it possible to disable receiver redaction
>     - re-structure the mal formatting code; mainly split it into
>       multiple functions (`format_header`, `format_body`,
>       `format_attachment` etc.)
>     - fix multiple typos

You are still missing Debian packaging :)

✂ snip ✂

> +
> +    fn format_header(
> +        &self,
> +        now: i64,
> +        file_boundary: &str,
> +        html_boundary: &str,
> +    ) -> Result<String, Error> {
> +        let mut header = String::new();
> +
> +        let encoded_to = if self.to.len() > 1 && self.mask_participants {
> +            // if the receivers are masked, we know that they don't need to be encoded
> +            false
> +        } else {
> +            // check if there is a recipient that needs encoding
> +            self.to.iter().any(|r| !r.is_ascii())
> +        };
> +
> +        if !self.attachments.is_empty() {
> +            header.push_str("Content-Type: multipart/mixed;\n");
> +            header.push_str(&format!("\tboundary=\"{file_boundary}\"\n"));

Sorry I missed this in v1, but I think this could be a bit nicer as 

    use std::fmt::Write;

    writeln!(&mut header, "\tboundary=\"{file_boundary}\")?;

This is a bit shorter, easier to read and should be a tiny bit faster as well
(not that it makes an actual noticeable difference here).

Of course this also applies to the other places where you append to the header/body.


-- 
- Lukas



_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel

^ permalink raw reply	[flat|nested] 7+ messages in thread

* Re: [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate
  2024-12-02 13:30 ` [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Lukas Wagner
@ 2024-12-02 14:17   ` Shannon Sterz
  0 siblings, 0 replies; 7+ messages in thread
From: Shannon Sterz @ 2024-12-02 14:17 UTC (permalink / raw)
  To: Lukas Wagner, Proxmox Backup Server development discussion

On Mon Dec 2, 2024 at 2:30 PM CET, Lukas Wagner wrote:
> Thanks for the quick v2, Shannon!
>
> Two comment inline
>
>
> On  2024-12-02 13:58, Shannon Sterz wrote:
> > add the `proxmox-sendmail` crate that makes it easier to send mails via
> > the `sendmail` utility. features include:
> >
> > - multipart/alternative support for html+plain text mails
> > - multipart/mixed support for mails with attachments
> > - automatic nesting of multipart/alternative and multipart/mixed parts
> > - masks multiple receivers by default, can be disabled
> > - encoding Subject, To, From, and attachment file names correctly
> > - adding an `Auto-Submitted` header to avoid triggering automated mails
> >
> > also includes several tests to ensure that mails are formatted
> > correctly.
> >
> > Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> > ---
> >
> > changes since v1 (thanks @ Lukas Wagner <l.wagner@proxmox.com>):
> >     - make it possible to disable receiver redaction
> >     - re-structure the mal formatting code; mainly split it into
> >       multiple functions (`format_header`, `format_body`,
> >       `format_attachment` etc.)
> >     - fix multiple typos
>
> You are still missing Debian packaging :)
>
> ✂ snip ✂

ah yes, sorry fixed that up in a v3 just now

>
> > +
> > +    fn format_header(
> > +        &self,
> > +        now: i64,
> > +        file_boundary: &str,
> > +        html_boundary: &str,
> > +    ) -> Result<String, Error> {
> > +        let mut header = String::new();
> > +
> > +        let encoded_to = if self.to.len() > 1 && self.mask_participants {
> > +            // if the receivers are masked, we know that they don't need to be encoded
> > +            false
> > +        } else {
> > +            // check if there is a recipient that needs encoding
> > +            self.to.iter().any(|r| !r.is_ascii())
> > +        };
> > +
> > +        if !self.attachments.is_empty() {
> > +            header.push_str("Content-Type: multipart/mixed;\n");
> > +            header.push_str(&format!("\tboundary=\"{file_boundary}\"\n"));
>
> Sorry I missed this in v1, but I think this could be a bit nicer as
>
>     use std::fmt::Write;
>
>     writeln!(&mut header, "\tboundary=\"{file_boundary}\")?;
>
> This is a bit shorter, easier to read and should be a tiny bit faster as well
> (not that it makes an actual noticeable difference here).
>
> Of course this also applies to the other places where you append to the header/body.

alright, switch over instances of `push_str(&format!())` over to
`write!` and `writeln!`


_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel

^ permalink raw reply	[flat|nested] 7+ messages in thread

* Re: [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate
  2024-12-02 12:58 [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Shannon Sterz
                   ` (3 preceding siblings ...)
  2024-12-02 13:30 ` [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Lukas Wagner
@ 2024-12-02 14:18 ` Shannon Sterz
  4 siblings, 0 replies; 7+ messages in thread
From: Shannon Sterz @ 2024-12-02 14:18 UTC (permalink / raw)
  To: Shannon Sterz, pbs-devel

Superseeded-by: https://lore.proxmox.com/pbs-devel/20241202141655.254472-1-s.sterz@proxmox.com/

On Mon Dec 2, 2024 at 1:58 PM CET, Shannon Sterz wrote:
> add the `proxmox-sendmail` crate that makes it easier to send mails via
> the `sendmail` utility. features include:
>
> - multipart/alternative support for html+plain text mails
> - multipart/mixed support for mails with attachments
> - automatic nesting of multipart/alternative and multipart/mixed parts
> - masks multiple receivers by default, can be disabled
> - encoding Subject, To, From, and attachment file names correctly
> - adding an `Auto-Submitted` header to avoid triggering automated mails
>
> also includes several tests to ensure that mails are formatted
> correctly.
>
> Signed-off-by: Shannon Sterz <s.sterz@proxmox.com>
> ---
>
> changes since v1 (thanks @ Lukas Wagner <l.wagner@proxmox.com>):
>     - make it possible to disable receiver redaction
>     - re-structure the mal formatting code; mainly split it into
>       multiple functions (`format_header`, `format_body`,
>       `format_attachment` etc.)
>     - fix multiple typos
>
>  Cargo.toml                  |   2 +
>  proxmox-sendmail/Cargo.toml |  16 +
>  proxmox-sendmail/src/lib.rs | 769 ++++++++++++++++++++++++++++++++++++
>  3 files changed, 787 insertions(+)
>  create mode 100644 proxmox-sendmail/Cargo.toml
>  create mode 100644 proxmox-sendmail/src/lib.rs
>
> diff --git a/Cargo.toml b/Cargo.toml
> index 84fbe979..b62fcd50 100644
> --- a/Cargo.toml
> +++ b/Cargo.toml
> @@ -33,6 +33,7 @@ members = [
>      "proxmox-rrd-api-types",
>      "proxmox-schema",
>      "proxmox-section-config",
> +    "proxmox-sendmail",
>      "proxmox-serde",
>      "proxmox-shared-cache",
>      "proxmox-shared-memory",
> @@ -138,6 +139,7 @@ proxmox-rest-server = { version = "0.8.0", path = "proxmox-rest-server" }
>  proxmox-router = { version = "3.0.0", path = "proxmox-router" }
>  proxmox-schema = { version = "3.1.2", path = "proxmox-schema" }
>  proxmox-section-config = { version = "2.1.0", path = "proxmox-section-config" }
> +proxmox-sendmail = { version = "0.1.0", path = "proxmox-sendmail" }
>  proxmox-serde = { version = "0.1.1", path = "proxmox-serde", features = [ "serde_json" ] }
>  proxmox-shared-memory = { version = "0.3.0", path = "proxmox-shared-memory" }
>  proxmox-sortable-macro = { version = "0.1.3", path = "proxmox-sortable-macro" }
> diff --git a/proxmox-sendmail/Cargo.toml b/proxmox-sendmail/Cargo.toml
> new file mode 100644
> index 00000000..790b324b
> --- /dev/null
> +++ b/proxmox-sendmail/Cargo.toml
> @@ -0,0 +1,16 @@
> +[package]
> +name = "proxmox-sendmail"
> +version = "0.1.0"
> +authors.workspace = true
> +edition.workspace = true
> +license.workspace = true
> +repository.workspace = true
> +homepage.workspace = true
> +exclude.workspace = true
> +rust-version.workspace = true
> +
> +[dependencies]
> +anyhow = { workspace = true }
> +base64 = { workspace = true }
> +percent-encoding = { workspace = true }
> +proxmox-time = { workspace = true }
> diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs
> new file mode 100644
> index 00000000..a42bb0cd
> --- /dev/null
> +++ b/proxmox-sendmail/src/lib.rs
> @@ -0,0 +1,769 @@
> +//!
> +//! This library implements the [`Mail`] trait which makes it easy to send emails with attachments
> +//! and alternative html parts to one or multiple receivers via ``sendmail``.
> +//!
> +
> +use std::io::Write;
> +use std::process::{Command, Stdio};
> +
> +use anyhow::{bail, Context, Error};
> +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
> +
> +// Characters in this set will be encoded, so reproduce the inverse of the set described by RFC5987
> +// Section 3.2.1 `attr-char`, as that describes all characters that **don't** need encoding:
> +//
> +// https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1
> +//
> +// `CONTROLS` contains all control characters 0x00 - 0x1f and 0x7f as well as all non-ascii
> +// characters, so we need to add all characters here that aren't described in `attr-char` that are
> +// in the range 0x20-0x7e
> +const RFC5987SET: &AsciiSet = &CONTROLS
> +    .add(b' ')
> +    .add(b'"')
> +    .add(b'%')
> +    .add(b'&')
> +    .add(b'\'')
> +    .add(b'(')
> +    .add(b')')
> +    .add(b'*')
> +    .add(b',')
> +    .add(b'/')
> +    .add(b':')
> +    .add(b';')
> +    .add(b'<')
> +    .add(b'=')
> +    .add(b'>')
> +    .add(b'?')
> +    .add(b'@')
> +    .add(b'[')
> +    .add(b'\\')
> +    .add(b']')
> +    .add(b'{')
> +    .add(b'}');
> +
> +struct Recipient {
> +    name: Option<String>,
> +    email: String,
> +}
> +
> +impl Recipient {
> +    // Returns true if the name of the recipient is undefined or contains only ascii characters
> +    fn is_ascii(&self) -> bool {
> +        self.name.as_ref().map(|n| n.is_ascii()).unwrap_or(true)
> +    }
> +
> +    fn format_recipient(&self) -> String {
> +        if let Some(name) = &self.name {
> +            if !name.is_ascii() {
> +                format!("=?utf-8?B?{}?= <{}>", base64::encode(name), self.email)
> +            } else {
> +                format!("{} <{}>", name, self.email)
> +            }
> +        } else {
> +            self.email.to_string()
> +        }
> +    }
> +}
> +
> +struct Attachment<'a> {
> +    filename: String,
> +    mime: String,
> +    content: &'a [u8],
> +}
> +
> +impl<'a> Attachment<'a> {
> +    fn format_attachment(&self, file_boundary: &str) -> String {
> +        let mut attachment = String::new();
> +
> +        attachment.push_str(&format!("\n--{file_boundary}\n"));
> +        attachment.push_str(&format!(
> +            "Content-Type: {}; name=\"{}\"\n",
> +            self.mime, self.filename
> +        ));
> +
> +        // both `filename` and `filename*` are included for additional compatability
> +        attachment.push_str(&format!(
> +            "Content-Disposition: attachment; filename=\"{}\"; filename*=UTF-8''{}\n",
> +            self.filename,
> +            utf8_percent_encode(&self.filename, RFC5987SET)
> +        ));
> +        attachment.push_str("Content-Transfer-Encoding: base64\n\n");
> +
> +        // base64 encode the attachment and hard-wrap the base64 encoded string every 72
> +        // characters. this improves compatability.
> +        attachment.push_str(
> +            &base64::encode(self.content)
> +                .chars()
> +                .enumerate()
> +                .flat_map(|(i, c)| {
> +                    if i != 0 && i % 72 == 0 {
> +                        Some('\n')
> +                    } else {
> +                        None
> +                    }
> +                    .into_iter()
> +                    .chain(std::iter::once(c))
> +                })
> +                .collect::<String>(),
> +        );
> +
> +        attachment
> +    }
> +}
> +
> +/// This struct is used to define mails that are to be sent via the `sendmail` command.
> +pub struct Mail<'a> {
> +    mail_author: String,
> +    mail_from: String,
> +    subject: String,
> +    to: Vec<Recipient>,
> +    body_txt: String,
> +    body_html: Option<String>,
> +    attachments: Vec<Attachment<'a>>,
> +    mask_participants: bool,
> +}
> +
> +impl<'a> Mail<'a> {
> +    /// Creates a new mail with a mail author, from address, subject line and a plain text body.
> +    ///
> +    /// Note: If the author's name or the subject line contains UTF-8 characters they will be
> +    /// appropriately encoded.
> +    pub fn new(mail_author: &str, mail_from: &str, subject: &str, body_txt: &str) -> Self {
> +        Self {
> +            mail_author: mail_author.to_string(),
> +            mail_from: mail_from.to_string(),
> +            subject: subject.to_string(),
> +            to: Vec::new(),
> +            body_txt: body_txt.to_string(),
> +            body_html: None,
> +            attachments: Vec::new(),
> +            mask_participants: true,
> +        }
> +    }
> +
> +    /// Adds a recipient to the mail without specifying a name separately.
> +    ///
> +    /// Note: No formatting or encoding will be done here, the value will be passed to the `To:`
> +    /// header directly.
> +    pub fn add_recipient(&mut self, email: &str) {
> +        self.to.push(Recipient {
> +            name: None,
> +            email: email.to_string(),
> +        });
> +    }
> +
> +    /// Builder-pattern method to conveniently add a recipient to an email without specifying a
> +    /// name separately.
> +    ///
> +    /// Note: No formatting or encoding will be done here, the value will be passed to the `To:`
> +    /// header directly.
> +    pub fn with_recipient(mut self, email: &str) -> Self {
> +        self.add_recipient(email);
> +        self
> +    }
> +
> +    /// Adds a recipient to the mail with a name.
> +    ///
> +    /// Notes:
> +    ///
> +    /// - If the name contains UTF-8 characters it will be encoded. Then the possibly encoded name
> +    ///   and non-encoded email address will be passed to the `To:` header in this format:
> +    ///   `{encoded_name} <{email}>`
> +    /// - If multiple receivers are specified, they will be masked so as not to disclose them to
> +    ///   other receivers. This can be disabled via [`Mail::unmask_recipients`] or
> +    ///   [`Mail::with_unmasked_recipients`].
> +    pub fn add_recipient_and_name(&mut self, name: &str, email: &str) {
> +        self.to.push(Recipient {
> +            name: Some(name.to_string()),
> +            email: email.to_string(),
> +        });
> +    }
> +
> +    /// Builder-style method to conveniently add a recipient with a name to an email.
> +    ///
> +    /// Notes:
> +    ///
> +    /// - If the name contains UTF-8 characters it will be encoded. Then the possibly encoded name
> +    ///   and non-encoded email address will be passed to the `To:` header in this format:
> +    ///   `{encoded_name} <{email}>`
> +    /// - If multiple receivers are specified, they will be masked so as not to disclose them to
> +    ///   other receivers. This can be disabled via [`Mail::unmask_recipients`] or
> +    ///   [`Mail::with_unmasked_recipients`].
> +    pub fn with_recipient_and_name(mut self, name: &str, email: &str) -> Self {
> +        self.add_recipient_and_name(name, email);
> +        self
> +    }
> +
> +    /// Adds an attachment with a specified file name and mime-type to an email.
> +    ///
> +    /// Note: Adding attachments triggers `multipart/mixed` mode.
> +    pub fn add_attachment(&mut self, filename: &str, mime_type: &str, content: &'a [u8]) {
> +        self.attachments.push(Attachment {
> +            filename: filename.to_string(),
> +            mime: mime_type.to_string(),
> +            content,
> +        });
> +    }
> +
> +    /// Builder-style method to conveniently add an attachment with a specific filename and
> +    /// mime-type to an email.
> +    ///
> +    /// Note: Adding attachements triggers `multipart/mixed` mode.
> +    pub fn with_attachment(mut self, filename: &str, mime_type: &str, content: &'a [u8]) -> Self {
> +        self.add_attachment(filename, mime_type, content);
> +        self
> +    }
> +
> +    /// Set an alternative HTML part.
> +    ///
> +    /// Note: This triggers `multipart/alternative` mode. If both an HTML part and at least one
> +    /// attachment are specified, the `multipart/alternative` part will be nested within the first
> +    /// `multipart/mixed` part. This should ensure that the HTML is displayed properly by client's
> +    /// that prioritize it over the plain text part (should be the default for most clients) while
> +    /// also properly displaying the attachments.
> +    pub fn set_html_alt(&mut self, body_html: &str) {
> +        self.body_html.replace(body_html.to_string());
> +    }
> +
> +    /// Builder-style method to add an alternative HTML part.
> +    ///
> +    /// Note: This triggers `multipart/alternative` mode. If both an HTML part and at least one
> +    /// attachment are specified, the `multipart/alternative` part will be nested within the first
> +    /// `multipart/mixed` part. This should ensure that the HTML is displayed properly by client's
> +    /// that prioritize it over the plain text part (should be the default for most clients) while
> +    /// also properly displaying the attachments.
> +    pub fn with_html_alt(mut self, body_html: &str) -> Self {
> +        self.set_html_alt(body_html);
> +        self
> +    }
> +
> +    /// This function ensures that recipients of the mail are not masked. Being able to see all
> +    /// recipients of a mail can be helpful in, for example, notification scenarios.
> +    pub fn unmask_recipients(&mut self) {
> +        self.mask_participants = false;
> +    }
> +
> +    /// Builder-style function that ensures that recipients of the mail are not masked. Being able
> +    /// to see all recipients of a mail can be helpful in, for example, notification scenarios.
> +    pub fn with_unmasked_recipients(mut self) -> Self {
> +        self.unmask_recipients();
> +        self
> +    }
> +
> +    /// Sends the email. This will fail if no recipients have been added.
> +    ///
> +    /// Note: An `Auto-Submitted: auto-generated` header is added to avoid triggering OOO and
> +    /// similar mails.
> +    pub fn send(&self) -> Result<(), Error> {
> +        if self.to.is_empty() {
> +            bail!("no recipients provided for the mail, cannot send it.");
> +        }
> +
> +        let now = proxmox_time::epoch_i64();
> +        let body = self.format_mail(now)?;
> +
> +        let mut sendmail_process = Command::new("/usr/sbin/sendmail")
> +            .arg("-B")
> +            .arg("8BITMIME")
> +            .arg("-f")
> +            .arg(&self.mail_from)
> +            .arg("--")
> +            .args(self.to.iter().map(|p| &p.email).collect::<Vec<&String>>())
> +            .stdin(Stdio::piped())
> +            .spawn()
> +            .with_context(|| "could not spawn sendmail process")?;
> +
> +        sendmail_process
> +            .stdin
> +            .as_ref()
> +            .unwrap()
> +            .write_all(body.as_bytes())
> +            .with_context(|| "couldn't write to sendmail stdin")?;
> +
> +        sendmail_process
> +            .wait()
> +            .with_context(|| "sendmail did not exit successfully")?;
> +
> +        Ok(())
> +    }
> +
> +    fn format_mail(&self, now: i64) -> Result<String, Error> {
> +        let file_boundary = format!("----_=_NextPart_001_{}", now);
> +        let html_boundary = format!("----_=_NextPart_002_{}", now);
> +
> +        let mut mail = self.format_header(now, &file_boundary, &html_boundary)?;
> +
> +        mail.push_str(&self.format_body(&file_boundary, &html_boundary)?);
> +
> +        if !self.attachments.is_empty() {
> +            mail.push_str(
> +                &self
> +                    .attachments
> +                    .iter()
> +                    .map(|a| a.format_attachment(&file_boundary))
> +                    .collect::<String>(),
> +            );
> +            mail.push_str(&format!("\n--{file_boundary}--"));
> +        }
> +
> +        Ok(mail)
> +    }
> +
> +    fn format_header(
> +        &self,
> +        now: i64,
> +        file_boundary: &str,
> +        html_boundary: &str,
> +    ) -> Result<String, Error> {
> +        let mut header = String::new();
> +
> +        let encoded_to = if self.to.len() > 1 && self.mask_participants {
> +            // if the receivers are masked, we know that they don't need to be encoded
> +            false
> +        } else {
> +            // check if there is a recipient that needs encoding
> +            self.to.iter().any(|r| !r.is_ascii())
> +        };
> +
> +        if !self.attachments.is_empty() {
> +            header.push_str("Content-Type: multipart/mixed;\n");
> +            header.push_str(&format!("\tboundary=\"{file_boundary}\"\n"));
> +            header.push_str("MIME-Version: 1.0\n");
> +        } else if self.body_html.is_some() {
> +            header.push_str("Content-Type: multipart/alternative;\n");
> +            header.push_str(&format!("\tboundary=\"{html_boundary}\"\n"));
> +            header.push_str("MIME-Version: 1.0\n");
> +        } else if !self.subject.is_ascii() || !self.mail_author.is_ascii() || encoded_to {
> +            header.push_str("MIME-Version: 1.0\n");
> +        }
> +
> +        let subject = if !self.subject.is_ascii() {
> +            format!("Subject: =?utf-8?B?{}?=\n", base64::encode(&self.subject))
> +        } else {
> +            format!("Subject: {}\n", self.subject)
> +        };
> +
> +        header.push_str(&subject);
> +
> +        let from = if !self.mail_author.is_ascii() {
> +            format!(
> +                "From: =?utf-8?B?{}?= <{}>\n",
> +                base64::encode(&self.mail_author),
> +                self.mail_from
> +            )
> +        } else {
> +            format!("From: {} <{}>\n", self.mail_author, self.mail_from)
> +        };
> +
> +        header.push_str(&from);
> +
> +        let to = if self.to.len() > 1 && self.mask_participants {
> +            // don't disclose all recipients if the mail goes out to multiple
> +            let recipient = Recipient {
> +                name: Some("Undisclosed".to_string()),
> +                email: "noreply".to_string(),
> +            };
> +
> +            recipient.format_recipient()
> +        } else {
> +            self.to
> +                .iter()
> +                .map(Recipient::format_recipient)
> +                .collect::<Vec<String>>()
> +                .join(", ")
> +        };
> +
> +        header.push_str("To: ");
> +        header.push_str(&to);
> +        header.push('\n');
> +
> +        let rfc2822_date = proxmox_time::epoch_to_rfc2822(now)
> +            .with_context(|| "could not convert epoch to rfc2822 date")?;
> +        header.push_str(&format!("Date: {rfc2822_date}\n"));
> +        header.push_str("Auto-Submitted: auto-generated;\n");
> +
> +        Ok(header)
> +    }
> +
> +    fn format_body(&self, file_boundary: &str, html_boundary: &str) -> Result<String, Error> {
> +        let mut body = String::new();
> +
> +        if self.body_html.is_some() && !self.attachments.is_empty() {
> +            body.push_str("\nThis is a multi-part message in MIME format.\n");
> +            body.push_str(&format!("\n--{file_boundary}\n"));
> +            body.push_str(&format!(
> +                "Content-Type: multipart/alternative; boundary=\"{html_boundary}\"\n"
> +            ));
> +            body.push_str("MIME-Version: 1.0\n");
> +            body.push_str(&format!("\n--{html_boundary}\n"));
> +        } else if self.body_html.is_some() {
> +            body.push_str("\nThis is a multi-part message in MIME format.\n");
> +            body.push_str(&format!("\n--{html_boundary}\n"));
> +        } else if self.body_html.is_none() && !self.attachments.is_empty() {
> +            body.push_str("\nThis is a multi-part message in MIME format.\n");
> +            body.push_str(&format!("\n--{file_boundary}\n"));
> +        }
> +
> +        body.push_str("Content-Type: text/plain;\n");
> +        body.push_str("\tcharset=\"UTF-8\"\n");
> +        body.push_str("Content-Transfer-Encoding: 8bit\n\n");
> +        body.push_str(&self.body_txt);
> +
> +        if let Some(html) = &self.body_html {
> +            body.push_str(&format!("\n--{html_boundary}\n"));
> +            body.push_str("Content-Type: text/html;\n");
> +            body.push_str("\tcharset=\"UTF-8\"\n");
> +            body.push_str("Content-Transfer-Encoding: 8bit\n\n");
> +            body.push_str(html);
> +            body.push_str(&format!("\n--{html_boundary}--"));
> +        }
> +
> +        Ok(body)
> +    }
> +}
> +
> +#[cfg(test)]
> +mod test {
> +    use super::*;
> +
> +    #[test]
> +    fn email_without_recipients_fails() {
> +        let result = Mail::new("Sender", "mail@example.com", "hi", "body").send();
> +        assert!(result.is_err());
> +    }
> +
> +    #[test]
> +    fn simple_ascii_text_mail() {
> +        let mail = Mail::new(
> +            "Sender Name",
> +            "mailfrom@example.com",
> +            "Subject Line",
> +            "This is just ascii text.\nNothing too special.",
> +        )
> +        .with_recipient_and_name("Receiver Name", "receiver@example.com");
> +
> +        let body = mail.format_mail(0).expect("could not format mail");
> +
> +        assert_eq!(
> +            body,
> +            r#"Subject: Subject Line
> +From: Sender Name <mailfrom@example.com>
> +To: Receiver Name <receiver@example.com>
> +Date: Thu, 01 Jan 1970 01:00:00 +0100
> +Auto-Submitted: auto-generated;
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +This is just ascii text.
> +Nothing too special."#
> +        )
> +    }
> +
> +    #[test]
> +    fn multiple_receiver_masked() {
> +        let mail = Mail::new(
> +            "Sender Name",
> +            "mailfrom@example.com",
> +            "Subject Line",
> +            "This is just ascii text.\nNothing too special.",
> +        )
> +        .with_recipient_and_name("Receiver Name", "receiver@example.com")
> +        .with_recipient("two@example.com")
> +        .with_recipient_and_name("mäx müstermänn", "mm@example.com");
> +
> +        let body = mail.format_mail(0).expect("could not format mail");
> +
> +        assert_eq!(
> +            body,
> +            r#"Subject: Subject Line
> +From: Sender Name <mailfrom@example.com>
> +To: Undisclosed <noreply>
> +Date: Thu, 01 Jan 1970 01:00:00 +0100
> +Auto-Submitted: auto-generated;
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +This is just ascii text.
> +Nothing too special."#
> +        )
> +    }
> +
> +    #[test]
> +    fn multiple_receiver_unmasked() {
> +        let mail = Mail::new(
> +            "Sender Name",
> +            "mailfrom@example.com",
> +            "Subject Line",
> +            "This is just ascii text.\nNothing too special.",
> +        )
> +        .with_recipient_and_name("Receiver Name", "receiver@example.com")
> +        .with_recipient("two@example.com")
> +        .with_recipient_and_name("mäx müstermänn", "mm@example.com")
> +        .with_unmasked_recipients();
> +
> +        let body = mail.format_mail(0).expect("could not format mail");
> +
> +        assert_eq!(
> +            body,
> +            r#"MIME-Version: 1.0
> +Subject: Subject Line
> +From: Sender Name <mailfrom@example.com>
> +To: Receiver Name <receiver@example.com>, two@example.com, =?utf-8?B?bcOkeCBtw7xzdGVybcOkbm4=?= <mm@example.com>
> +Date: Thu, 01 Jan 1970 01:00:00 +0100
> +Auto-Submitted: auto-generated;
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +This is just ascii text.
> +Nothing too special."#
> +        )
> +    }
> +
> +    #[test]
> +    fn simple_utf8_text_mail() {
> +        let mail = Mail::new(
> +            "UTF-8 Sender Name 📧",
> +            "differentfrom@example.com",
> +            "Subject Line 🧑",
> +            "This utf-8 email should handle emojis\n🧑📧\nand weird german characters: öäüß\nand more.",
> +        )
> +        .with_recipient_and_name("Receiver Name📩", "receiver@example.com");
> +
> +        let body = mail.format_mail(1732806251).expect("could not format mail");
> +
> +        assert_eq!(
> +            body,
> +            r#"MIME-Version: 1.0
> +Subject: =?utf-8?B?U3ViamVjdCBMaW5lIPCfp5E=?=
> +From: =?utf-8?B?VVRGLTggU2VuZGVyIE5hbWUg8J+Tpw==?= <differentfrom@example.com>
> +To: =?utf-8?B?UmVjZWl2ZXIgTmFtZfCfk6k=?= <receiver@example.com>
> +Date: Thu, 28 Nov 2024 16:04:11 +0100
> +Auto-Submitted: auto-generated;
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +This utf-8 email should handle emojis
> +🧑📧
> +and weird german characters: öäüß
> +and more."#
> +        )
> +    }
> +
> +    #[test]
> +    fn multipart_html_alternative() {
> +        let mail = Mail::new(
> +            "Sender Name",
> +            "from@example.com",
> +            "Subject Line",
> +            "Lorem Ipsum Dolor Sit\nAmet",
> +        )
> +        .with_recipient("receiver@example.com")
> +        .with_html_alt("<html lang=\"de-at\"><head></head><body>\n\t<pre>\n\t\tLorem Ipsum Dolor Sit Amet\n\t</pre>\n</body></html>");
> +        let body = mail.format_mail(1732806251).expect("could not format mail");
> +        assert_eq!(
> +            body,
> +            r#"Content-Type: multipart/alternative;
> +	boundary="----_=_NextPart_002_1732806251"
> +MIME-Version: 1.0
> +Subject: Subject Line
> +From: Sender Name <from@example.com>
> +To: receiver@example.com
> +Date: Thu, 28 Nov 2024 16:04:11 +0100
> +Auto-Submitted: auto-generated;
> +
> +This is a multi-part message in MIME format.
> +
> +------_=_NextPart_002_1732806251
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +Lorem Ipsum Dolor Sit
> +Amet
> +------_=_NextPart_002_1732806251
> +Content-Type: text/html;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +<html lang="de-at"><head></head><body>
> +	<pre>
> +		Lorem Ipsum Dolor Sit Amet
> +	</pre>
> +</body></html>
> +------_=_NextPart_002_1732806251--"#
> +        )
> +    }
> +
> +    #[test]
> +    fn multipart_plain_text_attachments_mixed() {
> +        let bin: [u8; 62] = [
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
> +        ];
> +
> +        let mail = Mail::new(
> +            "Sender Name",
> +            "from@example.com",
> +            "Subject Line",
> +            "Lorem Ipsum Dolor Sit\nAmet",
> +        )
> +        .with_recipient_and_name("Receiver Name", "receiver@example.com")
> +        .with_attachment("deadbeef.bin", "application/octet-stream", &bin);
> +
> +        let body = mail.format_mail(1732806251).expect("could not format mail");
> +        assert_eq!(
> +            body,
> +            r#"Content-Type: multipart/mixed;
> +	boundary="----_=_NextPart_001_1732806251"
> +MIME-Version: 1.0
> +Subject: Subject Line
> +From: Sender Name <from@example.com>
> +To: Receiver Name <receiver@example.com>
> +Date: Thu, 28 Nov 2024 16:04:11 +0100
> +Auto-Submitted: auto-generated;
> +
> +This is a multi-part message in MIME format.
> +
> +------_=_NextPart_001_1732806251
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +Lorem Ipsum Dolor Sit
> +Amet
> +------_=_NextPart_001_1732806251
> +Content-Type: application/octet-stream; name="deadbeef.bin"
> +Content-Disposition: attachment; filename="deadbeef.bin"; filename*=UTF-8''deadbeef.bin
> +Content-Transfer-Encoding: base64
> +
> +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
> +3q2+796tvu8=
> +------_=_NextPart_001_1732806251--"#
> +        )
> +    }
> +
> +    #[test]
> +    fn multipart_plain_text_html_alternative_attachments() {
> +        let bin: [u8; 62] = [
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
> +            0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
> +        ];
> +
> +        let mail = Mail::new(
> +            "Sender Name",
> +            "from@example.com",
> +            "Subject Line",
> +            "Lorem Ipsum Dolor Sit\nAmet",
> +        )
> +        .with_recipient_and_name("Receiver Name", "receiver@example.com")
> +        .with_attachment("deadbeef.bin", "application/octet-stream", &bin)
> +        .with_attachment("🐄💀.bin", "image/bmp", &bin)
> +        .with_html_alt("<html lang=\"de-at\"><head></head><body>\n\t<pre>\n\t\tLorem Ipsum Dolor Sit Amet\n\t</pre>\n</body></html>");
> +
> +        let body = mail.format_mail(1732806251).expect("could not format mail");
> +
> +        assert_eq!(
> +            body,
> +            r#"Content-Type: multipart/mixed;
> +	boundary="----_=_NextPart_001_1732806251"
> +MIME-Version: 1.0
> +Subject: Subject Line
> +From: Sender Name <from@example.com>
> +To: Receiver Name <receiver@example.com>
> +Date: Thu, 28 Nov 2024 16:04:11 +0100
> +Auto-Submitted: auto-generated;
> +
> +This is a multi-part message in MIME format.
> +
> +------_=_NextPart_001_1732806251
> +Content-Type: multipart/alternative; boundary="----_=_NextPart_002_1732806251"
> +MIME-Version: 1.0
> +
> +------_=_NextPart_002_1732806251
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +Lorem Ipsum Dolor Sit
> +Amet
> +------_=_NextPart_002_1732806251
> +Content-Type: text/html;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +<html lang="de-at"><head></head><body>
> +	<pre>
> +		Lorem Ipsum Dolor Sit Amet
> +	</pre>
> +</body></html>
> +------_=_NextPart_002_1732806251--
> +------_=_NextPart_001_1732806251
> +Content-Type: application/octet-stream; name="deadbeef.bin"
> +Content-Disposition: attachment; filename="deadbeef.bin"; filename*=UTF-8''deadbeef.bin
> +Content-Transfer-Encoding: base64
> +
> +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
> +3q2+796tvu8=
> +------_=_NextPart_001_1732806251
> +Content-Type: image/bmp; name="🐄💀.bin"
> +Content-Disposition: attachment; filename="🐄💀.bin"; filename*=UTF-8''%F0%9F%90%84%F0%9F%92%80.bin
> +Content-Transfer-Encoding: base64
> +
> +3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
> +3q2+796tvu8=
> +------_=_NextPart_001_1732806251--"#
> +        )
> +    }
> +
> +    #[test]
> +    fn test_format_mail_multipart() {
> +        let mail = Mail::new(
> +            "Fred Oobar",
> +            "foobar@example.com",
> +            "This is the subject",
> +            "This is the plain body",
> +        )
> +        .with_recipient_and_name("Tony Est", "test@example.com")
> +        .with_html_alt("<body>This is the HTML body</body>");
> +
> +        let body = mail.format_mail(1718977850).expect("could not format mail");
> +
> +        assert_eq!(
> +            body,
> +            r#"Content-Type: multipart/alternative;
> +	boundary="----_=_NextPart_002_1718977850"
> +MIME-Version: 1.0
> +Subject: This is the subject
> +From: Fred Oobar <foobar@example.com>
> +To: Tony Est <test@example.com>
> +Date: Fri, 21 Jun 2024 15:50:50 +0200
> +Auto-Submitted: auto-generated;
> +
> +This is a multi-part message in MIME format.
> +
> +------_=_NextPart_002_1718977850
> +Content-Type: text/plain;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +This is the plain body
> +------_=_NextPart_002_1718977850
> +Content-Type: text/html;
> +	charset="UTF-8"
> +Content-Transfer-Encoding: 8bit
> +
> +<body>This is the HTML body</body>
> +------_=_NextPart_002_1718977850--"#
> +        );
> +    }
> +}
> --
> 2.39.5



_______________________________________________
pbs-devel mailing list
pbs-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel

^ permalink raw reply	[flat|nested] 7+ messages in thread

end of thread, other threads:[~2024-12-02 14:18 UTC | newest]

Thread overview: 7+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2024-12-02 12:58 [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Shannon Sterz
2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 2/4] notify: switch sendmail endpoint over to new crate Shannon Sterz
2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 3/4] sendmail: add mail-forwarder feature Shannon Sterz
2024-12-02 12:58 ` [pbs-devel] [PATCH proxmox v2 4/4] notify: use proxmox-sendmail forward implementation Shannon Sterz
2024-12-02 13:30 ` [pbs-devel] [PATCH proxmox v2 1/4] sendmail: add sendmail crate Lukas Wagner
2024-12-02 14:17   ` Shannon Sterz
2024-12-02 14:18 ` Shannon Sterz

This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal