mirror of
https://git.proxmox.com/git/proxmox
synced 2025-04-28 10:11:20 +00:00
sendmail: add sendmail crate
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> Reviewed-by: Lukas Wagner <l.wagner@proxmox.com> Tested-by: Lukas Wagner <l.wagner@proxmox.com> [ TL: update years in d/copyright ] Signed-off-by: Thomas Lamprecht <t.lamprecht@proxmox.com>
This commit is contained in:
parent
8a1166be4b
commit
a69e86dff1
@ -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" }
|
||||
|
16
proxmox-sendmail/Cargo.toml
Normal file
16
proxmox-sendmail/Cargo.toml
Normal file
@ -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 }
|
5
proxmox-sendmail/debian/changelog
Normal file
5
proxmox-sendmail/debian/changelog
Normal file
@ -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
|
43
proxmox-sendmail/debian/control
Normal file
43
proxmox-sendmail/debian/control
Normal file
@ -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"
|
18
proxmox-sendmail/debian/copyright
Normal file
18
proxmox-sendmail/debian/copyright
Normal file
@ -0,0 +1,18 @@
|
||||
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
|
||||
|
||||
Files:
|
||||
*
|
||||
Copyright: 2019 - 2024 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/>.
|
7
proxmox-sendmail/debian/debcargo.toml
Normal file
7
proxmox-sendmail/debian/debcargo.toml
Normal file
@ -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"
|
779
proxmox-sendmail/src/lib.rs
Normal file
779
proxmox-sendmail/src/lib.rs
Normal file
@ -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--"#
|
||||
);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user