mod attachment;
mod content_type;
mod encoded_word;
mod header;
mod mime_parse;
mod mime_render;
mod shared;
mod transfer_encoding;
use std::str::FromStr;
use email_message::{
Address, AddressList, ContentTransferEncoding, Header, Mailbox, Message, MessageId,
MessageValidationError,
};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc2822;
pub use encoded_word::decode_rfc2047_phrase;
use encoded_word::{
decode_rfc2047_words, encode_rfc2047_unstructured, escape_encoded_words_inside_quoted_strings,
};
use header::{
is_structured_header, parse_header_lines_bytes, push_header_line, render_address_list_header,
render_mailbox_header, split_headers_and_body_bytes,
};
use mime_render::build_render_payload;
pub use shared::{MAX_INPUT_BYTES, MAX_MULTIPART_DEPTH, MAX_MULTIPART_PARTS};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MessageParseError {
#[error("input is not valid UTF-8")]
InvalidUtf8,
#[error("invalid header line `{line}`")]
#[non_exhaustive]
InvalidHeaderLine {
line: String,
},
#[error("failed to parse mailbox from `{header}` header")]
#[non_exhaustive]
MailboxHeaderParse {
header: &'static str,
},
#[error("failed to parse address list from `{header}` header")]
#[non_exhaustive]
AddressHeaderParse {
header: &'static str,
},
#[error("failed to parse Date header as RFC 2822 datetime")]
#[non_exhaustive]
Date {
#[source]
source: time::error::Parse,
},
#[error("failed to parse Message-ID header")]
#[non_exhaustive]
MessageId {
#[source]
source: email_message::MessageIdParseError,
},
#[error("failed to parse MIME body: {details}")]
#[non_exhaustive]
MimeBodyParse {
details: String,
},
}
impl PartialEq for MessageParseError {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::InvalidUtf8, Self::InvalidUtf8)
| (Self::Date { .. }, Self::Date { .. })
| (Self::MessageId { .. }, Self::MessageId { .. }) => true,
(Self::InvalidHeaderLine { line: a }, Self::InvalidHeaderLine { line: b })
| (Self::MimeBodyParse { details: a }, Self::MimeBodyParse { details: b }) => a == b,
(Self::MailboxHeaderParse { header: a }, Self::MailboxHeaderParse { header: b })
| (Self::AddressHeaderParse { header: a }, Self::AddressHeaderParse { header: b }) => {
a == b
}
_ => false,
}
}
}
impl Eq for MessageParseError {}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum MessageRenderError {
#[error("header `{name}` contains raw newline characters")]
#[non_exhaustive]
HeaderContainsRawNewline {
name: String,
},
#[error("header `{name}` contains invalid control characters")]
#[non_exhaustive]
HeaderContainsControlCharacter {
name: String,
},
#[error("header `{name}` contains non-ASCII characters")]
#[non_exhaustive]
HeaderContainsNonAscii {
name: String,
},
#[error("header name `{name}` is invalid")]
#[non_exhaustive]
InvalidHeaderName {
name: String,
},
#[error("header `{name}` exceeds RFC 5322 hard line length limit")]
#[non_exhaustive]
HeaderLineTooLong {
name: String,
},
#[error("failed to format Date header as RFC 2822 datetime")]
DateFormat,
#[error("MIME boundary cannot be empty")]
EmptyMimeBoundary,
#[error("MIME boundary contains forbidden characters")]
InvalidMimeBoundary,
#[error("multipart boundary parameter does not match part boundary")]
MismatchedMimeBoundary,
#[error("multipart parts cannot be empty")]
EmptyMultipartParts,
#[error("multipart nesting exceeds maximum depth of {MAX_MULTIPART_DEPTH}")]
MimeNestingTooDeep,
#[error("multipart part must use a multipart content type")]
InvalidMultipartContentType,
#[error("attachment body variant is not supported")]
UnsupportedAttachmentBody,
#[error("attachment content-id is invalid")]
InvalidContentId,
#[error("message body variant is not supported")]
UnsupportedBody,
#[error(transparent)]
MessageValidation(#[from] MessageValidationError),
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderOptions {
pub include_bcc: bool,
pub soft_fold_at: Option<usize>,
}
impl RenderOptions {
#[must_use]
pub const fn new() -> Self {
Self {
include_bcc: false,
soft_fold_at: None,
}
}
#[must_use]
pub const fn with_include_bcc(mut self, value: bool) -> Self {
self.include_bcc = value;
self
}
#[must_use]
pub const fn with_soft_fold(mut self, soft_fold_at: usize) -> Self {
self.soft_fold_at = Some(soft_fold_at);
self
}
#[must_use]
pub const fn without_soft_fold(mut self) -> Self {
self.soft_fold_at = None;
self
}
}
#[allow(clippy::too_many_lines)]
pub fn parse_rfc822(input: &[u8]) -> Result<Message, MessageParseError> {
if input.len() > MAX_INPUT_BYTES {
return Err(MessageParseError::MimeBodyParse {
details: format!(
"input is {} bytes, exceeding maximum of {MAX_INPUT_BYTES}",
input.len()
),
});
}
let (raw_headers, raw_body) = split_headers_and_body_bytes(input);
let parsed_headers = parse_header_lines_bytes(raw_headers)?;
let mut from: Option<Mailbox> = None;
let mut sender: Option<Mailbox> = None;
let mut to: Vec<Address> = Vec::new();
let mut cc: Vec<Address> = Vec::new();
let mut bcc: Vec<Address> = Vec::new();
let mut reply_to: Vec<Address> = Vec::new();
let mut subject: Option<String> = None;
let mut date: Option<OffsetDateTime> = None;
let mut message_id: Option<MessageId> = None;
let mut root_content_type: Option<String> = None;
let mut root_content_transfer_encoding: Option<ContentTransferEncoding> = None;
let mut headers = Vec::new();
for (header_name, header_value) in parsed_headers {
let header_name_ref = header_name.as_str();
let header_value_ref = header_value.as_str();
let decoded_header_value = decode_rfc2047_words(header_value_ref);
let address_value = escape_encoded_words_inside_quoted_strings(header_value_ref);
if header_name_ref.eq_ignore_ascii_case("from") {
from = Some(
address_value
.parse::<Mailbox>()
.map_err(|_| MessageParseError::MailboxHeaderParse { header: "From" })?,
);
continue;
}
if header_name_ref.eq_ignore_ascii_case("sender") {
sender = Some(
address_value
.parse::<Mailbox>()
.map_err(|_| MessageParseError::MailboxHeaderParse { header: "Sender" })?,
);
continue;
}
if header_name_ref.eq_ignore_ascii_case("to") {
let mut parsed = AddressList::from_str(&address_value)
.map_err(|_| MessageParseError::AddressHeaderParse { header: "To" })?
.into_vec();
to.append(&mut parsed);
continue;
}
if header_name_ref.eq_ignore_ascii_case("cc") {
let mut parsed = AddressList::from_str(&address_value)
.map_err(|_| MessageParseError::AddressHeaderParse { header: "Cc" })?
.into_vec();
cc.append(&mut parsed);
continue;
}
if header_name_ref.eq_ignore_ascii_case("bcc") {
let mut parsed = AddressList::from_str(&address_value)
.map_err(|_| MessageParseError::AddressHeaderParse { header: "Bcc" })?
.into_vec();
bcc.append(&mut parsed);
continue;
}
if header_name_ref.eq_ignore_ascii_case("reply-to") {
let mut parsed = AddressList::from_str(&address_value)
.map_err(|_| MessageParseError::AddressHeaderParse { header: "Reply-To" })?
.into_vec();
reply_to.append(&mut parsed);
continue;
}
if header_name_ref.eq_ignore_ascii_case("subject") {
subject = Some(decoded_header_value.into_owned());
continue;
}
if header_name_ref.eq_ignore_ascii_case("date") {
date = Some(
OffsetDateTime::parse(header_value_ref.trim(), &Rfc2822)
.map_err(|source| MessageParseError::Date { source })?,
);
continue;
}
if header_name_ref.eq_ignore_ascii_case("message-id") {
message_id = Some(
MessageId::try_from(header_value_ref.trim())
.map_err(|source| MessageParseError::MessageId { source })?,
);
continue;
}
if header_name_ref.eq_ignore_ascii_case("content-type") {
root_content_type = Some(header_value);
continue;
}
if header_name_ref.eq_ignore_ascii_case("content-transfer-encoding") {
root_content_transfer_encoding = Some(
ContentTransferEncoding::from_str(header_value_ref).map_err(|_| {
MessageParseError::MimeBodyParse {
details: format!(
"invalid top-level content-transfer-encoding `{header_value_ref}`"
),
}
})?,
);
continue;
}
headers.push(Header::new(header_name, header_value).map_err(|error| {
MessageParseError::InvalidHeaderLine {
line: error.to_string(),
}
})?);
}
let body = mime_parse::parse_body(
raw_body,
root_content_type.as_deref(),
root_content_transfer_encoding,
)?;
let mut builder = Message::builder(body)
.to(to)
.cc(cc)
.bcc(bcc)
.reply_to(reply_to)
.headers(headers)
.attachments(Vec::new());
if let Some(from) = from {
builder = builder.from_mailbox(from);
}
if let Some(sender) = sender {
builder = builder.sender(sender);
}
if let Some(subject) = subject {
builder = builder.subject(subject);
}
if let Some(date) = date {
builder = builder.date(date);
}
if let Some(message_id) = message_id {
builder = builder.message_id(message_id);
}
Ok(builder.build_unchecked())
}
pub fn render_rfc822(message: &Message) -> Result<Vec<u8>, MessageRenderError> {
render_rfc822_with(message, &RenderOptions::default())
}
#[allow(clippy::too_many_lines)]
pub fn render_rfc822_with(
message: &Message,
options: &RenderOptions,
) -> Result<Vec<u8>, MessageRenderError> {
message.validate_basic()?;
let mut out = Vec::new();
if let Some(from) = message.from_mailbox() {
push_header_line(
&mut out,
"From",
&render_mailbox_header(from),
options.soft_fold_at,
)?;
}
if let Some(sender) = message.sender() {
push_header_line(
&mut out,
"Sender",
&render_mailbox_header(sender),
options.soft_fold_at,
)?;
}
if !message.to().is_empty() {
push_header_line(
&mut out,
"To",
&render_address_list_header(message.to()),
options.soft_fold_at,
)?;
}
if !message.cc().is_empty() {
push_header_line(
&mut out,
"Cc",
&render_address_list_header(message.cc()),
options.soft_fold_at,
)?;
}
if options.include_bcc && !message.bcc().is_empty() {
push_header_line(
&mut out,
"Bcc",
&render_address_list_header(message.bcc()),
options.soft_fold_at,
)?;
}
if !message.reply_to().is_empty() {
push_header_line(
&mut out,
"Reply-To",
&render_address_list_header(message.reply_to()),
options.soft_fold_at,
)?;
}
if let Some(subject) = message.subject() {
push_header_line(
&mut out,
"Subject",
&encode_rfc2047_unstructured(subject),
options.soft_fold_at,
)?;
}
if let Some(date) = message.date() {
let formatted = date
.format(&Rfc2822)
.map_err(|_| MessageRenderError::DateFormat)?;
push_header_line(&mut out, "Date", &formatted, options.soft_fold_at)?;
}
if let Some(message_id) = message.message_id() {
push_header_line(
&mut out,
"Message-ID",
message_id.as_str(),
options.soft_fold_at,
)?;
}
let (mime_headers, body_out, is_mime) = build_render_payload(message, options.soft_fold_at)?;
for header in message.headers() {
if is_mime
&& (header.name().eq_ignore_ascii_case("content-type")
|| header
.name()
.eq_ignore_ascii_case("content-transfer-encoding")
|| header.name().eq_ignore_ascii_case("mime-version"))
{
continue;
}
let value_owned;
let value: &str = if header.value().is_ascii() || is_structured_header(header.name()) {
header.value()
} else {
value_owned = encode_rfc2047_unstructured(header.value());
&value_owned
};
push_header_line(&mut out, header.name(), value, options.soft_fold_at)?;
}
if is_mime {
push_header_line(&mut out, "MIME-Version", "1.0", options.soft_fold_at)?;
for (name, value) in mime_headers {
push_header_line(&mut out, &name, &value, options.soft_fold_at)?;
}
}
out.extend_from_slice(b"\r\n");
out.extend_from_slice(&body_out);
Ok(out)
}
#[cfg(test)]
mod tests {
use email_message::{Body, Message, MessageId};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc2822;
use super::{parse_rfc822, render_rfc822};
#[test]
fn parse_rfc822_extracts_core_headers_and_body() {
let input = concat!(
"From: Mary Smith <mary@x.test>\r\n",
"To: jdoe@one.test\r\n",
"Subject: Test\r\n",
"Date: Fri, 06 Mar 2026 12:00:00 +0000\r\n",
"Message-ID: <test@example.com>\r\n",
"X-Custom: demo\r\n",
"\r\n",
"hello"
);
let message = parse_rfc822(input.as_bytes()).expect("message should parse");
assert_eq!(message.subject(), Some("Test"));
assert_eq!(message.to().len(), 1);
assert_eq!(
message.date(),
Some(
&OffsetDateTime::parse("Fri, 06 Mar 2026 12:00:00 +0000", &Rfc2822)
.expect("date should parse")
)
);
assert_eq!(
message.message_id(),
Some(
&"<test@example.com>"
.parse::<MessageId>()
.expect("message id should parse")
)
);
assert_eq!(message.body(), &Body::Text("hello".to_owned()));
}
#[test]
fn render_rfc822_writes_expected_lines() {
let message = Message::builder(Body::Text("hello".to_owned()))
.from_mailbox("Mary Smith <mary@x.test>".parse().expect("valid mailbox"))
.to(vec![email_message::Address::Mailbox(
"jdoe@one.test".parse().expect("valid mailbox"),
)])
.subject("Test")
.build()
.expect("message should validate");
let rendered = render_rfc822(&message).expect("render should succeed");
let text = String::from_utf8(rendered).expect("rendered text should be utf8");
assert!(text.contains("From: \"Mary Smith\" <mary@x.test>\r\n"));
assert!(text.contains("To: jdoe@one.test\r\n"));
assert!(text.contains("Subject: Test\r\n"));
assert!(text.ends_with("\r\n\r\nhello"));
}
}