* [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
* [pbs-devel] [PATCH proxmox v3 2/4] notify: switch sendmail endpoint over to new crate 2024-12-02 14:16 [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate Shannon Sterz @ 2024-12-02 14:16 ` Shannon Sterz 2024-12-02 14:16 ` [pbs-devel] [PATCH proxmox v3 3/4] sendmail: add mail-forwarder feature Shannon Sterz ` (3 subsequent siblings) 4 siblings, 0 replies; 8+ messages in thread From: Shannon Sterz @ 2024-12-02 14:16 UTC (permalink / raw) To: pbs-devel use the new `proxmox-sendmail` crate instead of the bespoke implementation in `proxmox-notify`. Signed-off-by: Shannon Sterz <s.sterz@proxmox.com> --- proxmox-notify/Cargo.toml | 3 +- proxmox-notify/src/endpoints/sendmail.rs | 172 ++--------------------- 2 files changed, 10 insertions(+), 165 deletions(-) diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml index b5b3719e..6e94930a 100644 --- a/proxmox-notify/Cargo.toml +++ b/proxmox-notify/Cargo.toml @@ -32,6 +32,7 @@ proxmox-human-byte.workspace = true proxmox-schema = { workspace = true, features = ["api-macro", "api-types"] } proxmox-section-config = { workspace = true } proxmox-serde.workspace = true +proxmox-sendmail = { workspace = true, optional = true } proxmox-sys = { workspace = true, optional = true } proxmox-time.workspace = true proxmox-uuid = { workspace = true, features = ["serde"] } @@ -39,7 +40,7 @@ proxmox-uuid = { workspace = true, features = ["serde"] } [features] default = ["sendmail", "gotify", "smtp", "webhook"] mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys"] -sendmail = ["dep:proxmox-sys", "dep:base64"] +sendmail = ["dep:proxmox-sys", "dep:base64", "dep:proxmox-sendmail"] gotify = ["dep:proxmox-http"] pve-context = ["dep:proxmox-sys"] pbs-context = ["dep:proxmox-sys"] diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs index d31b9672..c5e16fe4 100644 --- a/proxmox-notify/src/endpoints/sendmail.rs +++ b/proxmox-notify/src/endpoints/sendmail.rs @@ -137,15 +137,14 @@ impl Endpoint for SendmailEndpoint { .clone() .unwrap_or_else(|| context().default_sendmail_author()); - sendmail( - &recipients_str, - &subject, - &text_part, - &html_part, - &mailfrom, - &author, - ) - .map_err(|err| Error::NotifyFailed(self.config.name.clone(), err.into())) + let mut mail = Mail::new(&author, &mailfrom, &subject, &text_part) + .with_html_alt(&html_part) + .with_unmasked_recipients(); + + recipients_str.iter().for_each(|r| mail.add_recipient(r)); + + mail.send() + .map_err(|err| Error::NotifyFailed(self.config.name.clone(), err.into())) } #[cfg(feature = "mail-forwarder")] Content::ForwardedMail { raw, uid, .. } => { @@ -165,107 +164,6 @@ impl Endpoint for SendmailEndpoint { } } -/// Sends multi-part mail with text and/or html to a list of recipients -/// -/// Includes the header `Auto-Submitted: auto-generated`, so that auto-replies -/// (i.e. OOO replies) won't trigger. -/// ``sendmail`` is used for sending the mail. -fn sendmail( - mailto: &[&str], - subject: &str, - text: &str, - html: &str, - mailfrom: &str, - author: &str, -) -> Result<(), Error> { - if mailto.is_empty() { - return Err(Error::Generic( - "At least one recipient has to be specified!".into(), - )); - } - let now = proxmox_time::epoch_i64(); - let body = format_mail(mailto, mailfrom, author, subject, text, html, now)?; - - let mut sendmail_process = Command::new("/usr/sbin/sendmail") - .arg("-B") - .arg("8BITMIME") - .arg("-f") - .arg(mailfrom) - .arg("--") - .args(mailto) - .stdin(Stdio::piped()) - .spawn() - .map_err(|err| Error::Generic(format!("could not spawn sendmail process: {err}")))?; - - sendmail_process - .stdin - .take() - .expect("stdin already taken") - .write_all(body.as_bytes()) - .map_err(|err| Error::Generic(format!("couldn't write to sendmail stdin: {err}")))?; - - sendmail_process - .wait() - .map_err(|err| Error::Generic(format!("sendmail did not exit successfully: {err}")))?; - - Ok(()) -} - -fn format_mail( - mailto: &[&str], - mailfrom: &str, - author: &str, - subject: &str, - text: &str, - html: &str, - timestamp: i64, -) -> Result<String, Error> { - use std::fmt::Write as _; - - let recipients = mailto.join(","); - let boundary = format!("----_=_NextPart_001_{timestamp}"); - - let mut body = String::new(); - - // Format email header - body.push_str("Content-Type: multipart/alternative;\n"); - let _ = writeln!(body, "\tboundary=\"{boundary}\""); - body.push_str("MIME-Version: 1.0\n"); - - if !subject.is_ascii() { - let _ = writeln!(body, "Subject: =?utf-8?B?{}?=", base64::encode(subject)); - } else { - let _ = writeln!(body, "Subject: {subject}"); - } - let _ = writeln!(body, "From: {author} <{mailfrom}>"); - let _ = writeln!(body, "To: {recipients}"); - let rfc2822_date = proxmox_time::epoch_to_rfc2822(timestamp) - .map_err(|err| Error::Generic(format!("failed to format time: {err}")))?; - let _ = writeln!(body, "Date: {rfc2822_date}"); - body.push_str("Auto-Submitted: auto-generated;\n"); - body.push('\n'); - - // Format email body - body.push_str("This is a multi-part message in MIME format.\n"); - let _ = write!(body, "\n--{boundary}\n"); - - body.push_str("Content-Type: text/plain;\n"); - body.push_str("\tcharset=\"UTF-8\"\n"); - body.push_str("Content-Transfer-Encoding: 8bit\n"); - body.push('\n'); - body.push_str(text); - let _ = write!(body, "\n--{boundary}\n"); - - body.push_str("Content-Type: text/html;\n"); - body.push_str("\tcharset=\"UTF-8\"\n"); - body.push_str("Content-Transfer-Encoding: 8bit\n"); - body.push('\n'); - body.push_str(html); - let _ = write!(body, "\n--{boundary}--"); - - Ok(body) -} - /// Forwards an email message to a given list of recipients. /// /// ``sendmail`` is used for sending the mail, thus `message` must be @@ -313,57 +211,3 @@ fn forward(mailto: &[&str], mailfrom: &str, message: &[u8], uid: Option<u32>) -> Ok(()) } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn email_without_recipients() { - let result = sendmail(&[], "Subject2", "", "<b>HTML</b>", "root", "Proxmox"); - assert!(result.is_err()); - } - - #[test] - fn test_format_mail_multipart() { - let message = format_mail( - &["Tony Est <test@example.com>"], - "foobar@example.com", - "Fred Oobar", - "This is the subject", - "This is the plain body", - "<body>This is the HTML body</body>", - 1718977850, - ) - .expect("format_message failed"); - - assert_eq!( - message, - r#"Content-Type: multipart/alternative; - boundary="----_=_NextPart_001_1718977850" -MIME-Version: 1.0 -Subject: This is the subject -From: Fred Oobar <foobar@example.com> -To: Tony Est <test@example.com> -Date: Fri, 21 Jun 2024 15:50:50 +0200 -Auto-Submitted: auto-generated; - -This is a multi-part message in MIME format. - -------_=_NextPart_001_1718977850 -Content-Type: text/plain; - charset="UTF-8" -Content-Transfer-Encoding: 8bit - -This is the plain body -------_=_NextPart_001_1718977850 -Content-Type: text/html; - charset="UTF-8" -Content-Transfer-Encoding: 8bit - -<body>This is the HTML body</body> -------_=_NextPart_001_1718977850--"# - .to_owned() - ); - } -} -- 2.39.5 _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel ^ permalink raw reply [flat|nested] 8+ messages in thread
* [pbs-devel] [PATCH proxmox v3 3/4] sendmail: add mail-forwarder feature 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 ` Shannon Sterz 2024-12-02 14:16 ` [pbs-devel] [PATCH proxmox v3 4/4] notify: use proxmox-sendmail forward implementation Shannon Sterz ` (2 subsequent siblings) 4 siblings, 0 replies; 8+ messages in thread From: Shannon Sterz @ 2024-12-02 14:16 UTC (permalink / raw) To: pbs-devel this moves the mail forwarding implementation from `proxmox-notify` into `proxmox-sendmail` to cover more `sendmail` related use-cases in one place. Signed-off-by: Shannon Sterz <s.sterz@proxmox.com> --- proxmox-sendmail/Cargo.toml | 4 +++ proxmox-sendmail/src/lib.rs | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/proxmox-sendmail/Cargo.toml b/proxmox-sendmail/Cargo.toml index 790b324b..e04e2595 100644 --- a/proxmox-sendmail/Cargo.toml +++ b/proxmox-sendmail/Cargo.toml @@ -14,3 +14,7 @@ anyhow = { workspace = true } base64 = { workspace = true } percent-encoding = { workspace = true } proxmox-time = { workspace = true } + +[features] +default = [] +mail-forwarder = [] diff --git a/proxmox-sendmail/src/lib.rs b/proxmox-sendmail/src/lib.rs index e6cb258e..e7e2982f 100644 --- a/proxmox-sendmail/src/lib.rs +++ b/proxmox-sendmail/src/lib.rs @@ -291,6 +291,56 @@ impl<'a> Mail<'a> { Ok(()) } + /// Forwards an email message to a given list of recipients. + /// + /// `message` must be compatible with ``sendmail`` (the message is piped into stdin unmodified). + #[cfg(feature = "mail-forwarder")] + pub fn forward( + mailto: &[&str], + mailfrom: &str, + message: &[u8], + uid: Option<u32>, + ) -> Result<(), Error> { + use std::os::unix::process::CommandExt; + + if mailto.is_empty() { + bail!("At least one recipient has to be specified!"); + } + + let mut builder = Command::new("/usr/sbin/sendmail"); + + builder + .args([ + "-N", "never", // never send DSN (avoid mail loops) + "-f", mailfrom, "--", + ]) + .args(mailto) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + if let Some(uid) = uid { + builder.uid(uid); + } + + let mut sendmail_process = builder + .spawn() + .with_context(|| "could not spawn sendmail process")?; + + sendmail_process + .stdin + .take() + .unwrap() + .write_all(message) + .with_context(|| "couldn't write to sendmail stdin")?; + + sendmail_process + .wait() + .with_context(|| "sendmail did not exit successfully")?; + + Ok(()) + } + fn format_mail(&self, now: i64) -> Result<String, Error> { use std::fmt::Write; @@ -442,6 +492,13 @@ mod test { assert!(result.is_err()); } + #[test] + #[cfg(feature = "mail-forwarder")] + fn forwarding_without_recipients_fails() { + let result = Mail::forward(&[], "me@example.com", String::from("text").as_bytes(), None); + assert!(result.is_err()); + } + #[test] fn simple_ascii_text_mail() { let mail = Mail::new( -- 2.39.5 _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel ^ permalink raw reply [flat|nested] 8+ messages in thread
* [pbs-devel] [PATCH proxmox v3 4/4] notify: use proxmox-sendmail forward implementation 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 ` Shannon Sterz 2024-12-02 14:22 ` [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate Shannon Sterz 2024-12-02 14:45 ` [pbs-devel] applied-series: " Thomas Lamprecht 4 siblings, 0 replies; 8+ messages in thread From: Shannon Sterz @ 2024-12-02 14:16 UTC (permalink / raw) To: pbs-devel moves to depending on `proxmox-sendmail` for forwarding mails via `sendmail` too. Signed-off-by: Shannon Sterz <s.sterz@proxmox.com> --- proxmox-notify/Cargo.toml | 2 +- proxmox-notify/src/endpoints/sendmail.rs | 54 +----------------------- 2 files changed, 3 insertions(+), 53 deletions(-) diff --git a/proxmox-notify/Cargo.toml b/proxmox-notify/Cargo.toml index 6e94930a..725bd210 100644 --- a/proxmox-notify/Cargo.toml +++ b/proxmox-notify/Cargo.toml @@ -39,7 +39,7 @@ proxmox-uuid = { workspace = true, features = ["serde"] } [features] default = ["sendmail", "gotify", "smtp", "webhook"] -mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys"] +mail-forwarder = ["dep:mail-parser", "dep:proxmox-sys", "proxmox-sendmail/mail-forwarder"] sendmail = ["dep:proxmox-sys", "dep:base64", "dep:proxmox-sendmail"] gotify = ["dep:proxmox-http"] pve-context = ["dep:proxmox-sys"] diff --git a/proxmox-notify/src/endpoints/sendmail.rs b/proxmox-notify/src/endpoints/sendmail.rs index c5e16fe4..70b0f111 100644 --- a/proxmox-notify/src/endpoints/sendmail.rs +++ b/proxmox-notify/src/endpoints/sendmail.rs @@ -1,6 +1,4 @@ -use std::io::Write; -use std::process::{Command, Stdio}; - +use proxmox_sendmail::Mail; use serde::{Deserialize, Serialize}; use proxmox_schema::api_types::COMMENT_SCHEMA; @@ -148,7 +146,7 @@ impl Endpoint for SendmailEndpoint { } #[cfg(feature = "mail-forwarder")] Content::ForwardedMail { raw, uid, .. } => { - forward(&recipients_str, &mailfrom, raw, *uid) + Mail::forward(&recipients_str, &mailfrom, raw, *uid) .map_err(|err| Error::NotifyFailed(self.config.name.clone(), err.into())) } } @@ -163,51 +161,3 @@ impl Endpoint for SendmailEndpoint { self.config.disable.unwrap_or_default() } } - -/// Forwards an email message to a given list of recipients. -/// -/// ``sendmail`` is used for sending the mail, thus `message` must be -/// compatible with that (the message is piped into stdin unmodified). -#[cfg(feature = "mail-forwarder")] -fn forward(mailto: &[&str], mailfrom: &str, message: &[u8], uid: Option<u32>) -> Result<(), Error> { - use std::os::unix::process::CommandExt; - - if mailto.is_empty() { - return Err(Error::Generic( - "At least one recipient has to be specified!".into(), - )); - } - - let mut builder = Command::new("/usr/sbin/sendmail"); - - builder - .args([ - "-N", "never", // never send DSN (avoid mail loops) - "-f", mailfrom, "--", - ]) - .args(mailto) - .stdin(Stdio::piped()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - if let Some(uid) = uid { - builder.uid(uid); - } - - let mut process = builder - .spawn() - .map_err(|err| Error::Generic(format!("could not spawn sendmail process: {err}")))?; - - process - .stdin - .take() - .unwrap() - .write_all(message) - .map_err(|err| Error::Generic(format!("couldn't write to sendmail stdin: {err}")))?; - - process - .wait() - .map_err(|err| Error::Generic(format!("sendmail did not exit successfully: {err}")))?; - - Ok(()) -} -- 2.39.5 _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel ^ permalink raw reply [flat|nested] 8+ messages in thread
* Re: [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate 2024-12-02 14:16 [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate Shannon Sterz ` (2 preceding siblings ...) 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 ` Shannon Sterz 2024-12-02 14:33 ` Lukas Wagner 2024-12-02 14:45 ` [pbs-devel] applied-series: " Thomas Lamprecht 4 siblings, 1 reply; 8+ messages in thread From: Shannon Sterz @ 2024-12-02 14:22 UTC (permalink / raw) To: Shannon Sterz, pbs-devel 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@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@proxmox.com> Reviewed-by: Lukas Wagner <l.wagner@proxmox.com> just thought i'd quickly mention this before it gets lost > --- > > 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
* Re: [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate 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 0 siblings, 1 reply; 8+ messages in thread From: Lukas Wagner @ 2024-12-02 14:33 UTC (permalink / raw) To: Proxmox Backup Server development discussion, Shannon Sterz On 2024-12-02 15:22, Shannon Sterz wrote: > 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@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@proxmox.com> > Reviewed-by: Lukas Wagner <l.wagner@proxmox.com> > > just thought i'd quickly mention this before it gets lost > Changes look good, so no objection from me. Only tested v1 and v2, but since this has some serious test coverage which I assume you have run before submitting v3, I think the T-b is also okay :) -- - Lukas _______________________________________________ pbs-devel mailing list pbs-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pbs-devel ^ permalink raw reply [flat|nested] 8+ messages in thread
* Re: [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate 2024-12-02 14:33 ` Lukas Wagner @ 2024-12-02 14:42 ` Shannon Sterz 0 siblings, 0 replies; 8+ messages in thread From: Shannon Sterz @ 2024-12-02 14:42 UTC (permalink / raw) To: Lukas Wagner, Proxmox Backup Server development discussion On Mon Dec 2, 2024 at 3:33 PM CET, Lukas Wagner wrote: > > > On 2024-12-02 15:22, Shannon Sterz wrote: > > 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@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@proxmox.com> > > Reviewed-by: Lukas Wagner <l.wagner@proxmox.com> > > > > just thought i'd quickly mention this before it gets lost > > > > Changes look good, so no objection from me. > Only tested v1 and v2, but since this has some serious test coverage > which I assume you have run before submitting v3, I think > the T-b is also okay :) i did and the changes should be contained to proxmox-sendmail in v3 so hopefully that is all fine. thanks for the quick response time! _______________________________________________ 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
* [pbs-devel] applied-series: [PATCH proxmox v3 1/4] sendmail: add sendmail crate 2024-12-02 14:16 [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate Shannon Sterz ` (3 preceding siblings ...) 2024-12-02 14:22 ` [pbs-devel] [PATCH proxmox v3 1/4] sendmail: add sendmail crate Shannon Sterz @ 2024-12-02 14:45 ` Thomas Lamprecht 4 siblings, 0 replies; 8+ messages in thread From: Thomas Lamprecht @ 2024-12-02 14:45 UTC (permalink / raw) To: Proxmox Backup Server development discussion, Shannon Sterz Am 02.12.24 um 15:16 schrieb Shannon Sterz: > add the `proxmox-sendmail` crate that makes it easier to send mails via > the `sendmail` utility. features include: > > - multipart/alternative support for html+plain text mails > - 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 > > applied series and uploaded the new debian rust-source package to our devel repo, thanks! _______________________________________________ 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 a public inbox, see mirroring instructions for how to clone and mirror all data and code used for this inbox