From: Shannon Sterz <s.sterz@proxmox.com>
To: pbs-devel@lists.proxmox.com
Subject: [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate
Date: Mon, 2 Dec 2024 15:16:52 +0100 [thread overview]
Message-ID: <20241202141655.254472-1-s.sterz@proxmox.com> (raw)
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
next reply other threads:[~2024-12-02 14:17 UTC|newest]
Thread overview: 8+ messages / expand[flat|nested] mbox.gz Atom feed top
2024-12-02 14:16 Shannon Sterz [this message]
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
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20241202141655.254472-1-s.sterz@proxmox.com \
--to=s.sterz@proxmox.com \
--cc=pbs-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox