use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct ContentType {
pub type_: String,
pub subtype: String,
pub params: HashMap<String, String>,
}
impl ContentType {
pub fn default_for_missing_header() -> Self {
let mut params = HashMap::new();
params.insert("charset".into(), "us-ascii".into());
Self {
type_: "text".into(),
subtype: "plain".into(),
params,
}
}
pub fn parse(value: &str) -> Self {
let trimmed = value.trim();
let (kind, rest) = match trimmed.split_once(';') {
Some((k, r)) => (k.trim(), r),
None => (trimmed, ""),
};
let (type_, subtype) = match kind.split_once('/') {
Some((t, s)) => (t.trim().to_ascii_lowercase(), s.trim().to_ascii_lowercase()),
None => (kind.to_ascii_lowercase(), String::new()),
};
let params = parse_params(rest);
Self {
type_,
subtype,
params,
}
}
pub fn is_multipart(&self) -> bool {
self.type_ == "multipart"
}
pub fn mime_type(&self) -> String {
format!("{}/{}", self.type_, self.subtype)
}
pub fn boundary(&self) -> Option<&str> {
self.params.get("boundary").map(String::as_str)
}
pub fn charset(&self) -> &str {
self.params
.get("charset")
.map(String::as_str)
.unwrap_or("us-ascii")
}
pub fn name(&self) -> Option<&str> {
self.params.get("name").map(String::as_str)
}
}
#[derive(Debug, Clone)]
pub struct Disposition {
pub kind: String,
pub params: HashMap<String, String>,
}
impl Disposition {
pub fn parse(value: &str) -> Self {
let trimmed = value.trim();
let (kind, rest) = match trimmed.split_once(';') {
Some((k, r)) => (k.trim().to_ascii_lowercase(), r),
None => (trimmed.to_ascii_lowercase(), ""),
};
let params = parse_params(rest);
Self { kind, params }
}
pub fn filename(&self) -> Option<&str> {
self.params.get("filename").map(String::as_str)
}
pub fn is_attachment(&self) -> bool {
self.kind == "attachment"
}
pub fn is_inline(&self) -> bool {
self.kind == "inline"
}
}
fn parse_params(input: &str) -> HashMap<String, String> {
let mut out = HashMap::new();
for token in input.split(';') {
let token = token.trim();
if token.is_empty() {
continue;
}
let Some((name, value)) = token.split_once('=') else {
continue;
};
let mut name = name.trim().to_ascii_lowercase();
if let Some(base) = name.strip_suffix('*') {
name = base.to_string();
}
let value_decoded = mailrs_rfc2231::decode_param_value(value.trim())
.map(|c| c.into_owned())
.unwrap_or_else(|| value.trim().to_string());
let value_clean = value_decoded
.trim()
.trim_matches('"')
.to_string();
out.insert(name, value_clean);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_simple_text_plain() {
let ct = ContentType::parse("text/plain");
assert_eq!(ct.type_, "text");
assert_eq!(ct.subtype, "plain");
assert!(ct.params.is_empty());
}
#[test]
fn parse_text_plain_with_charset() {
let ct = ContentType::parse("text/plain; charset=utf-8");
assert_eq!(ct.charset(), "utf-8");
}
#[test]
fn parse_multipart_with_boundary() {
let ct = ContentType::parse("multipart/mixed; boundary=\"abc-123\"");
assert!(ct.is_multipart());
assert_eq!(ct.boundary(), Some("abc-123"));
}
#[test]
fn parse_multipart_unquoted_boundary() {
let ct = ContentType::parse("multipart/alternative; boundary=xyz");
assert_eq!(ct.boundary(), Some("xyz"));
}
#[test]
fn parse_case_insensitive_type() {
let ct = ContentType::parse("TEXT/HTML");
assert_eq!(ct.type_, "text");
assert_eq!(ct.subtype, "html");
}
#[test]
fn parse_attachment_filename_quoted() {
let ct = ContentType::parse("application/pdf; name=\"report.pdf\"");
assert_eq!(ct.name(), Some("report.pdf"));
}
#[test]
fn parse_rfc2231_filename_decoded() {
let ct = ContentType::parse(
"application/pdf; name*=UTF-8''%E6%97%A5%E6%9C%AC.pdf",
);
assert_eq!(ct.name(), Some("日本.pdf"));
}
#[test]
fn parse_disposition_attachment() {
let d = Disposition::parse("attachment; filename=\"report.pdf\"");
assert!(d.is_attachment());
assert_eq!(d.filename(), Some("report.pdf"));
}
#[test]
fn parse_disposition_inline() {
let d = Disposition::parse("inline");
assert!(d.is_inline());
assert!(d.filename().is_none());
}
#[test]
fn parse_disposition_rfc2231_filename() {
let d = Disposition::parse("attachment; filename*=UTF-8''%E6%97%A5.pdf");
assert_eq!(d.filename(), Some("日.pdf"));
}
#[test]
fn default_for_missing_header_is_text_plain_ascii() {
let ct = ContentType::default_for_missing_header();
assert_eq!(ct.mime_type(), "text/plain");
assert_eq!(ct.charset(), "us-ascii");
}
#[test]
fn parse_handles_extra_whitespace() {
let ct = ContentType::parse(" multipart/mixed ; boundary=\"xx\" ");
assert!(ct.is_multipart());
assert_eq!(ct.boundary(), Some("xx"));
}
#[test]
fn parse_no_subtype_yields_empty() {
let ct = ContentType::parse("application");
assert_eq!(ct.type_, "application");
assert_eq!(ct.subtype, "");
}
#[test]
fn parse_handles_multiple_params() {
let ct = ContentType::parse(
"text/plain; charset=utf-8; format=flowed; delsp=yes",
);
assert_eq!(ct.charset(), "utf-8");
assert_eq!(ct.params.get("format").map(String::as_str), Some("flowed"));
assert_eq!(ct.params.get("delsp").map(String::as_str), Some("yes"));
}
}