email-message-wire 0.8.0

RFC822/MIME wire parsing and rendering for email-message
Documentation
use std::str::FromStr;

use email_message::{Body, ContentDisposition, ContentTransferEncoding, ContentType, MimePart};

use super::MessageParseError;
use super::content_type::ContentTypeHeader;
use super::header::{parse_header_lines_bytes, split_headers_and_body_bytes};
use super::shared::{MAX_MULTIPART_DEPTH, MAX_MULTIPART_PARTS, trim_lwsp_end};
use super::transfer_encoding::{
    decode_transfer_encoded_body, validate_multipart_transfer_encoding,
};

pub(super) fn parse_body(
    raw_body: &[u8],
    root_content_type: Option<&str>,
    root_content_transfer_encoding: Option<ContentTransferEncoding>,
) -> Result<Body, MessageParseError> {
    let Some(root_content_type) = root_content_type else {
        let decoded_root_body = decode_transfer_encoded_body(
            raw_body,
            root_content_transfer_encoding
                .as_ref()
                .map(ContentTransferEncoding::as_str),
        )?;
        return Ok(Body::Text(
            String::from_utf8_lossy(&decoded_root_body).into_owned(),
        ));
    };

    let content_type = ContentTypeHeader::parse(root_content_type);
    if content_type.media_type == "text/plain" {
        let decoded_root_body = decode_transfer_encoded_body(
            raw_body,
            root_content_transfer_encoding
                .as_ref()
                .map(ContentTransferEncoding::as_str),
        )?;
        Ok(Body::Text(decode_text_body(
            &decoded_root_body,
            content_type.charset.as_deref(),
        )))
    } else if content_type.media_type == "text/html" {
        let decoded_root_body = decode_transfer_encoded_body(
            raw_body,
            root_content_transfer_encoding
                .as_ref()
                .map(ContentTransferEncoding::as_str),
        )?;
        Ok(Body::Html(decode_text_body(
            &decoded_root_body,
            content_type.charset.as_deref(),
        )))
    } else if content_type.media_type.starts_with("multipart/") {
        validate_multipart_transfer_encoding(root_content_transfer_encoding.as_ref())?;
        let boundary = content_type
            .boundary
            .ok_or_else(|| MessageParseError::MimeBodyParse {
                details: "multipart body is missing boundary parameter".to_owned(),
            })?;
        Ok(Body::Mime(parse_multipart_body(
            raw_body,
            &content_type.normalized,
            Some(boundary),
            0,
        )?))
    } else {
        let decoded_root_body = decode_transfer_encoded_body(
            raw_body,
            root_content_transfer_encoding
                .as_ref()
                .map(ContentTransferEncoding::as_str),
        )?;
        Ok(Body::Mime(MimePart::Leaf {
            content_type: ContentType::from_str(&content_type.normalized).map_err(|_| {
                MessageParseError::MimeBodyParse {
                    details: format!("invalid content type `{}`", content_type.normalized),
                }
            })?,
            content_transfer_encoding: root_content_transfer_encoding,
            content_disposition: None,
            body: decoded_root_body,
        }))
    }
}

fn decode_text_body(body: &[u8], charset: Option<&str>) -> String {
    let Some(charset) = charset else {
        return String::from_utf8_lossy(body).into_owned();
    };

    if charset.eq_ignore_ascii_case("utf-8") || charset.eq_ignore_ascii_case("us-ascii") {
        return String::from_utf8_lossy(body).into_owned();
    }

    if charset.eq_ignore_ascii_case("iso-8859-1") || charset.eq_ignore_ascii_case("latin1") {
        return body.iter().copied().map(char::from).collect();
    }

    String::from_utf8_lossy(body).into_owned()
}

fn parse_multipart_body(
    body: &[u8],
    content_type_value: &str,
    boundary: Option<String>,
    depth: usize,
) -> Result<MimePart, MessageParseError> {
    if depth > MAX_MULTIPART_DEPTH {
        return Err(MessageParseError::MimeBodyParse {
            details: format!("multipart nesting exceeds maximum depth of {MAX_MULTIPART_DEPTH}"),
        });
    }

    let boundary = boundary.ok_or_else(|| MessageParseError::MimeBodyParse {
        details: "multipart part is missing boundary parameter".to_owned(),
    })?;

    let parts = split_multipart_parts(body, &boundary)?;
    let mut parsed_parts = Vec::with_capacity(parts.len());
    for part in parts {
        parsed_parts.push(parse_mime_part(&part, depth + 1)?);
    }

    Ok(MimePart::Multipart {
        content_type: ContentType::from_str(content_type_value).map_err(|_| {
            MessageParseError::MimeBodyParse {
                details: format!("invalid multipart content type `{content_type_value}`"),
            }
        })?,
        boundary: Some(boundary),
        parts: parsed_parts,
    })
}

fn split_multipart_parts(body: &[u8], boundary: &str) -> Result<Vec<Vec<u8>>, MessageParseError> {
    let delimiter = {
        let mut value = Vec::with_capacity(boundary.len() + 2);
        value.extend_from_slice(b"--");
        value.extend_from_slice(boundary.as_bytes());
        value
    };
    let end_delimiter = {
        let mut value = delimiter.clone();
        value.extend_from_slice(b"--");
        value
    };

    let mut parts = Vec::new();
    let mut current = Vec::new();
    let mut in_part = false;
    let mut found_opening = false;
    let mut found_closing = false;

    for raw_line in body.split(|byte| *byte == b'\n') {
        let line = raw_line.strip_suffix(b"\r").unwrap_or(raw_line);
        let line = trim_lwsp_end(line);

        if line == delimiter.as_slice() {
            if in_part {
                if parts.len() >= MAX_MULTIPART_PARTS {
                    return Err(MessageParseError::MimeBodyParse {
                        details: format!(
                            "multipart body exceeds maximum of {MAX_MULTIPART_PARTS} parts"
                        ),
                    });
                }
                strip_boundary_separator_newline(&mut current);
                parts.push(std::mem::take(&mut current));
            }
            in_part = true;
            found_opening = true;
            continue;
        }

        if line == end_delimiter.as_slice() {
            if in_part {
                if parts.len() >= MAX_MULTIPART_PARTS {
                    return Err(MessageParseError::MimeBodyParse {
                        details: format!(
                            "multipart body exceeds maximum of {MAX_MULTIPART_PARTS} parts"
                        ),
                    });
                }
                strip_boundary_separator_newline(&mut current);
                parts.push(std::mem::take(&mut current));
            }
            found_closing = true;
            break;
        }

        if in_part {
            current.extend_from_slice(raw_line);
            current.push(b'\n');
        }
    }

    if !found_closing {
        return Err(MessageParseError::MimeBodyParse {
            details: "multipart body missing closing boundary".to_owned(),
        });
    }

    if !found_opening {
        return Err(MessageParseError::MimeBodyParse {
            details: "multipart body missing opening boundary".to_owned(),
        });
    }

    Ok(parts)
}

fn parse_mime_part(part: &[u8], depth: usize) -> Result<MimePart, MessageParseError> {
    if depth > MAX_MULTIPART_DEPTH {
        return Err(MessageParseError::MimeBodyParse {
            details: format!("multipart nesting exceeds maximum depth of {MAX_MULTIPART_DEPTH}"),
        });
    }

    let (raw_headers, raw_body) = split_headers_and_body_bytes(part);
    let parsed_headers = parse_header_lines_bytes(raw_headers)?;

    let mut content_type = ContentTypeHeader {
        normalized: "text/plain".to_owned(),
        media_type: "text/plain".to_owned(),
        boundary: None,
        charset: None,
    };
    let mut content_transfer_encoding = None;
    let mut content_disposition = None;

    for (name, value) in parsed_headers {
        if name.eq_ignore_ascii_case("content-type") {
            content_type = ContentTypeHeader::parse(&value);
            continue;
        }
        if name.eq_ignore_ascii_case("content-transfer-encoding") {
            content_transfer_encoding =
                Some(ContentTransferEncoding::from_str(&value).map_err(|_| {
                    MessageParseError::MimeBodyParse {
                        details: format!("invalid content-transfer-encoding `{value}`"),
                    }
                })?);
            continue;
        }
        if name.eq_ignore_ascii_case("content-disposition") {
            content_disposition = Some(ContentDisposition::from_str(&value).map_err(|_| {
                MessageParseError::MimeBodyParse {
                    details: format!("invalid content-disposition `{value}`"),
                }
            })?);
        }
    }

    if content_type.media_type.starts_with("multipart/") {
        validate_multipart_transfer_encoding(content_transfer_encoding.as_ref())?;
        return parse_multipart_body(
            raw_body,
            &content_type.normalized,
            content_type.boundary,
            depth,
        );
    }

    let decoded_body = decode_transfer_encoded_body(
        raw_body,
        content_transfer_encoding
            .as_ref()
            .map(ContentTransferEncoding::as_str),
    )?;

    Ok(MimePart::Leaf {
        content_type: ContentType::from_str(&content_type.normalized).map_err(|_| {
            MessageParseError::MimeBodyParse {
                details: format!("invalid content type `{}`", content_type.normalized),
            }
        })?,
        content_transfer_encoding,
        content_disposition,
        body: decoded_body,
    })
}

fn strip_boundary_separator_newline(value: &mut Vec<u8>) {
    if value.ends_with(b"\r\n") {
        value.truncate(value.len() - 2);
        return;
    }

    if value.ends_with(b"\n") {
        value.truncate(value.len() - 1);
    }
}