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

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

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

also includes several tests to ensure that mails are formatted
correctly. debian packaging is also provided.

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

changes since v2 (thanks @ Lukas Wagner <l.wagner@proxmox.com>)
    - added debian packaging
    - change instances of `push_str(&format!(..)` over to `writeln!(..)`
      and `write!(..)`

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

 Cargo.toml                            |   2 +
 proxmox-sendmail/Cargo.toml           |  16 +
 proxmox-sendmail/debian/changelog     |   5 +
 proxmox-sendmail/debian/control       |  43 ++
 proxmox-sendmail/debian/copyright     |  18 +
 proxmox-sendmail/debian/debcargo.toml |   7 +
 proxmox-sendmail/src/lib.rs           | 779 ++++++++++++++++++++++++++
 7 files changed, 870 insertions(+)
 create mode 100644 proxmox-sendmail/Cargo.toml
 create mode 100644 proxmox-sendmail/debian/changelog
 create mode 100644 proxmox-sendmail/debian/control
 create mode 100644 proxmox-sendmail/debian/copyright
 create mode 100644 proxmox-sendmail/debian/debcargo.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/debian/changelog b/proxmox-sendmail/debian/changelog
new file mode 100644
index 00000000..71d7c9f8
--- /dev/null
+++ b/proxmox-sendmail/debian/changelog
@@ -0,0 +1,5 @@
+rust-proxmox-sendmail (0.1.0-1) bookworm; urgency=medium
+
+  * Initial release.
+
+ -- Proxmox Support Team <support@proxmox.com>  Mon, 02 Dec 2024 14:47:42 +0100
diff --git a/proxmox-sendmail/debian/control b/proxmox-sendmail/debian/control
new file mode 100644
index 00000000..dfc8b9bf
--- /dev/null
+++ b/proxmox-sendmail/debian/control
@@ -0,0 +1,43 @@
+Source: rust-proxmox-sendmail
+Section: rust
+Priority: optional
+Build-Depends: debhelper-compat (= 13),
+ dh-sequence-cargo,
+ cargo:native <!nocheck>,
+ rustc:native (>= 1.80) <!nocheck>,
+ libstd-rust-dev <!nocheck>,
+ librust-anyhow-1+default-dev <!nocheck>,
+ librust-base64-0.13+default-dev <!nocheck>,
+ librust-percent-encoding-2+default-dev (>= 2.1-~~) <!nocheck>,
+ librust-proxmox-time-2+default-dev <!nocheck>
+Maintainer: Proxmox Support Team <support@proxmox.com>
+Standards-Version: 4.7.0
+Vcs-Git: git://git.proxmox.com/git/proxmox.git
+Vcs-Browser: https://git.proxmox.com/?p=proxmox.git
+Homepage: https://proxmox.com
+X-Cargo-Crate: proxmox-sendmail
+Rules-Requires-Root: no
+
+Package: librust-proxmox-sendmail-dev
+Architecture: any
+Multi-Arch: same
+Depends:
+ ${misc:Depends},
+ librust-anyhow-1+default-dev,
+ librust-base64-0.13+default-dev,
+ librust-percent-encoding-2+default-dev (>= 2.1-~~),
+ librust-proxmox-time-2+default-dev
+Provides:
+ librust-proxmox-sendmail+default-dev (= ${binary:Version}),
+ librust-proxmox-sendmail+mail-forwarder-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0+default-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0+mail-forwarder-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0.1-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0.1+default-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0.1+mail-forwarder-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0.1.0-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0.1.0+default-dev (= ${binary:Version}),
+ librust-proxmox-sendmail-0.1.0+mail-forwarder-dev (= ${binary:Version})
+Description: Rust crate "proxmox-sendmail" - Rust source code
+ Source code for Debianized Rust crate "proxmox-sendmail"
diff --git a/proxmox-sendmail/debian/copyright b/proxmox-sendmail/debian/copyright
new file mode 100644
index 00000000..0d9eab3e
--- /dev/null
+++ b/proxmox-sendmail/debian/copyright
@@ -0,0 +1,18 @@
+Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
+
+Files:
+ *
+Copyright: 2019 - 2023 Proxmox Server Solutions GmbH <support@proxmox.com>
+License: AGPL-3.0-or-later
+ This program is free software: you can redistribute it and/or modify it under
+ the terms of the GNU Affero General Public License as published by the Free
+ Software Foundation, either version 3 of the License, or (at your option) any
+ later version.
+ .
+ This program is distributed in the hope that it will be useful, but WITHOUT
+ ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
+ FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
+ details.
+ .
+ You should have received a copy of the GNU Affero General Public License along
+ with this program. If not, see <https://www.gnu.org/licenses/>.
diff --git a/proxmox-sendmail/debian/debcargo.toml b/proxmox-sendmail/debian/debcargo.toml
new file mode 100644
index 00000000..b7864cdb
--- /dev/null
+++ b/proxmox-sendmail/debian/debcargo.toml
@@ -0,0 +1,7 @@
+overlay = "."
+crate_src_path = ".."
+maintainer = "Proxmox Support Team <support@proxmox.com>"
+
+[source]
+vcs_git = "git://git.proxmox.com/git/proxmox.git"
+vcs_browser = "https://git.proxmox.com/?p=proxmox.git"
diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs
new file mode 100644
index 00000000..e6cb258e
--- /dev/null
+++ b/proxmox-sendmail/src/lib.rs
@@ -0,0 +1,779 @@
+//!
+//! This library implements the [`Mail`] trait which makes it easy to send emails with attachments
+//! and alternative html parts to one or multiple receivers via ``sendmail``.
+//!
+
+use std::io::Write;
+use std::process::{Command, Stdio};
+
+use anyhow::{bail, Context, Error};
+use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
+
+// Characters in this set will be encoded, so reproduce the inverse of the set described by RFC5987
+// Section 3.2.1 `attr-char`, as that describes all characters that **don't** need encoding:
+//
+// https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1
+//
+// `CONTROLS` contains all control characters 0x00 - 0x1f and 0x7f as well as all non-ascii
+// characters, so we need to add all characters here that aren't described in `attr-char` that are
+// in the range 0x20-0x7e
+const RFC5987SET: &AsciiSet = &CONTROLS
+    .add(b' ')
+    .add(b'"')
+    .add(b'%')
+    .add(b'&')
+    .add(b'\'')
+    .add(b'(')
+    .add(b')')
+    .add(b'*')
+    .add(b',')
+    .add(b'/')
+    .add(b':')
+    .add(b';')
+    .add(b'<')
+    .add(b'=')
+    .add(b'>')
+    .add(b'?')
+    .add(b'@')
+    .add(b'[')
+    .add(b'\\')
+    .add(b']')
+    .add(b'{')
+    .add(b'}');
+
+struct Recipient {
+    name: Option<String>,
+    email: String,
+}
+
+impl Recipient {
+    // Returns true if the name of the recipient is undefined or contains only ascii characters
+    fn is_ascii(&self) -> bool {
+        self.name.as_ref().map(|n| n.is_ascii()).unwrap_or(true)
+    }
+
+    fn format_recipient(&self) -> String {
+        if let Some(name) = &self.name {
+            if !name.is_ascii() {
+                format!("=?utf-8?B?{}?= <{}>", base64::encode(name), self.email)
+            } else {
+                format!("{name} <{}>", self.email)
+            }
+        } else {
+            self.email.to_string()
+        }
+    }
+}
+
+struct Attachment<'a> {
+    filename: String,
+    mime: String,
+    content: &'a [u8],
+}
+
+impl<'a> Attachment<'a> {
+    fn format_attachment(&self, file_boundary: &str) -> String {
+        use std::fmt::Write;
+
+        let mut attachment = String::new();
+
+        let _ = writeln!(attachment, "\n--{file_boundary}");
+        let _ = writeln!(
+            attachment,
+            "Content-Type: {}; name=\"{}\"",
+            self.mime, self.filename
+        );
+
+        // both `filename` and `filename*` are included for additional compatability
+        let _ = writeln!(
+            attachment,
+            "Content-Disposition: attachment; filename=\"{}\"; filename*=UTF-8''{}",
+            self.filename,
+            utf8_percent_encode(&self.filename, RFC5987SET)
+        );
+        attachment.push_str("Content-Transfer-Encoding: base64\n\n");
+
+        // base64 encode the attachment and hard-wrap the base64 encoded string every 72
+        // characters. this improves compatability.
+        attachment.push_str(
+            &base64::encode(self.content)
+                .chars()
+                .enumerate()
+                .flat_map(|(i, c)| {
+                    if i != 0 && i % 72 == 0 {
+                        Some('\n')
+                    } else {
+                        None
+                    }
+                    .into_iter()
+                    .chain(std::iter::once(c))
+                })
+                .collect::<String>(),
+        );
+
+        attachment
+    }
+}
+
+/// This struct is used to define mails that are to be sent via the `sendmail` command.
+pub struct Mail<'a> {
+    mail_author: String,
+    mail_from: String,
+    subject: String,
+    to: Vec<Recipient>,
+    body_txt: String,
+    body_html: Option<String>,
+    attachments: Vec<Attachment<'a>>,
+    mask_participants: bool,
+}
+
+impl<'a> Mail<'a> {
+    /// Creates a new mail with a mail author, from address, subject line and a plain text body.
+    ///
+    /// Note: If the author's name or the subject line contains UTF-8 characters they will be
+    /// appropriately encoded.
+    pub fn new(mail_author: &str, mail_from: &str, subject: &str, body_txt: &str) -> Self {
+        Self {
+            mail_author: mail_author.to_string(),
+            mail_from: mail_from.to_string(),
+            subject: subject.to_string(),
+            to: Vec::new(),
+            body_txt: body_txt.to_string(),
+            body_html: None,
+            attachments: Vec::new(),
+            mask_participants: true,
+        }
+    }
+
+    /// Adds a recipient to the mail without specifying a name separately.
+    ///
+    /// Note: No formatting or encoding will be done here, the value will be passed to the `To:`
+    /// header directly.
+    pub fn add_recipient(&mut self, email: &str) {
+        self.to.push(Recipient {
+            name: None,
+            email: email.to_string(),
+        });
+    }
+
+    /// Builder-pattern method to conveniently add a recipient to an email without specifying a
+    /// name separately.
+    ///
+    /// Note: No formatting or encoding will be done here, the value will be passed to the `To:`
+    /// header directly.
+    pub fn with_recipient(mut self, email: &str) -> Self {
+        self.add_recipient(email);
+        self
+    }
+
+    /// Adds a recipient to the mail with a name.
+    ///
+    /// Notes:
+    ///
+    /// - If the name contains UTF-8 characters it will be encoded. Then the possibly encoded name
+    ///   and non-encoded email address will be passed to the `To:` header in this format:
+    ///   `{encoded_name} <{email}>`
+    /// - If multiple receivers are specified, they will be masked so as not to disclose them to
+    ///   other receivers. This can be disabled via [`Mail::unmask_recipients`] or
+    ///   [`Mail::with_unmasked_recipients`].
+    pub fn add_recipient_and_name(&mut self, name: &str, email: &str) {
+        self.to.push(Recipient {
+            name: Some(name.to_string()),
+            email: email.to_string(),
+        });
+    }
+
+    /// Builder-style method to conveniently add a recipient with a name to an email.
+    ///
+    /// Notes:
+    ///
+    /// - If the name contains UTF-8 characters it will be encoded. Then the possibly encoded name
+    ///   and non-encoded email address will be passed to the `To:` header in this format:
+    ///   `{encoded_name} <{email}>`
+    /// - If multiple receivers are specified, they will be masked so as not to disclose them to
+    ///   other receivers. This can be disabled via [`Mail::unmask_recipients`] or
+    ///   [`Mail::with_unmasked_recipients`].
+    pub fn with_recipient_and_name(mut self, name: &str, email: &str) -> Self {
+        self.add_recipient_and_name(name, email);
+        self
+    }
+
+    /// Adds an attachment with a specified file name and mime-type to an email.
+    ///
+    /// Note: Adding attachments triggers `multipart/mixed` mode.
+    pub fn add_attachment(&mut self, filename: &str, mime_type: &str, content: &'a [u8]) {
+        self.attachments.push(Attachment {
+            filename: filename.to_string(),
+            mime: mime_type.to_string(),
+            content,
+        });
+    }
+
+    /// Builder-style method to conveniently add an attachment with a specific filename and
+    /// mime-type to an email.
+    ///
+    /// Note: Adding attachements triggers `multipart/mixed` mode.
+    pub fn with_attachment(mut self, filename: &str, mime_type: &str, content: &'a [u8]) -> Self {
+        self.add_attachment(filename, mime_type, content);
+        self
+    }
+
+    /// Set an alternative HTML part.
+    ///
+    /// Note: This triggers `multipart/alternative` mode. If both an HTML part and at least one
+    /// attachment are specified, the `multipart/alternative` part will be nested within the first
+    /// `multipart/mixed` part. This should ensure that the HTML is displayed properly by client's
+    /// that prioritize it over the plain text part (should be the default for most clients) while
+    /// also properly displaying the attachments.
+    pub fn set_html_alt(&mut self, body_html: &str) {
+        self.body_html.replace(body_html.to_string());
+    }
+
+    /// Builder-style method to add an alternative HTML part.
+    ///
+    /// Note: This triggers `multipart/alternative` mode. If both an HTML part and at least one
+    /// attachment are specified, the `multipart/alternative` part will be nested within the first
+    /// `multipart/mixed` part. This should ensure that the HTML is displayed properly by client's
+    /// that prioritize it over the plain text part (should be the default for most clients) while
+    /// also properly displaying the attachments.
+    pub fn with_html_alt(mut self, body_html: &str) -> Self {
+        self.set_html_alt(body_html);
+        self
+    }
+
+    /// This function ensures that recipients of the mail are not masked. Being able to see all
+    /// recipients of a mail can be helpful in, for example, notification scenarios.
+    pub fn unmask_recipients(&mut self) {
+        self.mask_participants = false;
+    }
+
+    /// Builder-style function that ensures that recipients of the mail are not masked. Being able
+    /// to see all recipients of a mail can be helpful in, for example, notification scenarios.
+    pub fn with_unmasked_recipients(mut self) -> Self {
+        self.unmask_recipients();
+        self
+    }
+
+    /// Sends the email. This will fail if no recipients have been added.
+    ///
+    /// Note: An `Auto-Submitted: auto-generated` header is added to avoid triggering OOO and
+    /// similar mails.
+    pub fn send(&self) -> Result<(), Error> {
+        if self.to.is_empty() {
+            bail!("no recipients provided for the mail, cannot send it.");
+        }
+
+        let now = proxmox_time::epoch_i64();
+        let body = self.format_mail(now)?;
+
+        let mut sendmail_process = Command::new("/usr/sbin/sendmail")
+            .arg("-B")
+            .arg("8BITMIME")
+            .arg("-f")
+            .arg(&self.mail_from)
+            .arg("--")
+            .args(self.to.iter().map(|p| &p.email).collect::<Vec<&String>>())
+            .stdin(Stdio::piped())
+            .spawn()
+            .with_context(|| "could not spawn sendmail process")?;
+
+        sendmail_process
+            .stdin
+            .as_ref()
+            .unwrap()
+            .write_all(body.as_bytes())
+            .with_context(|| "couldn't write to sendmail stdin")?;
+
+        sendmail_process
+            .wait()
+            .with_context(|| "sendmail did not exit successfully")?;
+
+        Ok(())
+    }
+
+    fn format_mail(&self, now: i64) -> Result<String, Error> {
+        use std::fmt::Write;
+
+        let file_boundary = format!("----_=_NextPart_001_{now}");
+        let html_boundary = format!("----_=_NextPart_002_{now}");
+
+        let mut mail = self.format_header(now, &file_boundary, &html_boundary)?;
+        mail.push_str(&self.format_body(&file_boundary, &html_boundary)?);
+
+        if !self.attachments.is_empty() {
+            mail.push_str(
+                &self
+                    .attachments
+                    .iter()
+                    .map(|a| a.format_attachment(&file_boundary))
+                    .collect::<String>(),
+            );
+
+            write!(mail, "\n--{file_boundary}--")?;
+        }
+
+        Ok(mail)
+    }
+
+    fn format_header(
+        &self,
+        now: i64,
+        file_boundary: &str,
+        html_boundary: &str,
+    ) -> Result<String, Error> {
+        use std::fmt::Write;
+
+        let mut header = String::new();
+
+        let encoded_to = if self.to.len() > 1 && self.mask_participants {
+            // if the receivers are masked, we know that they don't need to be encoded
+            false
+        } else {
+            // check if there is a recipient that needs encoding
+            self.to.iter().any(|r| !r.is_ascii())
+        };
+
+        if !self.attachments.is_empty() {
+            header.push_str("Content-Type: multipart/mixed;\n");
+            writeln!(header, "\tboundary=\"{file_boundary}\"")?;
+            header.push_str("MIME-Version: 1.0\n");
+        } else if self.body_html.is_some() {
+            header.push_str("Content-Type: multipart/alternative;\n");
+            writeln!(header, "\tboundary=\"{html_boundary}\"")?;
+            header.push_str("MIME-Version: 1.0\n");
+        } else if !self.subject.is_ascii() || !self.mail_author.is_ascii() || encoded_to {
+            header.push_str("MIME-Version: 1.0\n");
+        }
+
+        if !self.subject.is_ascii() {
+            writeln!(
+                header,
+                "Subject: =?utf-8?B?{}?=",
+                base64::encode(&self.subject)
+            )?;
+        } else {
+            writeln!(header, "Subject: {}", self.subject)?;
+        };
+
+        if !self.mail_author.is_ascii() {
+            writeln!(
+                header,
+                "From: =?utf-8?B?{}?= <{}>",
+                base64::encode(&self.mail_author),
+                self.mail_from
+            )?;
+        } else {
+            writeln!(header, "From: {} <{}>", self.mail_author, self.mail_from)?;
+        }
+
+        let to = if self.to.len() > 1 && self.mask_participants {
+            // don't disclose all recipients if the mail goes out to multiple
+            let recipient = Recipient {
+                name: Some("Undisclosed".to_string()),
+                email: "noreply".to_string(),
+            };
+
+            recipient.format_recipient()
+        } else {
+            self.to
+                .iter()
+                .map(Recipient::format_recipient)
+                .collect::<Vec<String>>()
+                .join(", ")
+        };
+
+        writeln!(header, "To: {to}")?;
+
+        let rfc2822_date = proxmox_time::epoch_to_rfc2822(now)
+            .with_context(|| "could not convert epoch to rfc2822 date")?;
+        writeln!(header, "Date: {rfc2822_date}")?;
+        header.push_str("Auto-Submitted: auto-generated;\n");
+
+        Ok(header)
+    }
+
+    fn format_body(&self, file_boundary: &str, html_boundary: &str) -> Result<String, Error> {
+        use std::fmt::Write;
+
+        let mut body = String::new();
+
+        if self.body_html.is_some() && !self.attachments.is_empty() {
+            body.push_str("\nThis is a multi-part message in MIME format.\n");
+            writeln!(body, "\n--{file_boundary}")?;
+            writeln!(
+                body,
+                "Content-Type: multipart/alternative; boundary=\"{html_boundary}\""
+            )?;
+            body.push_str("MIME-Version: 1.0\n");
+            writeln!(body, "\n--{html_boundary}")?;
+        } else if self.body_html.is_some() {
+            body.push_str("\nThis is a multi-part message in MIME format.\n");
+            writeln!(body, "\n--{html_boundary}")?;
+        } else if self.body_html.is_none() && !self.attachments.is_empty() {
+            body.push_str("\nThis is a multi-part message in MIME format.\n");
+            writeln!(body, "\n--{file_boundary}")?;
+        }
+
+        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 {
+            writeln!(body, "\n--{html_boundary}")?;
+            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);
+            write!(body, "\n--{html_boundary}--")?;
+        }
+
+        Ok(body)
+    }
+}
+
+#[cfg(test)]
+mod test {
+    use super::*;
+
+    #[test]
+    fn email_without_recipients_fails() {
+        let result = Mail::new("Sender", "mail@example.com", "hi", "body").send();
+        assert!(result.is_err());
+    }
+
+    #[test]
+    fn simple_ascii_text_mail() {
+        let mail = Mail::new(
+            "Sender Name",
+            "mailfrom@example.com",
+            "Subject Line",
+            "This is just ascii text.\nNothing too special.",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com");
+
+        let body = mail.format_mail(0).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"Subject: Subject Line
+From: Sender Name <mailfrom@example.com>
+To: Receiver Name <receiver@example.com>
+Date: Thu, 01 Jan 1970 01:00:00 +0100
+Auto-Submitted: auto-generated;
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This is just ascii text.
+Nothing too special."#
+        )
+    }
+
+    #[test]
+    fn multiple_receiver_masked() {
+        let mail = Mail::new(
+            "Sender Name",
+            "mailfrom@example.com",
+            "Subject Line",
+            "This is just ascii text.\nNothing too special.",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com")
+        .with_recipient("two@example.com")
+        .with_recipient_and_name("mäx müstermänn", "mm@example.com");
+
+        let body = mail.format_mail(0).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"Subject: Subject Line
+From: Sender Name <mailfrom@example.com>
+To: Undisclosed <noreply>
+Date: Thu, 01 Jan 1970 01:00:00 +0100
+Auto-Submitted: auto-generated;
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This is just ascii text.
+Nothing too special."#
+        )
+    }
+
+    #[test]
+    fn multiple_receiver_unmasked() {
+        let mail = Mail::new(
+            "Sender Name",
+            "mailfrom@example.com",
+            "Subject Line",
+            "This is just ascii text.\nNothing too special.",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com")
+        .with_recipient("two@example.com")
+        .with_recipient_and_name("mäx müstermänn", "mm@example.com")
+        .with_unmasked_recipients();
+
+        let body = mail.format_mail(0).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"MIME-Version: 1.0
+Subject: Subject Line
+From: Sender Name <mailfrom@example.com>
+To: Receiver Name <receiver@example.com>, two@example.com, =?utf-8?B?bcOkeCBtw7xzdGVybcOkbm4=?= <mm@example.com>
+Date: Thu, 01 Jan 1970 01:00:00 +0100
+Auto-Submitted: auto-generated;
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This is just ascii text.
+Nothing too special."#
+        )
+    }
+
+    #[test]
+    fn simple_utf8_text_mail() {
+        let mail = Mail::new(
+            "UTF-8 Sender Name 📧",
+            "differentfrom@example.com",
+            "Subject Line 🧑",
+            "This utf-8 email should handle emojis\n🧑📧\nand weird german characters: öäüß\nand more.",
+        )
+        .with_recipient_and_name("Receiver Name📩", "receiver@example.com");
+
+        let body = mail.format_mail(1732806251).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"MIME-Version: 1.0
+Subject: =?utf-8?B?U3ViamVjdCBMaW5lIPCfp5E=?=
+From: =?utf-8?B?VVRGLTggU2VuZGVyIE5hbWUg8J+Tpw==?= <differentfrom@example.com>
+To: =?utf-8?B?UmVjZWl2ZXIgTmFtZfCfk6k=?= <receiver@example.com>
+Date: Thu, 28 Nov 2024 16:04:11 +0100
+Auto-Submitted: auto-generated;
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This utf-8 email should handle emojis
+🧑📧
+and weird german characters: öäüß
+and more."#
+        )
+    }
+
+    #[test]
+    fn multipart_html_alternative() {
+        let mail = Mail::new(
+            "Sender Name",
+            "from@example.com",
+            "Subject Line",
+            "Lorem Ipsum Dolor Sit\nAmet",
+        )
+        .with_recipient("receiver@example.com")
+        .with_html_alt("<html lang=\"de-at\"><head></head><body>\n\t<pre>\n\t\tLorem Ipsum Dolor Sit Amet\n\t</pre>\n</body></html>");
+        let body = mail.format_mail(1732806251).expect("could not format mail");
+        assert_eq!(
+            body,
+            r#"Content-Type: multipart/alternative;
+	boundary="----_=_NextPart_002_1732806251"
+MIME-Version: 1.0
+Subject: Subject Line
+From: Sender Name <from@example.com>
+To: receiver@example.com
+Date: Thu, 28 Nov 2024 16:04:11 +0100
+Auto-Submitted: auto-generated;
+
+This is a multi-part message in MIME format.
+
+------_=_NextPart_002_1732806251
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+Lorem Ipsum Dolor Sit
+Amet
+------_=_NextPart_002_1732806251
+Content-Type: text/html;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+<html lang="de-at"><head></head><body>
+	<pre>
+		Lorem Ipsum Dolor Sit Amet
+	</pre>
+</body></html>
+------_=_NextPart_002_1732806251--"#
+        )
+    }
+
+    #[test]
+    fn multipart_plain_text_attachments_mixed() {
+        let bin: [u8; 62] = [
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
+        ];
+
+        let mail = Mail::new(
+            "Sender Name",
+            "from@example.com",
+            "Subject Line",
+            "Lorem Ipsum Dolor Sit\nAmet",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com")
+        .with_attachment("deadbeef.bin", "application/octet-stream", &bin);
+
+        let body = mail.format_mail(1732806251).expect("could not format mail");
+        assert_eq!(
+            body,
+            r#"Content-Type: multipart/mixed;
+	boundary="----_=_NextPart_001_1732806251"
+MIME-Version: 1.0
+Subject: Subject Line
+From: Sender Name <from@example.com>
+To: Receiver Name <receiver@example.com>
+Date: Thu, 28 Nov 2024 16:04:11 +0100
+Auto-Submitted: auto-generated;
+
+This is a multi-part message in MIME format.
+
+------_=_NextPart_001_1732806251
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+Lorem Ipsum Dolor Sit
+Amet
+------_=_NextPart_001_1732806251
+Content-Type: application/octet-stream; name="deadbeef.bin"
+Content-Disposition: attachment; filename="deadbeef.bin"; filename*=UTF-8''deadbeef.bin
+Content-Transfer-Encoding: base64
+
+3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
+3q2+796tvu8=
+------_=_NextPart_001_1732806251--"#
+        )
+    }
+
+    #[test]
+    fn multipart_plain_text_html_alternative_attachments() {
+        let bin: [u8; 62] = [
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef, 0xde, 0xad,
+            0xbe, 0xef, 0xde, 0xad, 0xbe, 0xef,
+        ];
+
+        let mail = Mail::new(
+            "Sender Name",
+            "from@example.com",
+            "Subject Line",
+            "Lorem Ipsum Dolor Sit\nAmet",
+        )
+        .with_recipient_and_name("Receiver Name", "receiver@example.com")
+        .with_attachment("deadbeef.bin", "application/octet-stream", &bin)
+        .with_attachment("🐄💀.bin", "image/bmp", &bin)
+        .with_html_alt("<html lang=\"de-at\"><head></head><body>\n\t<pre>\n\t\tLorem Ipsum Dolor Sit Amet\n\t</pre>\n</body></html>");
+
+        let body = mail.format_mail(1732806251).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"Content-Type: multipart/mixed;
+	boundary="----_=_NextPart_001_1732806251"
+MIME-Version: 1.0
+Subject: Subject Line
+From: Sender Name <from@example.com>
+To: Receiver Name <receiver@example.com>
+Date: Thu, 28 Nov 2024 16:04:11 +0100
+Auto-Submitted: auto-generated;
+
+This is a multi-part message in MIME format.
+
+------_=_NextPart_001_1732806251
+Content-Type: multipart/alternative; boundary="----_=_NextPart_002_1732806251"
+MIME-Version: 1.0
+
+------_=_NextPart_002_1732806251
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+Lorem Ipsum Dolor Sit
+Amet
+------_=_NextPart_002_1732806251
+Content-Type: text/html;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+<html lang="de-at"><head></head><body>
+	<pre>
+		Lorem Ipsum Dolor Sit Amet
+	</pre>
+</body></html>
+------_=_NextPart_002_1732806251--
+------_=_NextPart_001_1732806251
+Content-Type: application/octet-stream; name="deadbeef.bin"
+Content-Disposition: attachment; filename="deadbeef.bin"; filename*=UTF-8''deadbeef.bin
+Content-Transfer-Encoding: base64
+
+3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
+3q2+796tvu8=
+------_=_NextPart_001_1732806251
+Content-Type: image/bmp; name="🐄💀.bin"
+Content-Disposition: attachment; filename="🐄💀.bin"; filename*=UTF-8''%F0%9F%90%84%F0%9F%92%80.bin
+Content-Transfer-Encoding: base64
+
+3q2+796tvu/erb7v3q3erb7v3q2+796tvu/erd6tvu/erb7v3q2+796t3q2+796tvu/erb7v
+3q2+796tvu8=
+------_=_NextPart_001_1732806251--"#
+        )
+    }
+
+    #[test]
+    fn test_format_mail_multipart() {
+        let mail = Mail::new(
+            "Fred Oobar",
+            "foobar@example.com",
+            "This is the subject",
+            "This is the plain body",
+        )
+        .with_recipient_and_name("Tony Est", "test@example.com")
+        .with_html_alt("<body>This is the HTML body</body>");
+
+        let body = mail.format_mail(1718977850).expect("could not format mail");
+
+        assert_eq!(
+            body,
+            r#"Content-Type: multipart/alternative;
+	boundary="----_=_NextPart_002_1718977850"
+MIME-Version: 1.0
+Subject: This is the subject
+From: Fred Oobar <foobar@example.com>
+To: Tony Est <test@example.com>
+Date: Fri, 21 Jun 2024 15:50:50 +0200
+Auto-Submitted: auto-generated;
+
+This is a multi-part message in MIME format.
+
+------_=_NextPart_002_1718977850
+Content-Type: text/plain;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+This is the plain body
+------_=_NextPart_002_1718977850
+Content-Type: text/html;
+	charset="UTF-8"
+Content-Transfer-Encoding: 8bit
+
+<body>This is the HTML body</body>
+------_=_NextPart_002_1718977850--"#
+        );
+    }
+}
--
2.39.5



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

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

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

Thread overview: 8+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2024-12-02 14:16 [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate Shannon Sterz
2024-12-02 14:16 ` [pbs-devel] [PATCH proxmox v3 2/4] notify: switch sendmail endpoint over to new crate Shannon Sterz
2024-12-02 14:16 ` [pbs-devel] [PATCH proxmox v3 3/4] sendmail: add mail-forwarder feature Shannon Sterz
2024-12-02 14:16 ` [pbs-devel] [PATCH proxmox v3 4/4] notify: use proxmox-sendmail forward implementation Shannon Sterz
2024-12-02 14:22 ` [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate Shannon Sterz
2024-12-02 14:33   ` Lukas Wagner
2024-12-02 14:42     ` Shannon Sterz
2024-12-02 14:45 ` [pbs-devel] applied-series: " Thomas Lamprecht

This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal