public inbox for pbs-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate
@ 2024-11-29 10:53 Shannon Sterz
  2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 2/4] notify: switchi sendmail endpoint over to new crate Shannon Sterz
                   ` (5 more replies)
  0 siblings, 6 replies; 11+ messages in thread
From: Shannon Sterz @ 2024-11-29 10:53 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
- mulitpart/mixed support for mails with attachments
- automatic nesting of multipart/alternative and multipart/mixed parts
- masking recipients to avoid disclosing them to everyone
- 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>
---
 Cargo.toml                  |   2 +
 proxmox-sendmail/Cargo.toml |  16 +
 proxmox-sendmail/src/lib.rs | 664 ++++++++++++++++++++++++++++++++++++
 3 files changed, 682 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..c97c3186
--- /dev/null
+++ b/proxmox-sendmail/src/lib.rs
@@ -0,0 +1,664 @@
+//!
+//! 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,
+}
+
+struct Attachment<'a> {
+    filename: String,
+    mime: String,
+    content: &'a [u8],
+}
+
+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>>,
+}
+
+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
+    /// appropriatelly 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(),
+        }
+    }
+
+    /// 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.
+    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 reciepient 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.
+    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 attachements 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
+    /// attachement 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
+    /// attachement 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
+    }
+
+    /// Sends the email. This will fail if no reciepient's 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 mut body = String::new();
+        let file_boundary = format!("----_=_NextPart_001_{}", now);
+        let html_boundary = format!("----_=_NextPart_002_{}", now);
+
+        let to = if self.to.len() > 1 {
+            // don't disclose all recipients if the mail goes out to multiple
+            &Recipient {
+                name: Some("Undisclosed".to_string()),
+                email: "noreply".to_string(),
+            }
+        } else {
+            self.to
+                .first()
+                .expect("the checks before make sure there is at least one recipient")
+        };
+
+        if !self.attachments.is_empty() {
+            body.push_str("Content-Type: multipart/mixed;\n");
+            body.push_str(&format!("\tboundary=\"{file_boundary}\"\n"));
+            body.push_str("MIME-Version: 1.0\n");
+        } else if self.body_html.is_some() {
+            body.push_str("Content-Type: multipart/alternative;\n");
+            body.push_str(&format!("\tboundary=\"{html_boundary}\"\n"));
+            body.push_str("MIME-Version: 1.0\n");
+        } else if !self.subject.is_ascii()
+            || !self.mail_author.is_ascii()
+            || !to.name.as_ref().map(|t| t.is_ascii()).unwrap_or(true)
+        {
+            body.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)
+        };
+
+        body.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)
+        };
+
+        body.push_str(&from);
+
+        let to = if let Some(name) = &to.name {
+            if !name.is_ascii() {
+                format!("To: =?utf-8?B?{}?= <{}>\n", base64::encode(&name), to.email)
+            } else {
+                format!("To: {} <{}>\n", name, to.email)
+            }
+        } else {
+            format!("To: {}\n", to.email)
+        };
+
+        body.push_str(&to);
+
+        let rfc2822_date = proxmox_time::epoch_to_rfc2822(now)
+            .with_context(|| "could not convert epoch to rfc2822 date")?;
+        body.push_str(&format!("Date: {rfc2822_date}\n"));
+        body.push_str("Auto-Submitted: auto-generated;\n");
+
+        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}--"));
+        }
+
+        for attachment in &self.attachments {
+            let filename = &attachment.filename;
+
+            body.push_str(&format!("\n--{file_boundary}\n"));
+            body.push_str(&format!(
+                "Content-Type: {}; name=\"{filename}\"\n",
+                attachment.mime
+            ));
+
+            // both `filename` and `filename*` are included for additional compatability
+            body.push_str(&format!(
+                "Content-Disposition: attachment; filename=\"{filename}\"; filename*=UTF-8''{}\n",
+                utf8_percent_encode(filename, RFC5987SET)
+            ));
+            body.push_str("Content-Transfer-Encoding: base64\n\n");
+
+            // wrap the base64 string every 72 characters. this improves compatability
+            let base64 = base64::encode(attachment.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>();
+            body.push_str(&base64);
+        }
+
+        if !self.attachments.is_empty() {
+            body.push_str(&format!("\n--{file_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_redaction() {
+        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");
+
+        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 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] 11+ messages in thread

* [pbs-devel] [PATCH proxmox 2/4] notify: switchi sendmail endpoint over to new crate
  2024-11-29 10:53 [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Shannon Sterz
@ 2024-11-29 10:53 ` Shannon Sterz
  2024-12-02 10:20   ` Lukas Wagner
  2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 3/4] sendmail: add mail-forwarder feature Shannon Sterz
                   ` (4 subsequent siblings)
  5 siblings, 1 reply; 11+ messages in thread
From: Shannon Sterz @ 2024-11-29 10:53 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 | 171 +----------------------
 2 files changed, 9 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..1268d372 100644
--- a/proxmox-notify/src/endpoints/sendmail.rs
+++ b/proxmox-notify/src/endpoints/sendmail.rs
@@ -137,15 +137,13 @@ 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);
+
+                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 +163,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 +210,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] 11+ messages in thread

* [pbs-devel] [PATCH proxmox 3/4] sendmail: add mail-forwarder feature
  2024-11-29 10:53 [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Shannon Sterz
  2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 2/4] notify: switchi sendmail endpoint over to new crate Shannon Sterz
@ 2024-11-29 10:53 ` Shannon Sterz
  2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 4/4] notify: use proxmox-sendmail forward implementation Shannon Sterz
                   ` (3 subsequent siblings)
  5 siblings, 0 replies; 11+ messages in thread
From: Shannon Sterz @ 2024-11-29 10:53 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 c97c3186..d41b266c 100644
--- a/proxmox-sendmail/src/lib.rs
+++ b/proxmox-sendmail/src/lib.rs
@@ -210,6 +210,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 mut body = String::new();
         let file_boundary = format!("----_=_NextPart_001_{}", now);
@@ -360,6 +410,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] 11+ messages in thread

* [pbs-devel] [PATCH proxmox 4/4] notify: use proxmox-sendmail forward implementation
  2024-11-29 10:53 [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Shannon Sterz
  2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 2/4] notify: switchi sendmail endpoint over to new crate Shannon Sterz
  2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 3/4] sendmail: add mail-forwarder feature Shannon Sterz
@ 2024-11-29 10:53 ` Shannon Sterz
  2024-11-29 14:38 ` [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Thomas Lamprecht
                   ` (2 subsequent siblings)
  5 siblings, 0 replies; 11+ messages in thread
From: Shannon Sterz @ 2024-11-29 10:53 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 1268d372..a2a1ed3a 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;
@@ -147,7 +145,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()))
             }
         }
@@ -162,51 +160,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] 11+ messages in thread

* Re: [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate
  2024-11-29 10:53 [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Shannon Sterz
                   ` (2 preceding siblings ...)
  2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 4/4] notify: use proxmox-sendmail forward implementation Shannon Sterz
@ 2024-11-29 14:38 ` Thomas Lamprecht
  2024-12-02 10:20 ` Lukas Wagner
  2024-12-02 13:00 ` Shannon Sterz
  5 siblings, 0 replies; 11+ messages in thread
From: Thomas Lamprecht @ 2024-11-29 14:38 UTC (permalink / raw)
  To: Proxmox Backup Server development discussion, Shannon Sterz,
	Lukas Wagner

Am 29.11.24 um 11:53 schrieb Shannon Sterz:
> 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
> - mulitpart/mixed support for mails with attachments
> - automatic nesting of multipart/alternative and multipart/mixed parts
> - masking recipients to avoid disclosing them to everyone
> - 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.

Looks quite nice to me, but no need to rush this, so maybe another opinion (@Lukas)
wouldn't hurt here.


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


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

* Re: [pbs-devel] [PATCH proxmox 2/4] notify: switchi sendmail endpoint over to new crate
  2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 2/4] notify: switchi sendmail endpoint over to new crate Shannon Sterz
@ 2024-12-02 10:20   ` Lukas Wagner
  0 siblings, 0 replies; 11+ messages in thread
From: Lukas Wagner @ 2024-12-02 10:20 UTC (permalink / raw)
  To: Proxmox Backup Server development discussion, Shannon Sterz

typo in the commit message ('switchi')

-- 
- 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] 11+ messages in thread

* Re: [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate
  2024-11-29 10:53 [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Shannon Sterz
                   ` (3 preceding siblings ...)
  2024-11-29 14:38 ` [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Thomas Lamprecht
@ 2024-12-02 10:20 ` Lukas Wagner
  2024-12-02 11:02   ` Shannon Sterz
  2024-12-02 13:00 ` Shannon Sterz
  5 siblings, 1 reply; 11+ messages in thread
From: Lukas Wagner @ 2024-12-02 10:20 UTC (permalink / raw)
  To: Proxmox Backup Server development discussion, Shannon Sterz

Gave these changes a quick test in Proxmox Backup Server as well as in proxmox-mail-forward.

Looks good!

Tested-by: Lukas Wagner <l.wagner@proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>

(the latter iff the minor issues I mentioned are addressed)


On  2024-11-29 11:53, 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
> - mulitpart/mixed support for mails with attachments
      ^ typo

> - automatic nesting of multipart/alternative and multipart/mixed parts
> - masking recipients to avoid disclosing them to everyone
> - 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>
> ---
>  Cargo.toml                  |   2 +
>  proxmox-sendmail/Cargo.toml |  16 +
>  proxmox-sendmail/src/lib.rs | 664 ++++++++++++++++++++++++++++++++++++
>  3 files changed, 682 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..c97c3186
> --- /dev/null
> +++ b/proxmox-sendmail/src/lib.rs
> @@ -0,0 +1,664 @@
> +//!
> +//! 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,
> +}
> +
> +struct Attachment<'a> {
> +    filename: String,
> +    mime: String,
> +    content: &'a [u8],
> +}
> +
> +pub struct Mail<'a> {

Doc comment is missing

> +    mail_author: String,
> +    mail_from: String,
> +    subject: String,
> +    to: Vec<Recipient>,
> +    body_txt: String,
> +    body_html: Option<String>,
> +    attachments: Vec<Attachment<'a>>,
> +}
> +
> +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
> +    /// appropriatelly encoded.

nit: typo in appropriately

> +    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(),
> +        }
> +    }
> +
> +    /// 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

Thinking about the main usecase of this new crate, which is to provide the implementation
to send notification mails from PVE and PBS, I think the masking part should be 
configurable.

In the most common case, a notification mail might go to the members of an infra team of
a organization, where the identities of other team members is not really sensitive information.
I'd actually go as far and say that the info "who else was notified" is actually quite valuable
and useful to have.

Then again, I can see the benefits of masking, e.g. in the case of PBS datastore notifications,
which might go to non-admin users (e.g. when PBS is offered as a service a la Tuxis).

I don't care that much whether this is opt-in or opt-out at the crate level, but at the
'sendmail target' level I'd make this configurable and opt-in (gut feeling and to not
change the current behavior, I'd be happy to be convinced for another way :) )

What are your thoughts about this?

> +    ///   other receivers.
> +    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 reciepient with a name to an email.

nit: typo in recipient

> +    ///
> +    /// 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.
> +    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 attachements triggers `multipart/mixed` mode.

nit: typo in attachment

> +    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
> +    /// attachement are specified, the `multipart/alternative` part will be nested within the first

nit: typo in attachment

> +    /// `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
> +    /// attachement are specified, the `multipart/alternative` part will be nested within the 

nit: typo in attachment

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
> +    }
> +
> +    /// Sends the email. This will fail if no reciepient's have been added.

Nit: typo in 'recipient'

> +    ///
> +    /// 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 mut body = String::new();
> +        let file_boundary = format!("----_=_NextPart_001_{}", now);
> +        let html_boundary = format!("----_=_NextPart_002_{}", now);
> +
> +        let to = if self.to.len() > 1 {
> +            // don't disclose all recipients if the mail goes out to multiple
> +            &Recipient {
> +                name: Some("Undisclosed".to_string()),
> +                email: "noreply".to_string(),
> +            }
> +        } else {
> +            self.to
> +                .first()
> +                .expect("the checks before make sure there is at least one recipient")
> +        };
> +
> +        if !self.attachments.is_empty() {
> +            body.push_str("Content-Type: multipart/mixed;\n");
> +            body.push_str(&format!("\tboundary=\"{file_boundary}\"\n"));
> +            body.push_str("MIME-Version: 1.0\n");
> +        } else if self.body_html.is_some() {
> +            body.push_str("Content-Type: multipart/alternative;\n");
> +            body.push_str(&format!("\tboundary=\"{html_boundary}\"\n"));
> +            body.push_str("MIME-Version: 1.0\n");
> +        } else if !self.subject.is_ascii()
> +            || !self.mail_author.is_ascii()
> +            || !to.name.as_ref().map(|t| t.is_ascii()).unwrap_or(true)
> +        {
> +            body.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)
> +        };
> +
> +        body.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)
> +        };
> +
> +        body.push_str(&from);
> +
> +        let to = if let Some(name) = &to.name {
> +            if !name.is_ascii() {
> +                format!("To: =?utf-8?B?{}?= <{}>\n", base64::encode(&name), to.email)
> +            } else {
> +                format!("To: {} <{}>\n", name, to.email)
> +            }
> +        } else {
> +            format!("To: {}\n", to.email)
> +        };
> +
> +        body.push_str(&to);
> +
> +        let rfc2822_date = proxmox_time::epoch_to_rfc2822(now)
> +            .with_context(|| "could not convert epoch to rfc2822 date")?;
> +        body.push_str(&format!("Date: {rfc2822_date}\n"));
> +        body.push_str("Auto-Submitted: auto-generated;\n");
> +
> +        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}--"));
> +        }
> +
> +        for attachment in &self.attachments {
> +            let filename = &attachment.filename;
> +
> +            body.push_str(&format!("\n--{file_boundary}\n"));
> +            body.push_str(&format!(
> +                "Content-Type: {}; name=\"{filename}\"\n",
> +                attachment.mime
> +            ));
> +
> +            // both `filename` and `filename*` are included for additional compatability
> +            body.push_str(&format!(
> +                "Content-Disposition: attachment; filename=\"{filename}\"; filename*=UTF-8''{}\n",
> +                utf8_percent_encode(filename, RFC5987SET)
> +            ));
> +            body.push_str("Content-Transfer-Encoding: base64\n\n");
> +
> +            // wrap the base64 string every 72 characters. this improves compatability
> +            let base64 = base64::encode(attachment.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>();
> +            body.push_str(&base64);
> +        }
> +
> +        if !self.attachments.is_empty() {
> +            body.push_str(&format!("\n--{file_boundary}--"));
> +        }
> +
> +        Ok(body)
> +    }

I think with added support for attachments and the other changes it might make sense
to start breaking the method into smaller sub-methods. Personally I found it a bit
hard to follow the way it is right now :) At very least I'd try to break this into format_header
and format_body, in the latter one could probably also break out the attachment part.

What do you think?


Also, I think debian packaging should be added in this or a separate commit.


-- 
- 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] 11+ messages in thread

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

On Mon Dec 2, 2024 at 11:20 AM CET, Lukas Wagner wrote:
> Gave these changes a quick test in Proxmox Backup Server as well as in proxmox-mail-forward.
>
> Looks good!
>
> Tested-by: Lukas Wagner <l.wagner@proxmox.com>
> Reviewed-by: Lukas Wagner <l.wagner@proxmox.com>
>
> (the latter iff the minor issues I mentioned are addressed)
>

thanks for the review! addressed some of your comments in-line and will
send a v2 with the rest.

-->8 snip 8<--

> > +
> > +    /// 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
>
> Thinking about the main usecase of this new crate, which is to provide the implementation
> to send notification mails from PVE and PBS, I think the masking part should be
> configurable.
>
> In the most common case, a notification mail might go to the members of an infra team of
> a organization, where the identities of other team members is not really sensitive information.
> I'd actually go as far and say that the info "who else was notified" is actually quite valuable
> and useful to have.
>
> Then again, I can see the benefits of masking, e.g. in the case of PBS datastore notifications,
> which might go to non-admin users (e.g. when PBS is offered as a service a la Tuxis).
>
> I don't care that much whether this is opt-in or opt-out at the crate level, but at the
> 'sendmail target' level I'd make this configurable and opt-in (gut feeling and to not
> change the current behavior, I'd be happy to be convinced for another way :) )
>
> What are your thoughts about this?

making this configurable sounds reasonable to me. i'd tend towards
making the masking opt-out, though. at least on a crate level. my
use-case for this crate is to send mails to all participants of a
training, so there disclosing the mail addresses of other participants
could be really bad (even legally actionable, afaict). i can see the
value of this information in a notification scenario. however, i think
the implications of forgetting to disclose this in some scenarios is
much less detrimental than doing so in scenarios where we don't want to
disclose them.

-->8 snip 8<--

> I think with added support for attachments and the other changes it might make sense
> to start breaking the method into smaller sub-methods. Personally I found it a bit
> hard to follow the way it is right now :) At very least I'd try to break this into format_header
> and format_body, in the latter one could probably also break out the attachment part.
>
> What do you think?

yep makes sense, this is starting to spaghettify so yeah, i'll break
these out a bit.

> Also, I think debian packaging should be added in this or a separate commit.

will try to add this in a v2.


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


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

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

On  2024-12-02 12:02, Shannon Sterz wrote:
>> In the most common case, a notification mail might go to the members of an infra team of
>> a organization, where the identities of other team members is not really sensitive information.
>> I'd actually go as far and say that the info "who else was notified" is actually quite valuable
>> and useful to have.
>>
>> Then again, I can see the benefits of masking, e.g. in the case of PBS datastore notifications,
>> which might go to non-admin users (e.g. when PBS is offered as a service a la Tuxis).
>>
>> I don't care that much whether this is opt-in or opt-out at the crate level, but at the
>> 'sendmail target' level I'd make this configurable and opt-in (gut feeling and to not
>> change the current behavior, I'd be happy to be convinced for another way :) )
>>
>> What are your thoughts about this?
> 
> making this configurable sounds reasonable to me. i'd tend towards
> making the masking opt-out, though. at least on a crate level. my
> use-case for this crate is to send mails to all participants of a
> training, so there disclosing the mail addresses of other participants
> could be really bad (even legally actionable, afaict). i can see the
> value of this information in a notification scenario. however, i think
> the implications of forgetting to disclose this in some scenarios is
> much less detrimental than doing so in scenarios where we don't want to
> disclose them.
> 

I don't think we have to decide it for proxmox-notify's targets now.
I'd suggest making masking default at the crate level with some way to disable it in the
builder. In proxmox-notify, I'd opt-out for now to keep the current behavior.
In the future we can easily add configuration parameter to the sendmail target config to
configure this; then we can also reevaluate what a sane default would be at the target level.
At the same time we can add the same behavior to smtp targets then so that
the behavior is consistent with the sendmail target.


-- 
- 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] 11+ messages in thread

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

On Mon Dec 2, 2024 at 1:11 PM CET, Lukas Wagner wrote:
> On  2024-12-02 12:02, Shannon Sterz wrote:
> >> In the most common case, a notification mail might go to the members of an infra team of
> >> a organization, where the identities of other team members is not really sensitive information.
> >> I'd actually go as far and say that the info "who else was notified" is actually quite valuable
> >> and useful to have.
> >>
> >> Then again, I can see the benefits of masking, e.g. in the case of PBS datastore notifications,
> >> which might go to non-admin users (e.g. when PBS is offered as a service a la Tuxis).
> >>
> >> I don't care that much whether this is opt-in or opt-out at the crate level, but at the
> >> 'sendmail target' level I'd make this configurable and opt-in (gut feeling and to not
> >> change the current behavior, I'd be happy to be convinced for another way :) )
> >>
> >> What are your thoughts about this?
> >
> > making this configurable sounds reasonable to me. i'd tend towards
> > making the masking opt-out, though. at least on a crate level. my
> > use-case for this crate is to send mails to all participants of a
> > training, so there disclosing the mail addresses of other participants
> > could be really bad (even legally actionable, afaict). i can see the
> > value of this information in a notification scenario. however, i think
> > the implications of forgetting to disclose this in some scenarios is
> > much less detrimental than doing so in scenarios where we don't want to
> > disclose them.
> >
>
> I don't think we have to decide it for proxmox-notify's targets now.
> I'd suggest making masking default at the crate level with some way to disable it in the
> builder. In proxmox-notify, I'd opt-out for now to keep the current behavior.
> In the future we can easily add configuration parameter to the sendmail target config to
> configure this; then we can also reevaluate what a sane default would be at the target level.
> At the same time we can add the same behavior to smtp targets then so that
> the behavior is consistent with the sendmail target.

yup, that sounds sensible and is what i am currently work on, will
send a v2 later today.


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


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

* Re: [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate
  2024-11-29 10:53 [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Shannon Sterz
                   ` (4 preceding siblings ...)
  2024-12-02 10:20 ` Lukas Wagner
@ 2024-12-02 13:00 ` Shannon Sterz
  5 siblings, 0 replies; 11+ messages in thread
From: Shannon Sterz @ 2024-12-02 13:00 UTC (permalink / raw)
  To: Shannon Sterz, pbs-devel

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

On Fri Nov 29, 2024 at 11:53 AM 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
> - mulitpart/mixed support for mails with attachments
> - automatic nesting of multipart/alternative and multipart/mixed parts
> - masking recipients to avoid disclosing them to everyone
> - 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>
> ---
>  Cargo.toml                  |   2 +
>  proxmox-sendmail/Cargo.toml |  16 +
>  proxmox-sendmail/src/lib.rs | 664 ++++++++++++++++++++++++++++++++++++
>  3 files changed, 682 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..c97c3186
> --- /dev/null
> +++ b/proxmox-sendmail/src/lib.rs
> @@ -0,0 +1,664 @@
> +//!
> +//! 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,
> +}
> +
> +struct Attachment<'a> {
> +    filename: String,
> +    mime: String,
> +    content: &'a [u8],
> +}
> +
> +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>>,
> +}
> +
> +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
> +    /// appropriatelly 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(),
> +        }
> +    }
> +
> +    /// 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.
> +    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 reciepient 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.
> +    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 attachements 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
> +    /// attachement 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
> +    /// attachement 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
> +    }
> +
> +    /// Sends the email. This will fail if no reciepient's 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 mut body = String::new();
> +        let file_boundary = format!("----_=_NextPart_001_{}", now);
> +        let html_boundary = format!("----_=_NextPart_002_{}", now);
> +
> +        let to = if self.to.len() > 1 {
> +            // don't disclose all recipients if the mail goes out to multiple
> +            &Recipient {
> +                name: Some("Undisclosed".to_string()),
> +                email: "noreply".to_string(),
> +            }
> +        } else {
> +            self.to
> +                .first()
> +                .expect("the checks before make sure there is at least one recipient")
> +        };
> +
> +        if !self.attachments.is_empty() {
> +            body.push_str("Content-Type: multipart/mixed;\n");
> +            body.push_str(&format!("\tboundary=\"{file_boundary}\"\n"));
> +            body.push_str("MIME-Version: 1.0\n");
> +        } else if self.body_html.is_some() {
> +            body.push_str("Content-Type: multipart/alternative;\n");
> +            body.push_str(&format!("\tboundary=\"{html_boundary}\"\n"));
> +            body.push_str("MIME-Version: 1.0\n");
> +        } else if !self.subject.is_ascii()
> +            || !self.mail_author.is_ascii()
> +            || !to.name.as_ref().map(|t| t.is_ascii()).unwrap_or(true)
> +        {
> +            body.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)
> +        };
> +
> +        body.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)
> +        };
> +
> +        body.push_str(&from);
> +
> +        let to = if let Some(name) = &to.name {
> +            if !name.is_ascii() {
> +                format!("To: =?utf-8?B?{}?= <{}>\n", base64::encode(&name), to.email)
> +            } else {
> +                format!("To: {} <{}>\n", name, to.email)
> +            }
> +        } else {
> +            format!("To: {}\n", to.email)
> +        };
> +
> +        body.push_str(&to);
> +
> +        let rfc2822_date = proxmox_time::epoch_to_rfc2822(now)
> +            .with_context(|| "could not convert epoch to rfc2822 date")?;
> +        body.push_str(&format!("Date: {rfc2822_date}\n"));
> +        body.push_str("Auto-Submitted: auto-generated;\n");
> +
> +        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}--"));
> +        }
> +
> +        for attachment in &self.attachments {
> +            let filename = &attachment.filename;
> +
> +            body.push_str(&format!("\n--{file_boundary}\n"));
> +            body.push_str(&format!(
> +                "Content-Type: {}; name=\"{filename}\"\n",
> +                attachment.mime
> +            ));
> +
> +            // both `filename` and `filename*` are included for additional compatability
> +            body.push_str(&format!(
> +                "Content-Disposition: attachment; filename=\"{filename}\"; filename*=UTF-8''{}\n",
> +                utf8_percent_encode(filename, RFC5987SET)
> +            ));
> +            body.push_str("Content-Transfer-Encoding: base64\n\n");
> +
> +            // wrap the base64 string every 72 characters. this improves compatability
> +            let base64 = base64::encode(attachment.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>();
> +            body.push_str(&base64);
> +        }
> +
> +        if !self.attachments.is_empty() {
> +            body.push_str(&format!("\n--{file_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_redaction() {
> +        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");
> +
> +        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 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--"#
> +        );
> +    }
> +}



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

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

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

Thread overview: 11+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2024-11-29 10:53 [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Shannon Sterz
2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 2/4] notify: switchi sendmail endpoint over to new crate Shannon Sterz
2024-12-02 10:20   ` Lukas Wagner
2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 3/4] sendmail: add mail-forwarder feature Shannon Sterz
2024-11-29 10:53 ` [pbs-devel] [PATCH proxmox 4/4] notify: use proxmox-sendmail forward implementation Shannon Sterz
2024-11-29 14:38 ` [pbs-devel] [PATCH proxmox 1/4] sendmail: add sendmail crate Thomas Lamprecht
2024-12-02 10:20 ` Lukas Wagner
2024-12-02 11:02   ` Shannon Sterz
2024-12-02 12:11     ` Lukas Wagner
2024-12-02 12:21       ` Shannon Sterz
2024-12-02 13:00 ` 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