[pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate
Shannon Sterz
s.sterz at proxmox.com
Mon Dec 2 15:22:31 CET 2024
On Mon Dec 2, 2024 at 3:16 PM CET, Shannon Sterz wrote:
> add the `proxmox-sendmail` crate that makes it easier to send mails via
> the `sendmail` utility. features include:
>
> - multipart/alternative support for html+plain text mails
> - multipart/mixed support for mails with attachments
> - automatic nesting of multipart/alternative and multipart/mixed parts
> - masks multiple receivers by default, can be disabled
> - encoding Subject, To, From, and attachment file names correctly
> - adding an `Auto-Submitted` header to avoid triggering automated mails
>
> also includes several tests to ensure that mails are formatted
> correctly. debian packaging is also provided.
>
> Signed-off-by: Shannon Sterz <s.sterz at proxmox.com>
forgot to add this in the v2 already, but since that is now obsolete,
but unless Lukas objects this should probably include:
Tested-by: Lukas Wagner <l.wagner at proxmox.com>
Reviewed-by: Lukas Wagner <l.wagner at proxmox.com>
just thought i'd quickly mention this before it gets lost
> ---
>
> changes since v2 (thanks @ Lukas Wagner <l.wagner at proxmox.com>)
> - added debian packaging
> - change instances of `push_str(&format!(..)` over to `writeln!(..)`
> and `write!(..)`
>
> changes since v1 (thanks @ Lukas Wagner <l.wagner at 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 at 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 at 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 at 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 at 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 at example.com", "hi", "body").send();
> + assert!(result.is_err());
> + }
> +
> + #[test]
> + fn simple_ascii_text_mail() {
> + let mail = Mail::new(
> + "Sender Name",
> + "mailfrom at example.com",
> + "Subject Line",
> + "This is just ascii text.\nNothing too special.",
> + )
> + .with_recipient_and_name("Receiver Name", "receiver at example.com");
> +
> + let body = mail.format_mail(0).expect("could not format mail");
> +
> + assert_eq!(
> + body,
> + r#"Subject: Subject Line
> +From: Sender Name <mailfrom at example.com>
> +To: Receiver Name <receiver at 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 at example.com",
> + "Subject Line",
> + "This is just ascii text.\nNothing too special.",
> + )
> + .with_recipient_and_name("Receiver Name", "receiver at example.com")
> + .with_recipient("two at example.com")
> + .with_recipient_and_name("mäx müstermänn", "mm at example.com");
> +
> + let body = mail.format_mail(0).expect("could not format mail");
> +
> + assert_eq!(
> + body,
> + r#"Subject: Subject Line
> +From: Sender Name <mailfrom at 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 at example.com",
> + "Subject Line",
> + "This is just ascii text.\nNothing too special.",
> + )
> + .with_recipient_and_name("Receiver Name", "receiver at example.com")
> + .with_recipient("two at example.com")
> + .with_recipient_and_name("mäx müstermänn", "mm at 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 at example.com>
> +To: Receiver Name <receiver at example.com>, two at example.com, =?utf-8?B?bcOkeCBtw7xzdGVybcOkbm4=?= <mm at 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 at 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 at 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 at example.com>
> +To: =?utf-8?B?UmVjZWl2ZXIgTmFtZfCfk6k=?= <receiver at 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 at example.com",
> + "Subject Line",
> + "Lorem Ipsum Dolor Sit\nAmet",
> + )
> + .with_recipient("receiver at 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 at example.com>
> +To: receiver at 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 at example.com",
> + "Subject Line",
> + "Lorem Ipsum Dolor Sit\nAmet",
> + )
> + .with_recipient_and_name("Receiver Name", "receiver at 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 at example.com>
> +To: Receiver Name <receiver at 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 at example.com",
> + "Subject Line",
> + "Lorem Ipsum Dolor Sit\nAmet",
> + )
> + .with_recipient_and_name("Receiver Name", "receiver at 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 at example.com>
> +To: Receiver Name <receiver at 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 at example.com",
> + "This is the subject",
> + "This is the plain body",
> + )
> + .with_recipient_and_name("Tony Est", "test at 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 at example.com>
> +To: Tony Est <test at 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
More information about the pbs-devel
mailing list