/// Email and MIME AST types for mbt-email.
///
/// Defines the core data structures for representing parsed email messages,
/// email addresses, headers, and MIME parts.
///|
/// A parsed email address: local-part@domain
pub struct EmailAddress {
local_part : String
domain : String
}
///|
/// An email header: name and raw value.
pub struct Header {
name : String
value : String
}
///|
/// A MIME content-type with media type, subtype, and parameters.
pub struct ContentType {
media_type : String
sub_type : String
params : Array[(String, String)]
}
///|
/// A MIME content-disposition (inline or attachment).
pub struct ContentDisposition {
disposition : String
params : Array[(String, String)]
}
///|
/// A MIME body part.
pub enum Body {
/// Plain text body
Text(String)
/// Binary body (e.g., base64-decoded attachment)
Binary(Bytes)
/// Multipart body (nested MIME parts)
Multipart(Array[MimePart])
}
///|
/// A MIME part: headers + body.
pub struct MimePart {
headers : Array[Header]
body : Body
}
///|
/// A parsed email message with headers and optional MIME body.
pub struct Message {
headers : Array[Header]
body : String?
mime_part : MimePart?
}
///|
/// Look up a header by name (case-insensitive).
/// Returns None if the header is not found.
pub fn get_header(msg : Message, name : String) -> String? {
let lower = name.to_lower()
for header in msg.headers {
if header.name.to_lower() == lower {
return Some(header.value)
}
}
None
}
///|
/// Look up all headers matching a name (case-insensitive).
pub fn get_headers(msg : Message, name : String) -> Array[String] {
let lower = name.to_lower()
let results : Array[String] = []
for header in msg.headers {
if header.name.to_lower() == lower {
results.push(header.value)
}
}
results
}
///|
/// Create a new empty message.
pub fn empty_message() -> Message {
Message::{ headers: [], body: None, mime_part: None }
}
///|
/// Create a simple text email address.
pub fn email_address(local_part~ : String, domain~ : String) -> EmailAddress {
EmailAddress::{ local_part, domain }
}
///|
/// Create a new header.
pub fn header(name~ : String, value~ : String) -> Header {
Header::{ name, value }
}
///|
/// Create a new text-only MIME part.
pub fn text_part(body~ : String) -> MimePart {
MimePart::{
headers: [Header::{ name: "Content-Type", value: "text/plain" }],
body: Body::Text(body),
}
}
///|
/// Create a new multipart MIME part.
pub fn multipart_part(
sub_type~ : String,
boundary~ : String,
parts~ : Array[MimePart],
) -> MimePart {
MimePart::{
headers: [
Header::{
name: "Content-Type",
value: "multipart/" + sub_type + "; boundary=\"" + boundary + "\"",
},
],
body: Body::Multipart(parts),
}
}
///|
/// Create a new attachment MIME part.
pub fn attachment_part(
body~ : Bytes,
filename~ : String,
mime_type? : String = "application/octet-stream",
) -> MimePart {
MimePart::{
headers: [
Header::{
name: "Content-Type",
value: mime_type + "; name=\"" + filename + "\"",
},
Header::{
name: "Content-Disposition",
value: "attachment; filename=\"" + filename + "\"",
},
Header::{ name: "Content-Transfer-Encoding", value: "base64" },
],
body: Body::Binary(body),
}
}
///|
/// A structured RFC 5322 date-time value.
/// Represents the components of a date header after parsing.
pub struct EmailDate {
year : Int
month : Int
day : Int
hour : Int
minute : Int
second : Int
tz_hour : Int
tz_minute : Int
tz_sign : Int
}
///|
/// A parsed Message-ID header value:
pub struct MessageId {
id : String
domain : String
}
///|
/// A structured Received header value.
/// Received: from a by b with c for d; date
pub struct ReceivedInfo {
from_ : String
by : String
with_ : String
for_ : String
date : String
}
///|
/// An address group: GroupName: member1, member2;
pub struct AddressGroup {
name : String
members : Array[EmailAddress]
}
///|
/// Create an EmailDate value from its components.
pub fn email_date(
year~ : Int,
month~ : Int,
day~ : Int,
hour? : Int = 0,
minute? : Int = 0,
second? : Int = 0,
tz_hour? : Int = 0,
tz_minute? : Int = 0,
tz_sign? : Int = 1,
) -> EmailDate {
EmailDate::{
year,
month,
day,
hour,
minute,
second,
tz_hour,
tz_minute,
tz_sign,
}
}
///|
/// Create a MessageId value.
pub fn message_id(id~ : String, domain~ : String) -> MessageId {
MessageId::{ id, domain }
}
///|
/// Create a ReceivedInfo value.
pub fn received_info(
from_? : String = "",
by? : String = "",
with_? : String = "",
for_? : String = "",
date? : String = "",
) -> ReceivedInfo {
ReceivedInfo::{ from_, by, with_, for_, date }
}
///|
/// Create an AddressGroup value.
pub fn address_group(
name~ : String,
members~ : Array[EmailAddress],
) -> AddressGroup {
AddressGroup::{ name, members }
}
///|
/// Format an EmailDate as an RFC 5322 date-time string.
/// Output format: "Mon, 01 Jan 2026 12:34:56 +0800"
pub fn email_date_to_string(d : EmailDate) -> String {
let month_names = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov",
"Dec",
]
let day_names = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
let month_name = month_names[d.month - 1]
let day_name = day_names[weekday(d)]
let sign = if d.tz_sign >= 0 { "+" } else { "-" }
let tz_h = two_digits(d.tz_hour)
let tz_m = two_digits(d.tz_minute)
day_name +
", " +
two_digits(d.day) +
" " +
month_name +
" " +
d.year.to_string() +
" " +
two_digits(d.hour) +
":" +
two_digits(d.minute) +
":" +
two_digits(d.second) +
" " +
sign +
tz_h +
tz_m
}
///|
/// Compute the day of the week (0 = Sunday) using Zeller's congruence.
fn weekday(d : EmailDate) -> Int {
let mut year = d.year
let mut month = d.month
if month < 3 {
month = month + 12
year = year - 1
}
let k = year % 100
let j = year / 100
let h = (d.day + 13 * (month + 1) / 5 + k + k / 4 + j / 4 + 5 * j) % 7
// Zeller's h is 0=Saturday ... 6=Friday; convert to 0=Sunday.
(h + 6) % 7
}
///|
/// Pad a number to two digits: 5 -> "05"
fn two_digits(n : Int) -> String {
if n < 10 {
"0" + n.to_string()
} else {
n.to_string()
}
}
///|
/// Format a MessageId as an RFC 5322 Message-ID header value.
pub fn message_id_to_string(mid : MessageId) -> String {
"<" + mid.id + "@" + mid.domain + ">"
}
///|
/// Parse a Message-ID header value into a MessageId.
/// Accepts "" with optional surrounding whitespace.
pub fn parse_message_id(input : String) -> MessageId? {
let s = input.trim(chars=" \t\r\n").to_owned()
let content = if s.has_prefix("<") && s.has_suffix(">") {
s.unsafe_substring(start=1, end=s.length() - 1)
} else {
s
}
let at_pos = content.find("@")
match at_pos {
None => None
Some(pos) => {
let id = content.unsafe_substring(start=0, end=pos)
let domain = content.unsafe_substring(start=pos + 1, end=content.length())
if id.length() > 0 && domain.length() > 0 {
Some(MessageId::{ id, domain })
} else {
None
}
}
}
}
///|
/// Check whether a ContentType is a multipart type.
pub fn is_multipart(ct : ContentType) -> Bool {
ct.media_type == "multipart"
}
///|
/// Check whether a ContentType is a text type.
pub fn is_text(ct : ContentType) -> Bool {
ct.media_type == "text"
}
///|
/// Check whether a ContentType is an image type.
pub fn is_image(ct : ContentType) -> Bool {
ct.media_type == "image"
}
///|
/// Check whether a ContentType is an application type.
pub fn is_application(ct : ContentType) -> Bool {
ct.media_type == "application"
}
///|
/// Look up a parameter value by name in a ContentType.
pub fn content_type_param(ct : ContentType, name : String) -> String? {
let lower = name.to_lower()
for i = 0; i < ct.params.length(); i = i + 1 {
let (k, v) = ct.params[i]
if k == lower {
return Some(v)
}
}
None
}
///|
/// Look up a parameter value by name in a ContentDisposition.
pub fn disposition_param(cd : ContentDisposition, name : String) -> String? {
let lower = name.to_lower()
for i = 0; i < cd.params.length(); i = i + 1 {
let (k, v) = cd.params[i]
if k == lower {
return Some(v)
}
}
None
}
///|
/// Check whether a message has a header with the given name (case-insensitive).
pub fn has_header(msg : Message, name : String) -> Bool {
let lower = name.to_lower()
for h in msg.headers {
if h.name.to_lower() == lower {
return true
}
}
false
}
///|
/// Get all header values with a name, as a single array.
pub fn all_headers(msg : Message, name : String) -> Array[Header] {
let lower = name.to_lower()
let results : Array[Header] = []
for h in msg.headers {
if h.name.to_lower() == lower {
results.push(h)
}
}
results
}
///|
/// Get the Content-Type of a message as a parsed ContentType.
pub fn message_content_type(msg : Message) -> ContentType? {
let value = get_header(msg, "Content-Type")
match value {
None => None
Some(v) => parse_content_type(v)
}
}
///|
/// Get the boundary parameter of a multipart message.
pub fn message_boundary(msg : Message) -> String? {
let ct = message_content_type(msg)
match ct {
None => None
Some(c) => content_type_param(c, "boundary")
}
}
///|
/// Format an EmailDate to a compact ISO-like string (for diagnostics).
pub fn email_date_to_iso(d : EmailDate) -> String {
d.year.to_string() +
"-" +
two_digits(d.month) +
"-" +
two_digits(d.day) +
"T" +
two_digits(d.hour) +
":" +
two_digits(d.minute) +
":" +
two_digits(d.second)
}