std/mail

Standard Library source code

Pure ZuzuScript mail message object model.

Module

Name
std/mail
Area
Standard Library
Source
modules/std/mail.zzm
=encoding utf8

=head1 NAME

std/mail - Pure ZuzuScript mail message object model.

=head1 SYNOPSIS

  from std/mail import Address, Head, Body, Message;
  let message := new Message(
    head: new Head(),
    body: Body.bytes( to_binary("Hello\r\n") )
  );

=head1 IMPLEMENTATION SUPPORT

This module is supported by all implementations of ZuzuScript.

=head1 DESCRIPTION

This module provides the object model for RFC 5322-style message
headers, address objects, MIME-ish bodies, date helpers, a conservative
parser, and integration with compatible low-level mailers.

The parser ignores multipart preamble and epilogue bytes in Phase 9.
When either is present, C<Parser.warnings()> records the limitation.

The serializer can generate missing multipart boundaries on request.
C<Serializer.serialize()> writes matching C<Content-Type> output for the
generated boundary. C<Serializer.serialize_body()> returns only body bytes,
so callers using it with a generated boundary must provide the matching
C<Content-Type> header separately.

=head1 EXPORTS

=head2 Functions

=over

=item C<< parse_datetime(String text) >>

Parameters: C<text> is a mail date string. Returns: C<Time>. Parses a
mail header date into a C<std/time> object.

=item C<< format_datetime(Time time) >>

Parameters: C<time> is a C<std/time> object. Returns: C<String>. Formats
a time for use in mail headers.

=back

=head2 Classes

=over

=item C<Address>

Mail address object.

=over

=item C<< Address.parse(String text) >>, C<< Address.parse_list(String text) >>

Parameters: C<text> is address header text. Returns: C<Address> or
C<Array>. Parses one address or a comma-separated address list.

=item C<< address.local() >>, C<< address.domain() >>, C<< address.display_name() >>, C<< address.address() >>

Parameters: none. Returns: C<String> or C<null>. Returns address
components.

=item C<< address.to_header() >>, C<< address.to_String() >>

Parameters: none. Returns: C<String>. Formats the address.

=item C<< address.to_Dict() >>

Parameters: none. Returns: C<Dict>. Converts the address to a
dictionary.

=back

=item C<Head>

Ordered mail header collection.

=over

=item C<< head.fields() >>, C<< head.to_PairList() >>, C<< head.to_Iterator() >>

Parameters: none. Returns: C<PairList> or C<Function>. Returns header
fields or an iterator.

=item C<< head.raw(String name, fallback := null) >>, C<< head.raw_all(String name) >>, C<< head.decoded(String name, fallback := null) >>, C<< head.decoded_all(String name) >>, C<< head.get(String name, fallback := null) >>, C<< head.get_all(String name) >>

Parameters: C<name> is a header name and C<fallback> is optional.
Returns: C<String>, C<Array>, or fallback. Reads header values.

=item C<< head.set(String name, String value) >>, C<< head.add(String name, String value) >>, C<< head.remove(String name) >>

Parameters: C<name> is a header name and C<value> is header text.
Returns: C<Head>. Mutates header fields.

=item C<< head.has(String name) >>

Parameters: C<name> is a header name. Returns: C<Boolean>. Tests whether
a header exists.

=item C<< head.content_type() >>, C<< head.content_transfer_encoding() >>, C<< head.charset() >>, C<< head.boundary() >>, C<< head.message_id() >>, C<< head.date() >>, C<< head.from() >>, C<< head.to() >>, C<< head.cc() >>, C<< head.bcc() >>, C<< head.subject() >>

Parameters: none. Returns: header-specific value. Reads common headers.

=back

=item C<Body>

Mail body value.

=over

=item C<< Body.bytes(BinaryString raw, ... PairList options) >>

Parameters: C<raw> is body bytes and C<options> describe content type,
encoding, and parts. Returns: C<Body>. Creates a leaf or multipart body.

=item C<< Body.nested(message, ... PairList options) >>

Parameters: C<message> is a C<Message>. Returns: C<Body>. Creates a
nested message body.

=item C<< body.is_multipart() >>, C<< body.is_nested() >>

Parameters: none. Returns: C<Boolean>. Reports body shape.

=item C<< body.bytes() >>, C<< body.decoded() >>, C<< body.encoded() >>

Parameters: none. Returns: C<BinaryString>. Returns raw, decoded, or
encoded body bytes.

=item C<< body.parts() >>, C<< body.part(Number index) >>, C<< body.count() >>

Parameters: C<index> selects a body part. Returns: C<Array>, C<Body>, or
C<Number>. Reads multipart body parts.

=item C<< body.nested() >>, C<< body.content_type() >>, C<< body.transfer_encoding() >>, C<< body.to_Dict() >>

Parameters: none. Returns: value. Reads body metadata or converts the
body to a dictionary.

=back

=item C<Message>

Mail message object with C<head> and C<body>.

=over

=item C<< message.head() >>, C<< message.body() >>

Parameters: none. Returns: C<Head> or C<Body>. Returns message parts.

=item C<< message.header(String name, fallback := null) >>, C<< message.headers(String name) >>

Parameters: C<name> is a header name and C<fallback> is optional.
Returns: C<String>, C<Array>, or fallback. Reads message headers.

=item C<< message.set_header(String name, String value) >>, C<< message.add_header(String name, String value) >>, C<< message.remove_header(String name) >>

Parameters: C<name> is a header name and C<value> is header text.
Returns: C<Message>. Mutates message headers.

=item C<< message.subject() >>, C<< message.from() >>, C<< message.to() >>, C<< message.cc() >>, C<< message.bcc() >>, C<< message.date() >>, C<< message.message_id() >>

Parameters: none. Returns: header-specific value. Reads common message
headers.

=item C<< message.is_part() >>, C<< message.container() >>, C<< message.toplevel() >>

Parameters: none. Returns: C<Boolean> or C<Message>. Reads containment
state.

=item C<< message.send(mailer, ... PairList options) >>

Parameters: C<mailer> is a compatible low-level mailer. Returns: value.
Sends the message.

=item C<< message.to_Dict() >>

Parameters: none. Returns: C<Dict>. Converts the message to a
dictionary.

=back

=item C<Parser>

Conservative mail parser.

=over

=item C<< parser.warnings() >>

Parameters: none. Returns: C<Array>. Returns non-fatal parse warnings.

=item C<< parser.parse(BinaryString bytes) >>

Parameters: C<bytes> is raw message bytes. Returns: C<Message>. Parses a
mail message.

=back

=item C<Serializer>

Mail serializer.

=over

=item C<< serializer.serialize_body(Message message, ... PairList options) >>

Parameters: C<message> is a mail message and C<options> control output.
Returns: C<BinaryString>. Serializes only the message body.

=item C<< serializer.serialize(Message message, ... PairList options) >>

Parameters: C<message> is a mail message and C<options> control output.
Returns: C<BinaryString>. Serializes a complete message.

=back

=back

=head1 COPYRIGHT AND LICENCE

B<< std/mail >> is copyright Toby Inkster.

It is free software; you may redistribute it and/or modify it under
the terms of either the Artistic License 1.0 or the GNU General Public
License version 2.

=cut

from std/string import chr, index, join, ord, substr, trim;

from std/string/base64 import
	encode as _base64_encode,
	decode as _base64_decode;
from std/string/quoted_printable import decode as _qp_decode;
from std/time import Time, TimeZone;

function _parse_one_address;
function _parse_address_list;
function _parse_address_header;
function _mail_send_serialize_body;

function _has_crlf ( String text ) {
	return index( text, "\r" ) >= 0 or index( text, "\n" ) >= 0;
}

function _assert_no_crlf ( String text, String context ) {
	if ( _has_crlf(text) ) {
		die `mail.${context}: value must not contain CR or LF`;
	}
}

function _is_ws ( String ch ) {
	return ch eq " " or ch eq "\t";
}

function _skip_ws ( String text, Number i ) {
	let pos := i;
	let n := length text;
	while ( pos < n and _is_ws( substr( text, pos, 1 ) ) ) {
		pos++;
	}
	return pos;
}

function _div_floor ( Number n, Number d ) {
	return floor( n / d );
}

function _hex_value ( String ch ) {
	let v := index( "0123456789ABCDEF", uc(ch) );
	return v;
}

function _bytes_to_binary ( Array bytes ) {
	let alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
		_ "abcdefghijklmnopqrstuvwxyz0123456789+/";
	let out := "";
	let i := 0;
	let n := bytes.length();

	while ( i < n ) {
		let b0 := bytes[i];
		let b1 := null;
		let b2 := null;
		if ( i + 1 < n ) {
			b1 := bytes[i + 1];
		}
		if ( i + 2 < n ) {
			b2 := bytes[i + 2];
		}

		let c0 := _div_floor( b0, 4 );
		let c1 := ( b0 mod 4 ) * 16;
		let c2 := 64;
		let c3 := 64;

		if ( not( b1 == null ) ) {
			c1 += _div_floor( b1, 16 );
			c2 := ( b1 mod 16 ) * 4;
			if ( not( b2 == null ) ) {
				c2 += _div_floor( b2, 64 );
				c3 := b2 mod 64;
			}
		}

		out _= substr( alphabet, c0, 1 );
		out _= substr( alphabet, c1, 1 );
		out _= c2 == 64 ? "=" : substr( alphabet, c2, 1 );
		out _= c3 == 64 ? "=" : substr( alphabet, c3, 1 );
		i += 3;
	}

	return _base64_decode(out);
}

function _binary_to_bytes ( BinaryString raw ) {
	let alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
		_ "abcdefghijklmnopqrstuvwxyz0123456789+/";
	let b64 := _base64_encode(raw);
	let out := [];
	let i := 0;
	let n := length b64;

	while ( i < n ) {
		let c0 := index( alphabet, substr( b64, i, 1 ) );
		let c1 := index( alphabet, substr( b64, i + 1, 1 ) );
		let ch2 := substr( b64, i + 2, 1 );
		let ch3 := substr( b64, i + 3, 1 );
		let c2 := -1;
		let c3 := -1;
		if ( ch2 ne "=" ) {
			c2 := index( alphabet, ch2 );
		}
		if ( ch3 ne "=" ) {
			c3 := index( alphabet, ch3 );
		}

		out.push( c0 * 4 + _div_floor( c1, 16 ) );
		if ( c2 >= 0 ) {
			out.push( ( c1 mod 16 ) * 16 + _div_floor( c2, 4 ) );
		}
		if ( c3 >= 0 ) {
			out.push( ( c2 mod 4 ) * 64 + c3 );
		}

		i += 4;
	}

	return out;
}

function _latin1_to_string ( BinaryString raw ) {
	let out := "";
	for ( let b in _binary_to_bytes(raw) ) {
		out _= chr(b);
	}
	return out;
}

function _read_quoted_string ( String text, Number start ) {
	let n := length text;
	if ( substr( text, start, 1 ) ne "\"" ) {
		die "mail.invalid_address: expected quoted string";
	}

	let out := "";
	let i := start + 1;
	while ( i < n ) {
		let ch := substr( text, i, 1 );
		if ( ch eq "\"" ) {
			return { value: out, end: i + 1 };
		}
		if ( ch eq "\\" ) {
			if ( i + 1 >= n ) {
				die "mail.invalid_address: unterminated quoted string";
			}
			out _= substr( text, i + 1, 1 );
			i += 2;
			next;
		}
		if ( ch eq "\r" or ch eq "\n" ) {
			die "mail.invalid_address: quoted string must not contain CR or LF";
		}
		out _= ch;
		i++;
	}

	die "mail.invalid_address: unterminated quoted string";
}

function _quote_string ( String text ) {
	let out := "\"";
	let i := 0;
	let n := length text;

	while ( i < n ) {
		let ch := substr( text, i, 1 );
		if ( ch eq "\\" or ch eq "\"" ) {
			out _= "\\";
		}
		out _= ch;
		i++;
	}

	return out _ "\"";
}

function _is_alpha ( String ch ) {
	let cp := ord(ch);
	return ( cp >= 65 and cp <= 90 ) or ( cp >= 97 and cp <= 122 );
}

function _is_digit ( String ch ) {
	let cp := ord(ch);
	return cp >= 48 and cp <= 57;
}

function _is_atext ( String ch ) {
	if ( _is_alpha(ch) or _is_digit(ch) ) {
		return true;
	}
	return index( "!#$%&'*+-/=?^_`{|}~", ch ) >= 0;
}

function _is_dot_atom ( String text ) {
	let n := length text;
	return false if n == 0;
	return false if substr( text, 0, 1 ) eq ".";
	return false if substr( text, n - 1, 1 ) eq ".";

	let prev_dot := false;
	let i := 0;
	while ( i < n ) {
		let ch := substr( text, i, 1 );
		if ( ch eq "." ) {
			return false if prev_dot;
			prev_dot := true;
		}
		else {
			return false if not _is_atext(ch);
			prev_dot := false;
		}
		i++;
	}

	return true;
}

function _is_domain_text ( String text ) {
	let n := length text;
	return false if n == 0;
	return false if substr( text, 0, 1 ) eq ".";
	return false if substr( text, n - 1, 1 ) eq ".";

	let i := 0;
	while ( i < n ) {
		let ch := substr( text, i, 1 );
		return false if not(
			_is_alpha(ch)
			or _is_digit(ch)
			or ch eq "-"
			or ch eq "."
		);
		i++;
	}

	return true;
}

function _is_phrase_text ( String text ) {
	let n := length text;
	return false if n == 0;
	return false if text ne trim(text);

	let i := 0;
	while ( i < n ) {
		let ch := substr( text, i, 1 );
		if ( _is_ws(ch) ) {
			i++;
			next;
		}
		return false if not _is_atext(ch) and ch ne ".";
		i++;
	}

	return true;
}

function _find_unquoted (
	String text,
	String target,
	Number start := 0,
	Boolean ignore_angle := false,
) {
	let i := start;
	let n := length text;
	let in_quote := false;
	let escape := false;
	let in_literal := false;
	let angle_depth := 0;

	while ( i < n ) {
		let ch := substr( text, i, 1 );

		if ( escape ) {
			escape := false;
			i++;
			next;
		}
		if ( in_quote ) {
			if ( ch eq "\\" ) {
				escape := true;
			}
			else if ( ch eq "\"" ) {
				in_quote := false;
			}
			i++;
			next;
		}
		if ( in_literal ) {
			if ( ch eq "]" ) {
				in_literal := false;
			}
			i++;
			next;
		}

		if ( ch eq target and ( not ignore_angle or angle_depth == 0 ) ) {
			return i;
		}
		if ( ch eq "\"" ) {
			in_quote := true;
		}
		else if ( ch eq "[" ) {
			in_literal := true;
		}
		else if ( ch eq "<" ) {
			angle_depth++;
		}
		else if ( ch eq ">" and angle_depth > 0 ) {
			angle_depth--;
		}

		i++;
	}

	return -1;
}

function _find_top_level ( String text, String target, Number start := 0 ) {
	return _find_unquoted( text, target, start, true );
}

function _parse_phrase ( String raw ) {
	let text := trim(raw);
	return null if text eq "";

	if ( substr( text, 0, 1 ) eq "\"" ) {
		let quoted := _read_quoted_string( text, 0 );
		if ( trim( substr( text, quoted{end} ) ) ne "" ) {
			die "mail.invalid_address: text follows quoted display name";
		}
		return quoted{value};
	}

	return text;
}

function _parse_addr_spec ( String raw ) {
	let text := trim(raw);
	_assert_no_crlf( text, "invalid_address" );

	if ( substr( text, 0, 1 ) eq "@" ) {
		die "mail.invalid_address: obsolete route syntax is not supported";
	}

	let at := _find_unquoted( text, "@", 0, false );
	if ( at < 0 ) {
		die "mail.invalid_address: mailbox address is missing @";
	}

	let route := _find_unquoted( text, ":", 0, false );
	if ( route >= 0 and route < at ) {
		die "mail.invalid_address: obsolete route syntax is not supported";
	}

	let local := trim( substr( text, 0, at ) );
	let domain := trim( substr( text, at + 1 ) );
	if ( local eq "" or domain eq "" ) {
		die "mail.invalid_address: mailbox address is incomplete";
	}

	if ( substr( local, 0, 1 ) eq "\"" ) {
		let quoted := _read_quoted_string( local, 0 );
		if ( quoted{end} != length local ) {
			die "mail.invalid_address: text follows quoted local part";
		}
		local := quoted{value};
	}
	else if ( not _is_dot_atom(local) ) {
		die "mail.invalid_address: invalid local part";
	}

	if ( substr( domain, 0, 1 ) eq "[" ) {
		if ( substr( domain, ( length domain ) - 1, 1 ) ne "]" ) {
			die "mail.invalid_address: unterminated domain literal";
		}
		if ( _has_crlf(domain) ) {
			die "mail.invalid_address: invalid domain literal";
		}
	}
	else if ( not _is_domain_text(domain) ) {
		die "mail.invalid_address: invalid domain";
	}

	return { local: local, domain: domain };
}

function _decode_q_encoded_word ( String text ) {
	let bytes := [];
	let i := 0;
	let n := length text;

	while ( i < n ) {
		let ch := substr( text, i, 1 );
		if ( ch eq "_" ) {
			bytes.push(32);
			i++;
			next;
		}
		if ( ch eq "=" ) {
			if ( i + 2 >= n ) {
				return null;
			}
			let hi := _hex_value( substr( text, i + 1, 1 ) );
			let lo := _hex_value( substr( text, i + 2, 1 ) );
			if ( hi < 0 or lo < 0 ) {
				return null;
			}
			bytes.push( hi * 16 + lo );
			i += 3;
			next;
		}

		let cp := ord(ch);
		return null if cp > 127;
		bytes.push(cp);
		i++;
	}

	return _bytes_to_binary(bytes);
}

function _normal_charset ( String charset ) {
	let cs := lc(charset);
	if ( cs in [ "utf-8", "utf8" ] ) {
		return "utf-8";
	}
	if ( cs in [ "us-ascii", "ascii" ] ) {
		return "us-ascii";
	}
	if ( cs in [ "latin1", "latin-1", "iso-8859-1", "iso8859-1" ] ) {
		return "latin1";
	}
	return null;
}

function _decode_charset_bytes ( BinaryString bytes, String charset ) {
	let cs := _normal_charset(charset);
	return null if cs == null;

	if ( cs eq "latin1" ) {
		return _latin1_to_string(bytes);
	}

	return try {
		to_string(bytes);
	}
	catch {
		null;
	};
}

function _parse_encoded_word_at ( String text, Number start ) {
	if ( substr( text, start, 2 ) ne "=?" ) {
		return null;
	}

	let q1 := index( text, "?", start + 2 );
	return null if q1 < 0;
	let q2 := index( text, "?", q1 + 1 );
	return null if q2 < 0;
	let end := index( text, "?=", q2 + 1 );
	return null if end < 0;

	let charset := substr( text, start + 2, q1 - start - 2 );
	let encoding := lc( substr( text, q1 + 1, q2 - q1 - 1 ) );
	let payload := substr( text, q2 + 1, end - q2 - 1 );
	let bytes := null;

	if ( charset eq "" or payload eq "" ) {
		return null;
	}
	if ( encoding eq "b" ) {
		bytes := try {
			_base64_decode(payload);
		}
		catch {
			null;
		};
	}
	else if ( encoding eq "q" ) {
		bytes := _decode_q_encoded_word(payload);
	}
	else {
		return null;
	}

	return null if bytes == null;
	let decoded := _decode_charset_bytes( bytes, charset );
	return null if decoded == null;

	return { text: decoded, end: end + 2 };
}

function _decode_rfc2047 ( String raw ) {
	let out := "";
	let pending_ws := "";
	let last_encoded := false;
	let i := 0;
	let n := length raw;

	while ( i < n ) {
		let ch := substr( raw, i, 1 );
		if ( _is_ws(ch) ) {
			let start := i;
			while ( i < n and _is_ws( substr( raw, i, 1 ) ) ) {
				i++;
			}
			pending_ws _= substr( raw, start, i - start );
			next;
		}

		if ( substr( raw, i, 2 ) eq "=?" ) {
			let parsed := _parse_encoded_word_at( raw, i );
			if ( not( parsed == null ) ) {
				out _= pending_ws unless last_encoded;
				pending_ws := "";
				out _= parsed{text};
				last_encoded := true;
				i := parsed{end};
				next;
			}
		}

		out _= pending_ws;
		pending_ws := "";
		out _= ch;
		last_encoded := false;
		i++;
	}

	return out _ pending_ws;
}

function _copy_pairlist ( fields ) {
	let copy := new PairList();
	if ( fields == null ) {
		return copy;
	}
	if ( not( fields instanceof PairList ) ) {
		die "mail.invalid_headers: Head fields expects PairList";
	}
	for ( let pair in fields.to_Array() ) {
		copy.add( pair.key, pair.value );
	}
	return copy;
}

function _mail_send_recipients_option ( value ) {
	if ( value instanceof String ) {
		return [ value ];
	}
	if ( not( value instanceof Array ) ) {
		die "mail.send: envelope_to expects String or Array";
	}

	let out := [];
	for ( let item in value ) {
		if ( not( item instanceof String ) ) {
			die "mail.send: envelope_to expects String or Array of String";
		}
		out.push(item);
	}
	if ( out.length() == 0 ) {
		die "mail.invalid_address: Message.send requires at least one "
			_ "envelope recipient";
	}
	return out;
}

function _mail_send_options ( PairList options ) {
	let out := {
		envelope_from: null,
		envelope_to: null,
		send_options: new PairList(),
	};

	for ( let pair in options.to_Array() ) {
		if ( pair.key eq "envelope_from" ) {
			if ( not( pair.value instanceof String ) ) {
				die "mail.send: envelope_from expects String";
			}
			out{envelope_from} := pair.value;
		}
		else if ( pair.key eq "envelope_to" ) {
			out{envelope_to} := _mail_send_recipients_option(pair.value);
		}
		else if ( pair.key eq "send_options" ) {
			if ( not( pair.value instanceof Dict )
				and not( pair.value instanceof PairList ) ) {
				die "mail.send: send_options expects Dict or PairList";
			}
			out{send_options} := pair.value;
		}
		else {
			die "mail.send: unsupported option '" _ pair.key _ "'";
		}
	}

	return out;
}

function _valid_header_name ( String name ) {
	let n := length name;
	return false if n == 0;
	let i := 0;
	while ( i < n ) {
		let cp := ord( substr( name, i, 1 ) );
		return false if cp < 33 or cp > 126 or cp == 58;
		i++;
	}
	return true;
}

function _check_header_name ( String name ) {
	if ( not _valid_header_name(name) ) {
		die "mail.invalid_headers: invalid header name";
	}
}

function _check_header_value ( value ) {
	if ( not( value instanceof String ) ) {
		die "mail.invalid_headers: header value expects String, got "
			_ typeof value;
	}
	_assert_no_crlf( value, "invalid_headers" );
}

function _split_header_segments ( String text, String delimiter ) {
	let out := [];
	let start := 0;
	let i := 0;
	let n := length text;
	let in_quote := false;
	let escape := false;

	while ( i < n ) {
		let ch := substr( text, i, 1 );
		if ( escape ) {
			escape := false;
		}
		else if ( in_quote ) {
			if ( ch eq "\\" ) {
				escape := true;
			}
			else if ( ch eq "\"" ) {
				in_quote := false;
			}
		}
		else if ( ch eq "\"" ) {
			in_quote := true;
		}
		else if ( ch eq delimiter ) {
			out.push( substr( text, start, i - start ) );
			start := i + 1;
		}
		i++;
	}
	out.push( substr( text, start ) );

	return out;
}

function _unquote_header_param ( String text ) {
	let value := trim(text);
	if ( length value >= 2 and substr( value, 0, 1 ) eq "\"" ) {
		let quoted := _read_quoted_string( value, 0 );
		if ( trim( substr( value, quoted{end} ) ) eq "" ) {
			return quoted{value};
		}
	}
	return value;
}

function _header_main_value ( String value ) {
	let parts := _split_header_segments( value, ";" );
	return lc( trim( parts[0] ) );
}

function _header_parameter ( String value, String wanted ) {
	let parts := _split_header_segments( value, ";" );
	let i := 1;
	while ( i < parts.length() ) {
		let part := parts[i];
		let equals_at := _find_unquoted( part, "=", 0, false );
		if ( equals_at > 0 ) {
			let name := lc( trim( substr( part, 0, equals_at ) ) );
			if ( name eq lc(wanted) ) {
				return _unquote_header_param( substr( part, equals_at + 1 ) );
			}
		}
		i++;
	}
	return null;
}

function _body_options ( PairList options ) {
	let opts := {
		content_type: null,
		transfer_encoding: null,
		charset: null,
		boundary: null,
	};

	for ( let option in options.to_Array() ) {
		let key := option.key;
		let value := option.value;
		if ( key eq "content_type" ) {
			opts{content_type} := value;
		}
		else if ( key eq "transfer_encoding"
			or key eq "content_transfer_encoding" ) {
			opts{transfer_encoding} := value;
		}
		else if ( key eq "charset" ) {
			opts{charset} := value;
		}
		else if ( key eq "boundary" ) {
			opts{boundary} := value;
		}
		else {
			die `mail.body: unsupported Body option '${key}'`;
		}
	}

	return opts;
}

function _copy_array ( Array items ) {
	let out := [];
	for ( let item in items ) {
		out.push(item);
	}
	return out;
}

function _bytes_slice ( Array bytes, Number start, count := null ) {
	let out := [];
	let end := count == null ? bytes.length() : start + count;
	end := bytes.length() if end > bytes.length();
	let i := start;

	while ( i < end ) {
		out.push(bytes[i]);
		i++;
	}

	return out;
}

function _bytes_to_latin1_string ( Array bytes ) {
	return _latin1_to_string( _bytes_to_binary(bytes) );
}

function _split_message_bytes ( Array bytes ) {
	let n := bytes.length();
	let i := 0;
	let line_start := 0;

	while ( i < n ) {
		if ( bytes[i] == 10 ) {
			let content_end := i;
			if ( content_end > line_start and bytes[content_end - 1] == 13 ) {
				content_end--;
			}
			if ( content_end == line_start ) {
				return {
					header_bytes: _bytes_slice( bytes, 0, line_start ),
					body_bytes: _bytes_slice( bytes, i + 1 ),
					header_size: line_start,
					found_separator: true,
				};
			}
			i++;
			line_start := i;
			next;
		}
		i++;
	}

	return {
		header_bytes: _bytes_slice( bytes, 0 ),
		body_bytes: [],
		header_size: n,
		found_separator: false,
	};
}

function _mail_header_lines ( Array bytes ) {
	let lines := [];
	let n := bytes.length();
	let i := 0;
	let line_start := 0;

	while ( i < n ) {
		if ( bytes[i] == 10 ) {
			let content_end := i;
			if ( content_end > line_start and bytes[content_end - 1] == 13 ) {
				content_end--;
			}
			lines.push(
				_bytes_to_latin1_string(
					_bytes_slice( bytes, line_start, content_end - line_start ),
				),
			);
			i++;
			line_start := i;
			next;
		}
		i++;
	}

	if ( line_start < n ) {
		lines.push( _bytes_to_latin1_string( _bytes_slice( bytes, line_start ) ) );
	}

	return lines;
}

function _parse_mail_header_fields ( Array header_bytes ) {
	let fields := new PairList();
	let current_name := null;
	let current_value := "";

	for ( let line in _mail_header_lines(header_bytes) ) {
		next if line eq "";

		if ( _is_ws( substr( line, 0, 1 ) ) ) {
			if ( current_name == null ) {
				die "mail.parse: folded header without previous field";
			}
			current_value _= " " _ trim(line);
			next;
		}

		if ( not( current_name == null ) ) {
			fields.add( current_name, current_value );
		}

		let colon := index( line, ":" );
		if ( colon <= 0 ) {
			die "mail.parse: malformed header line";
		}

		current_name := substr( line, 0, colon );
		if ( not _valid_header_name(current_name) ) {
			die "mail.parse: invalid header name";
		}
		current_value := trim( substr( line, colon + 1 ) );
	}

	if ( not( current_name == null ) ) {
		fields.add( current_name, current_value );
	}

	return fields;
}

function _only_sp_tab ( String text ) {
	let i := 0;
	let n := length text;

	while ( i < n ) {
		return false if not _is_ws( substr( text, i, 1 ) );
		i++;
	}

	return true;
}

function _mail_boundary_line_kind ( Array line_bytes, String boundary ) {
	let line := _bytes_to_latin1_string(line_bytes);
	let delimiter := "--" _ boundary;
	let dlen := length delimiter;

	return null if length line < dlen;
	return null if substr( line, 0, dlen ) ne delimiter;

	let tail := substr( line, dlen );
	return "open" if _only_sp_tab(tail);

	if ( length tail >= 2 and substr( tail, 0, 2 ) eq "--" ) {
		return "close" if _only_sp_tab( substr( tail, 2 ) );
	}

	return null;
}

function _mail_part_end_before_boundary (
	Array bytes,
	Number part_start,
	Number line_start
) {
	let end := line_start;
	if ( end > part_start and bytes[end - 1] == 10 ) {
		end--;
		if ( end > part_start and bytes[end - 1] == 13 ) {
			end--;
		}
	}
	return end;
}

function _scan_multipart_parts ( Array bytes, String boundary ) {
	let parts := [];
	let n := bytes.length();
	let line_start := 0;
	let opened := false;
	let closed := false;
	let part_start := 0;
	let preamble_size := 0;
	let epilogue_size := 0;

	while ( line_start < n ) {
		let line_end := line_start;
		while ( line_end < n and bytes[line_end] != 10 ) {
			line_end++;
		}

		let content_end := line_end;
		if ( content_end > line_start and bytes[content_end - 1] == 13 ) {
			content_end--;
		}

		let after_line := line_end < n ? line_end + 1 : n;
		let kind := _mail_boundary_line_kind(
			_bytes_slice( bytes, line_start, content_end - line_start ),
			boundary,
		);

		if ( not( kind == null ) ) {
			if ( not opened ) {
				opened := true;
				preamble_size := line_start;
				part_start := after_line;
				if ( kind eq "close" ) {
					closed := true;
					epilogue_size := n - after_line;
					return {
						opened: opened,
						closed: closed,
						parts: parts,
						preamble_size: preamble_size,
						epilogue_size: epilogue_size,
					};
				}
			}
			else {
				let part_end := _mail_part_end_before_boundary(
					bytes,
					part_start,
					line_start,
				);
				parts.push(
					_bytes_slice( bytes, part_start, part_end - part_start ),
				);
				part_start := after_line;

				if ( kind eq "close" ) {
					closed := true;
					epilogue_size := n - after_line;
					return {
						opened: opened,
						closed: closed,
						parts: parts,
						preamble_size: preamble_size,
						epilogue_size: epilogue_size,
					};
				}
			}
		}

		line_start := after_line;
	}

	if ( opened and not closed ) {
		parts.push( _bytes_slice( bytes, part_start ) );
	}

	return {
		opened: opened,
		closed: closed,
		parts: parts,
		preamble_size: preamble_size,
		epilogue_size: epilogue_size,
	};
}

function _is_identity_transfer_encoding ( encoding ) {
	if ( encoding == null or encoding eq "" ) {
		return true;
	}
	return encoding in [ "identity", "7bit", "8bit", "binary" ];
}

function _invalid_datetime ( String message ) {
	die "mail.invalid_datetime: " _ message;
}

function _numeric_zone_seconds ( String zone ) {
	return null if not( zone ~ /^[+-][0-9]{4}$/ );

	let sign := substr( zone, 0, 1 ) eq "-" ? -1 : 1;
	let hours := int( substr( zone, 1, 2 ) );
	let minutes := int( substr( zone, 3, 2 ) );
	return null if hours > 23 or minutes > 59;
	return sign * ( hours * 3600 + minutes * 60 );
}

function _datetime_apply_option ( Dict opts, Dict seen, key, value ) {
	if ( not( key instanceof String ) ) {
		_invalid_datetime("format option names must be strings");
	}
	if ( seen.exists(key) ) {
		_invalid_datetime( "conflicting format option '" _ key _ "'" );
	}
	seen.set( key, true );

	if ( key eq "utc" ) {
		if ( not( value instanceof Boolean ) ) {
			_invalid_datetime("format option 'utc' expects Boolean");
		}
		opts{utc} := value;
		opts{utc_seen} := true;
		return;
	}
	if ( key eq "offset" ) {
		if ( not( value instanceof String ) ) {
			_invalid_datetime("format option 'offset' expects String");
		}
		let seconds := _numeric_zone_seconds(value);
		if ( seconds == null ) {
			_invalid_datetime("format option 'offset' expects +HHMM or -HHMM");
		}
		opts{offset} := value;
		opts{offset_seconds} := seconds;
		opts{offset_seen} := true;
		return;
	}
	if ( key eq "include_weekday" ) {
		if ( not( value instanceof Boolean ) ) {
			_invalid_datetime("format option 'include_weekday' expects Boolean");
		}
		opts{include_weekday} := value;
		return;
	}

	_invalid_datetime( "unknown format option '" _ key _ "'" );
}

function _datetime_options ( options, PairList named_options ) {
	let opts := {
		utc: true,
		utc_seen: false,
		offset: "+0000",
		offset_seconds: 0,
		offset_seen: false,
		include_weekday: true,
	};
	let seen := {};

	if ( options instanceof Dict ) {
		for ( let key in options.keys() ) {
			_datetime_apply_option( opts, seen, key, options.get(key) );
		}
	}
	else if ( options instanceof PairList ) {
		for ( let pair in options.to_Array() ) {
			_datetime_apply_option( opts, seen, pair.key, pair.value );
		}
	}
	else {
		_invalid_datetime("format options expects Dict");
	}

	for ( let pair in named_options.to_Array() ) {
		_datetime_apply_option( opts, seen, pair.key, pair.value );
	}

	if ( opts{offset_seen} and not opts{utc_seen} ) {
		opts{utc} := false;
	}
	if ( opts{utc} and opts{offset_seconds} != 0 ) {
		_invalid_datetime("format options 'utc' and 'offset' conflict");
	}
	return opts;
}

function _datetime_parse_error_message ( e ) {
	let message := lc( "" _ e );
	return "invalid time zone" if index( message, "invalid time zone" ) >= 0;
	return "invalid time zone" if index( message, "timezone offset" ) >= 0;
	return "invalid date" if index( message, "invalid date" ) >= 0;
	return "invalid time" if index( message, "invalid time" ) >= 0;
	return "invalid month" if index( message, "invalid month" ) >= 0;
	return "invalid weekday" if index( message, "invalid weekday" ) >= 0;
	return "invalid year" if index( message, "invalid year" ) >= 0;
	return "expected RFC 5322 date-time";
}

function parse_datetime ( String text ) {
	_assert_no_crlf( text, "invalid_datetime" );

	try {
		return Time.parse( trim(text) );
	}
	catch ( Exception e ) {
		_invalid_datetime( _datetime_parse_error_message(e) );
	}
}

function format_datetime (
	time,
	options := {},
	... PairList named_options
) {
	if ( not( time instanceof Time ) ) {
		_invalid_datetime("format_datetime expects Time");
	}

	let opts := _datetime_options( options, named_options );
	let zoned := opts{utc}
		? time.with_timezone( TimeZone.utc() )
		: time.with_timezone( TimeZone.offset(opts{offset_seconds}) );
	return zoned.to_rfc5322( include_weekday: opts{include_weekday} );
}

class Address {
	let local := null;
	let domain := null;
	let display_name := null;

	method __build__ () {
		if ( not( local instanceof String ) or local eq "" ) {
			die "mail.invalid_address: local part expects non-empty String";
		}
		if ( not( domain instanceof String ) or domain eq "" ) {
			die "mail.invalid_address: domain expects non-empty String";
		}
		if ( not( display_name == null ) and not( display_name instanceof String ) ) {
			die "mail.invalid_address: display_name expects String or null";
		}

		_assert_no_crlf( local, "invalid_address" );
		_assert_no_crlf( domain, "invalid_address" );
		if ( not( display_name == null ) ) {
			_assert_no_crlf( display_name, "invalid_address" );
		}
	}

	method local () {
		return local;
	}

	method domain () {
		return domain;
	}

	method display_name () {
		return display_name;
	}

	method address () {
		let lhs := _is_dot_atom(local) ? local : _quote_string(local);
		return lhs _ "@" _ domain;
	}

	method to_header () {
		if ( display_name == null or display_name eq "" ) {
			return self.address();
		}

		let name := _is_phrase_text(display_name)
			? display_name
			: _quote_string(display_name);
		return name _ " <" _ self.address() _ ">";
	}

	method to_String () {
		return self.to_header();
	}

	method to_Dict () {
		return {
			local: local,
			domain: domain,
			display_name: display_name,
			address: self.address(),
			header: self.to_header(),
		};
	}

	static method parse ( String text ) {
		return _parse_one_address(text);
	}

	static method parse_list ( String text ) {
		return _parse_address_list(text);
	}
}

function _parse_one_address ( String raw ) {
	let text := trim(raw);
	_assert_no_crlf( text, "invalid_address" );

	if ( text eq "" ) {
		die "mail.invalid_address: empty address";
	}
	if ( _find_unquoted( text, "(", 0, false ) >= 0
		or _find_unquoted( text, ")", 0, false ) >= 0 ) {
		die "mail.invalid_address: obsolete comment syntax is not supported";
	}

	let angle_start := _find_unquoted( text, "<", 0, false );
	if ( angle_start >= 0 ) {
		let angle_end := _find_unquoted( text, ">", angle_start + 1, false );
		if ( angle_end < 0 ) {
			die "mail.invalid_address: unterminated angle address";
		}
		if ( trim( substr( text, angle_end + 1 ) ) ne "" ) {
			die "mail.invalid_address: text follows angle address";
		}

		let spec := _parse_addr_spec(
			substr( text, angle_start + 1, angle_end - angle_start - 1 ),
		);
		return new Address(
			local: spec{local},
			domain: spec{domain},
			display_name: _parse_phrase( substr( text, 0, angle_start ) ),
		);
	}

	let spec := _parse_addr_spec(text);
	return new Address( local: spec{local}, domain: spec{domain} );
}

function _parse_address_list ( String text ) {
	_assert_no_crlf( text, "invalid_address" );

	let out := [];
	let i := 0;
	let n := length text;

	while ( i < n ) {
		i := _skip_ws( text, i );
		if ( i < n and substr( text, i, 1 ) eq "," ) {
			i++;
			next;
		}

		let comma := _find_top_level( text, ",", i );
		let semi := _find_top_level( text, ";", i );
		let colon := _find_top_level( text, ":", i );
		let group_end := semi;

		if ( colon >= 0
			and ( comma < 0 or colon < comma )
			and ( semi < 0 or colon < semi ) ) {
			if ( group_end < 0 ) {
				die "mail.invalid_address: unterminated address group";
			}
			for ( let addr in _parse_address_list(
				substr( text, colon + 1, group_end - colon - 1 ),
			) ) {
				out.push(addr);
			}
			i := group_end + 1;
			next;
		}

		let end := comma < 0 ? n : comma;
		if ( semi >= 0 and semi < end ) {
			end := semi;
		}
		let token := trim( substr( text, i, end - i ) );
		if ( token ne "" ) {
			out.push( Address.parse(token) );
		}
		i := end + 1;
	}

	return out;
}

function _parse_address_header ( value ) {
	if ( value == null ) {
		return [];
	}
	return Address.parse_list(value);
}

function _mail_send_addresses_from_headers ( head, Array names ) {
	let out := [];
	for ( let name in names ) {
		for ( let value in head.decoded_all(name) ) {
			for ( let address in Address.parse_list(value) ) {
				out.push(address);
			}
		}
	}
	return out;
}

function _mail_send_envelope_from ( head ) {
	let senders := _mail_send_addresses_from_headers( head, [ "Sender" ] );
	if ( senders.length() == 1 ) {
		return senders[0].address();
	}
	if ( senders.length() > 1 ) {
		die "mail.invalid_address: Message.send requires exactly one "
			_ "Sender address";
	}

	let from_addrs := _mail_send_addresses_from_headers( head, [ "From" ] );
	if ( from_addrs.length() == 1 ) {
		return from_addrs[0].address();
	}
	if ( from_addrs.length() > 1 ) {
		die "mail.invalid_address: Message.send requires exactly one "
			_ "From address when Sender is absent";
	}
	die "mail.invalid_address: Message.send requires a Sender or From "
		_ "address";
}

function _mail_send_envelope_to ( head ) {
	let addresses := _mail_send_addresses_from_headers(
		head,
		[ "To", "Cc", "Bcc" ],
	);
	let seen := {};
	let out := [];

	for ( let address in addresses ) {
		let value := address.address();
		if ( not( value in seen ) ) {
			seen{(value)} := true;
			out.push(value);
		}
	}
	if ( out.length() == 0 ) {
		die "mail.invalid_address: Message.send requires at least one "
			_ "envelope recipient";
	}
	return out;
}

function _mail_send_headers ( head ) {
	let out := new PairList();
	for ( let pair in head.to_PairList().to_Array() ) {
		if ( lc(pair.key) ne "bcc" ) {
			out.add( pair.key, pair.value );
		}
	}
	return out;
}

class Head {
	let fields := null;

	method __build__ () {
		fields := _copy_pairlist(fields);
		for ( let pair in fields.to_Array() ) {
			_check_header_name(pair.key);
			_check_header_value(pair.value);
		}
	}

	method fields () {
		return fields.keys();
	}

	method raw ( String name, fallback := null ) {
		_check_header_name(name);
		let key := lc(name);
		for ( let pair in fields.to_Array() ) {
			if ( lc(pair.key) eq key ) {
				return pair.value;
			}
		}
		return fallback;
	}

	method raw_all ( String name ) {
		_check_header_name(name);
		let key := lc(name);
		let out := [];
		for ( let pair in fields.to_Array() ) {
			if ( lc(pair.key) eq key ) {
				out.push(pair.value);
			}
		}
		return out;
	}

	method decoded ( String name, fallback := null ) {
		let raw := self.raw( name, null );
		return fallback if raw == null;
		return _decode_rfc2047(raw);
	}

	method decoded_all ( String name ) {
		let out := [];
		for ( let value in self.raw_all(name) ) {
			out.push( _decode_rfc2047(value) );
		}
		return out;
	}

	method get ( String name, fallback := null ) {
		return self.decoded( name, fallback );
	}

	method get_all ( String name ) {
		return self.decoded_all(name);
	}

	method set ( String name, String value ) {
		self.remove(name);
		return self.add( name, value );
	}

	method add ( String name, String value ) {
		_check_header_name(name);
		_check_header_value(value);
		fields.add( name, value );
		return self;
	}

	method remove ( String name ) {
		_check_header_name(name);
		let key := lc(name);
		let kept := new PairList();
		for ( let pair in fields.to_Array() ) {
			if ( lc(pair.key) ne key ) {
				kept.add( pair.key, pair.value );
			}
		}
		fields := kept;
		return self;
	}

	method has ( String name ) {
		return not( self.raw( name, null ) == null );
	}

	method to_PairList () {
		return _copy_pairlist(fields);
	}

	method to_Iterator () {
		let items := fields.to_Array();
		return items.to_Iterator();
	}

	method content_type () {
		let value := self.decoded( "Content-Type", null );
		return value == null ? null : _header_main_value(value);
	}

	method content_transfer_encoding () {
		let value := self.decoded( "Content-Transfer-Encoding", null );
		return value == null ? null : lc( trim(value) );
	}

	method charset () {
		let value := self.decoded( "Content-Type", null );
		let got := value == null ? null : _header_parameter( value, "charset" );
		return got == null ? null : lc(got);
	}

	method boundary () {
		let value := self.decoded( "Content-Type", null );
		return value == null ? null : _header_parameter( value, "boundary" );
	}

	method message_id () {
		return self.decoded( "Message-ID", null );
	}

	method date () {
		return self.decoded( "Date", null );
	}

	method from () {
		return _parse_address_header( self.decoded( "From", null ) );
	}

	method to () {
		return _parse_address_header( self.decoded( "To", null ) );
	}

	method cc () {
		return _parse_address_header( self.decoded( "Cc", null ) );
	}

	method bcc () {
		return _parse_address_header( self.decoded( "Bcc", null ) );
	}

	method subject () {
		return self.decoded( "Subject", null );
	}
}

class _BodyBase {
	let kind := "bytes";
	let _bytes := null;
	let _parts := null;
	let _nested := null;
	let content_type := null;
	let transfer_encoding := null;
	let charset := null;
	let boundary := null;
	let _owner_message but weak;

	method __build__ () {
		_bytes := to_binary("") if _bytes == null;
		_parts := [] if _parts == null;
		self._refresh_links();
	}

	method _refresh_links () {
		if ( kind eq "multipart" ) {
			for ( let part in _parts ) {
				part._set_container(self);
			}
		}
		else if ( kind eq "nested" and not( _nested == null ) ) {
			_nested._set_container(self);
		}
		return self;
	}

	method _set_owner_message ( owner ) {
		_owner_message := owner but weak;
		return self;
	}

	method owner_message () {
		return _owner_message;
	}

	method is_multipart () {
		return kind eq "multipart";
	}

	method is_nested () {
		return kind eq "nested";
	}

	method bytes () {
		if ( kind ne "bytes" ) {
			die "mail.body: only leaf bodies expose bytes";
		}
		return _bytes;
	}

	method parts () {
		return _copy_array(_parts);
	}

	method part ( Number index ) {
		if ( kind ne "multipart" ) {
			die "mail.body: only multipart bodies expose parts";
		}
		return _parts[index];
	}

	method count () {
		if ( kind eq "multipart" ) {
			return _parts.length();
		}
		return kind eq "nested" ? 1 : 0;
	}

	method nested () {
		return kind eq "nested" ? _nested : null;
	}

	method content_type () {
		return content_type;
	}

	method transfer_encoding () {
		return transfer_encoding == null ? "identity" : lc(transfer_encoding);
	}

	method decoded () {
		if ( kind ne "bytes" ) {
			die "mail.body: multipart/nested decoding is planned for Phase 10";
		}

		let enc := self.transfer_encoding();
		if ( enc eq "base64" ) {
			return _base64_decode( to_string(_bytes) );
		}
		if ( enc eq "quoted-printable" ) {
			return _qp_decode( to_string(_bytes) );
		}
		if ( enc in [ "identity", "7bit", "8bit", "binary" ] ) {
			return _bytes;
		}

		die `mail.body: unsupported transfer encoding '${enc}'`;
	}

	method encoded () {
		if ( kind ne "bytes" ) {
			die "mail.body: multipart/nested encoding is planned for Phase 10";
		}
		return _bytes;
	}

	method to_Dict () {
		if ( kind eq "multipart" ) {
			return {
				kind: kind,
				content_type: content_type,
				transfer_encoding: transfer_encoding,
				charset: charset,
				boundary: boundary,
				parts: _parts.map( fn part -> part.to_Dict() ),
			};
		}
		if ( kind eq "nested" ) {
			return {
				kind: kind,
				content_type: content_type,
				transfer_encoding: transfer_encoding,
				charset: charset,
				message: _nested == null ? null : _nested.to_Dict(),
			};
		}
		return {
			kind: kind,
			content_type: content_type,
			transfer_encoding: transfer_encoding,
			charset: charset,
			bytes: _bytes,
		};
	}

}

class Body extends _BodyBase {
	static method bytes ( BinaryString raw, ... PairList options ) {
		let opts := _body_options(options);
		return new Body(
			kind: "bytes",
			_bytes: raw,
			content_type: opts{content_type},
			transfer_encoding: opts{transfer_encoding},
			charset: opts{charset},
		);
	}

	static method multipart (
		Array parts,
		String boundary,
		... PairList options
	) {
		_assert_no_crlf( boundary, "body" );
		let opts := _body_options(options);
		let ctype := opts{content_type} == null
			? "multipart/mixed"
			: opts{content_type};
		let body := new Body(
			kind: "multipart",
			_parts: _copy_array(parts),
			content_type: ctype,
			transfer_encoding: opts{transfer_encoding},
			charset: opts{charset},
			boundary: boundary,
		);
		return body._refresh_links();
	}

	static method nested ( message, ... PairList options ) {
		let opts := _body_options(options);
		let ctype := opts{content_type} == null
			? "message/rfc822"
			: opts{content_type};
		let body := new Body(
			kind: "nested",
			_nested: message,
			content_type: ctype,
			transfer_encoding: opts{transfer_encoding},
			charset: opts{charset},
		);
		return body._refresh_links();
	}
}

class Message {
	let head := null;
	let body := null;
	let _container but weak;

	method __build__ () {
		head := new Head() if head == null;
		body := Body.bytes( to_binary("") ) if body == null;
		body._set_owner_message(self);
	}

	method _set_container ( container ) {
		_container := container but weak;
		return self;
	}

	method head () {
		return head;
	}

	method body () {
		return body;
	}

	method header ( String name, fallback := null ) {
		return head.get( name, fallback );
	}

	method headers ( String name ) {
		return head.get_all(name);
	}

	method set_header ( String name, String value ) {
		head.set( name, value );
		return self;
	}

	method add_header ( String name, String value ) {
		head.add( name, value );
		return self;
	}

	method remove_header ( String name ) {
		head.remove(name);
		return self;
	}

	method subject () {
		return head.subject();
	}

	method from () {
		return head.from();
	}

	method to () {
		return head.to();
	}

	method cc () {
		return head.cc();
	}

	method bcc () {
		return head.bcc();
	}

	method date () {
		return head.date();
	}

	method message_id () {
		return head.message_id();
	}

	method is_part () {
		return not( _container == null );
	}

	method container () {
		return _container;
	}

	method toplevel () {
		let current := self;
		while ( not( current.container() == null ) ) {
			let owner := current.container().owner_message();
			return current if owner == null;
			current := owner;
		}
		return current;
	}

	method send ( mailer, ... PairList options ) {
		if ( mailer == null or not( mailer can "send" ) ) {
			die "mail.unsupported: Message.send requires a compatible mailer";
		}

		let opts := _mail_send_options(options);
		let envelope_from := opts{envelope_from} == null
			? _mail_send_envelope_from(head)
			: opts{envelope_from};
		let envelope_to := opts{envelope_to} == null
			? _mail_send_envelope_to(head)
			: opts{envelope_to};
		let headers := _mail_send_headers(head);
		let send_body := _mail_send_serialize_body(self);

		return mailer.send(
			envelope_from,
			envelope_to,
			headers,
			send_body,
			opts{send_options},
		);
	}

	method to_Dict () {
		return {
			head: head.to_PairList(),
			body: body.to_Dict(),
			is_part: self.is_part(),
		};
	}
}

class Parser {
	let strict := false;
	let max_header_bytes := 65536;
	let max_depth := 32;
	let decode_transfer := false;
	let _warnings := [];

	method __build__ () {
		if ( not( strict instanceof Boolean ) ) {
			die "mail.parse: strict expects Boolean";
		}
		if ( not( max_header_bytes instanceof Number ) or max_header_bytes < 0 ) {
			die "mail.parse: max_header_bytes expects non-negative Number";
		}
		if ( not( max_depth instanceof Number ) or max_depth < 0 ) {
			die "mail.parse: max_depth expects non-negative Number";
		}
		if ( not( decode_transfer instanceof Boolean ) ) {
			die "mail.parse: decode_transfer expects Boolean";
		}

		_warnings := [];
	}

	method warnings () {
		return _copy_array(_warnings);
	}

	method _warn ( String message ) {
		_warnings.push( "mail.parse: " _ message );
		return self;
	}

	method _fail ( String message ) {
		die "mail.parse: " _ message;
	}

	method _enforce_depth ( Number depth ) {
		if ( depth > max_depth ) {
			self._fail("max_depth exceeded");
		}
	}

	method _parse_head ( Array header_bytes ) {
		let fields := _parse_mail_header_fields(header_bytes);
		return new Head( fields: fields );
	}

	method _header_malformed_message ( BinaryString raw ) {
		self._warn("malformed header section; preserving original bytes");
		return new Message(
			head: new Head(),
			body: Body.bytes(raw),
		);
	}

	method _safe_content_type ( Head head ) {
		try {
			return head.content_type();
		}
		catch {
			if ( strict ) {
				self._fail("malformed Content-Type header");
			}
			self._warn("malformed Content-Type header");
			return null;
		}
	}

	method _safe_charset ( Head head ) {
		try {
			return head.charset();
		}
		catch {
			if ( strict ) {
				self._fail("malformed Content-Type charset");
			}
			self._warn("malformed Content-Type charset");
			return null;
		}
	}

	method _safe_boundary ( Head head ) {
		try {
			return head.boundary();
		}
		catch {
			if ( strict ) {
				self._fail("malformed multipart boundary");
			}
			self._warn("malformed multipart boundary");
			return null;
		}
	}

	method _safe_transfer_encoding ( Head head ) {
		try {
			return head.content_transfer_encoding();
		}
		catch {
			if ( strict ) {
				self._fail("malformed Content-Transfer-Encoding header");
			}
			self._warn("malformed Content-Transfer-Encoding header");
			return null;
		}
	}

	method _leaf_body (
		BinaryString raw,
		content_type,
		transfer_encoding,
		charset
	) {
		return Body.bytes(
			raw,
			content_type: content_type,
			transfer_encoding: transfer_encoding,
			charset: charset,
		);
	}

	method _multipart_body (
		BinaryString raw,
		Array body_bytes,
		String content_type,
		transfer_encoding,
		charset,
		boundary,
		Number depth
	) {
		if ( boundary == null or boundary eq "" or _has_crlf(boundary) ) {
			if ( strict ) {
				self._fail("multipart body missing boundary");
			}
			self._warn("multipart body missing boundary; using leaf body");
			return self._leaf_body( raw, content_type, transfer_encoding, charset );
		}

		let scanned := _scan_multipart_parts( body_bytes, boundary );
		if ( not scanned{opened} ) {
			if ( strict ) {
				self._fail("multipart boundary open delimiter not found");
			}
			self._warn("multipart boundary open delimiter not found; using leaf body");
			return self._leaf_body( raw, content_type, transfer_encoding, charset );
		}

		if ( scanned{preamble_size} > 0 ) {
			self._warn("multipart preamble ignored");
		}
		if ( scanned{epilogue_size} > 0 ) {
			self._warn("multipart epilogue ignored");
		}
		if ( not scanned{closed} ) {
			if ( strict ) {
				self._fail("multipart boundary close delimiter not found");
			}
			self._warn("multipart boundary close delimiter not found");
		}

		let messages := [];
		for ( let part_bytes in scanned{parts} ) {
			messages.push(
				self._parse_message( _bytes_to_binary(part_bytes), depth + 1 ),
			);
		}

		return Body.multipart(
			messages,
			boundary,
			content_type: content_type,
			transfer_encoding: transfer_encoding,
			charset: charset,
		);
	}

	method _nested_body (
		BinaryString raw,
		String content_type,
		transfer_encoding,
		charset,
		Number depth
	) {
		if ( not _is_identity_transfer_encoding(transfer_encoding) ) {
			if ( strict ) {
				self._fail("encoded message/rfc822 bodies are not supported");
			}
			self._warn("encoded message/rfc822 body kept as leaf body");
			return self._leaf_body( raw, content_type, transfer_encoding, charset );
		}

		return Body.nested(
			self._parse_message( raw, depth + 1 ),
			content_type: content_type,
			transfer_encoding: transfer_encoding,
			charset: charset,
		);
	}

	method _parse_message ( BinaryString raw, Number depth ) {
		self._enforce_depth(depth);

		let bytes := _binary_to_bytes(raw);
		let split := _split_message_bytes(bytes);
		if ( split{header_size} > max_header_bytes ) {
			self._fail("max_header_bytes exceeded");
		}

		let head;
		if ( strict ) {
			head := try {
				self._parse_head( split{header_bytes} );
			} catch {
				self._fail("malformed header section");
			};
		}
		else {
			let fallback := null;
			head := try {
				self._parse_head( split{header_bytes} );
			} catch {
				fallback := self._header_malformed_message(raw);
				null;
			};
			return fallback if not( fallback == null );
		}

		let raw_body := _bytes_to_binary( split{body_bytes} );
		let content_type := self._safe_content_type(head);
		let charset := self._safe_charset(head);
		let boundary := self._safe_boundary(head);
		let transfer_encoding := self._safe_transfer_encoding(head);
		let body;

		if ( not( content_type == null ) and index( content_type, "multipart/" ) == 0 ) {
			body := self._multipart_body(
				raw_body,
				split{body_bytes},
				content_type,
				transfer_encoding,
				charset,
				boundary,
				depth,
			);
		}
		else if ( content_type eq "message/rfc822" ) {
			body := self._nested_body(
				raw_body,
				content_type,
				transfer_encoding,
				charset,
				depth,
			);
		}
		else {
			body := self._leaf_body(
				raw_body,
				content_type,
				transfer_encoding,
				charset,
			);
		}

		return new Message( head: head, body: body );
	}

	method parse ( BinaryString bytes ) {
		return self._parse_message( bytes, 0 );
	}
}

function _mail_serialize_fail ( String message ) {
	die "mail.serialize: " _ message;
}

function _mail_serialize_append_text (
	Array out,
	String text,
	String context
) {
	let i := 0;
	let n := length text;

	while ( i < n ) {
		let cp := ord( substr( text, i, 1 ) );
		if ( cp > 255 ) {
			_mail_serialize_fail(
				context _ " contains a character outside byte range",
			);
		}
		out.push(cp);
		i++;
	}

	return out;
}

function _mail_serialize_append_binary ( Array out, BinaryString bytes ) {
	for ( let b in _binary_to_bytes(bytes) ) {
		out.push(b);
	}
	return out;
}

function _mail_serialize_header_words ( String value ) {
	let words := [];
	let i := 0;
	let n := length value;

	while ( i < n ) {
		i := _skip_ws( value, i );
		last if i >= n;

		let start := i;
		while ( i < n and not _is_ws( substr( value, i, 1 ) ) ) {
			i++;
		}
		words.push( substr( value, start, i - start ) );
	}

	return words;
}

function _mail_serialize_folded_header_lines (
	String name,
	String value,
	Number line_length
) {
	let unfolded := name _ ": " _ value;
	return [ unfolded ] if length unfolded <= line_length;

	let words := _mail_serialize_header_words(value);
	return [ unfolded ] if words.length() <= 1;

	let lines := [];
	let first_prefix := name _ ": ";
	let line := first_prefix;

	for ( let word in words ) {
		let separator := ( line eq first_prefix or line eq "\t" )
			? ""
			: " ";
		let candidate := line _ separator _ word;

		if ( length candidate <= line_length
			or line eq first_prefix
			or line eq "\t" ) {
			line := candidate;
			next;
		}

		lines.push(line);
		line := "\t" _ word;
	}

	lines.push(line);
	return lines;
}

function _mail_serialize_quote_boundary ( String boundary ) {
	return _quote_string(boundary);
}

class Serializer {
	let newline := "\r\n";
	let fold_headers := true;
	let line_length := 78;
	let generate_boundary := false;
	let _boundary_counter := 0;

	method __build__ () {
		if ( not( newline instanceof String ) ) {
			self._fail("newline expects String");
		}
		if ( newline ne "\r\n" and newline ne "\n" ) {
			self._fail("newline must be CRLF or LF");
		}
		if ( not( fold_headers instanceof Boolean ) ) {
			self._fail("fold_headers expects Boolean");
		}
		if ( not( line_length instanceof Number ) ) {
			self._fail("line_length expects Number");
		}
		if ( line_length < 1 ) {
			self._fail("line_length must be positive");
		}
		if ( not( generate_boundary instanceof Boolean ) ) {
			self._fail("generate_boundary expects Boolean");
		}
		if ( not( _boundary_counter instanceof Number ) or _boundary_counter < 0 ) {
			self._fail("_boundary_counter expects non-negative Number");
		}

		line_length := int(line_length);
		_boundary_counter := int(_boundary_counter);
	}

	method _fail ( String message ) {
		_mail_serialize_fail(message);
	}

	method _reject_method_options ( PairList options ) {
		let items := options.to_Array();
		if ( items.length() > 0 ) {
			self._fail(
				"unsupported method option '" _ items[0].key
					_ "'; use constructor named arguments",
			);
		}
	}

	method _validate_header_pair ( pair ) {
		if ( not( pair.key instanceof String ) or not _valid_header_name(pair.key) ) {
			self._fail("invalid header name");
		}
		if ( not( pair.value instanceof String ) ) {
			self._fail("header value expects String, got " _ typeof pair.value);
		}
		if ( _has_crlf(pair.value) ) {
			self._fail("header value must not contain CR or LF");
		}
	}

	method _append_line ( Array out, String line ) {
		_mail_serialize_append_text( out, line, "header line" );
		_mail_serialize_append_text( out, newline, "newline" );
	}

	method _append_header ( Array out, String name, String value ) {
		let lines := fold_headers
			? _mail_serialize_folded_header_lines(
				name,
				value,
				line_length,
			)
			: [ name _ ": " _ value ];

		for ( let line in lines ) {
			self._append_line( out, line );
		}
	}

	method _generated_boundary () {
		_boundary_counter++;
		return `zuzu-boundary-${_boundary_counter}`;
	}

	method _validate_boundary ( String boundary ) {
		if ( boundary eq "" ) {
			self._fail("multipart body missing boundary");
		}
		if ( _has_crlf(boundary) ) {
			self._fail("multipart boundary must not contain CR or LF");
		}
		return boundary;
	}

	method _header_boundary ( Head head ) {
		let value := head.raw( "Content-Type", null );
		return null if value == null;

		let boundary := null;
		try {
			boundary := _header_parameter( value, "boundary" );
		}
		catch {
			boundary := null;
		}

		return boundary;
	}

	method _body_content_type ( body ) {
		let data := body.to_Dict();
		let content_type := data{content_type};
		return "multipart/mixed" if content_type == null;
		if ( not( content_type instanceof String ) ) {
			self._fail("multipart content_type expects String");
		}
		return content_type;
	}

	method _content_type_with_boundary (
		body,
		String value,
		String boundary
	) {
		let existing := null;
		let main := null;
		try {
			existing := _header_parameter( value, "boundary" );
			main := _header_main_value(value);
		}
		catch {
			existing := null;
			main := null;
		}

		let quoted := _mail_serialize_quote_boundary(boundary);
		if ( not( existing == null ) and existing eq "" ) {
			return self._body_content_type(body) _ "; boundary=" _ quoted;
		}
		if ( existing == null ) {
			if ( main == null or index( main, "multipart/" ) != 0 ) {
				return self._body_content_type(body) _ "; boundary=" _ quoted;
			}
			return value _ "; boundary=" _ quoted;
		}

		return value;
	}

	method _generated_content_type ( body, String boundary ) {
		return self._body_content_type(body)
			_ "; boundary="
			_ _mail_serialize_quote_boundary(boundary);
	}

	method _full_context ( Message message ) {
		let body := message.body();
		let data := body.to_Dict();
		if ( data{kind} ne "multipart" ) {
			return { multipart: false, boundary: null };
		}

		let boundary := data{boundary};
		if ( not( boundary == null ) and not( boundary instanceof String ) ) {
			self._fail("multipart boundary expects String");
		}
		if ( boundary == null or boundary eq "" ) {
			boundary := self._header_boundary( message.head() );
		}
		if ( not( boundary == null ) and not( boundary instanceof String ) ) {
			self._fail("multipart boundary expects String");
		}
		if ( boundary == null or boundary eq "" ) {
			if ( generate_boundary ) {
				boundary := self._generated_boundary();
			}
			else {
				return { multipart: true, boundary: null };
			}
		}

		return {
			multipart: true,
			boundary: self._validate_boundary(boundary),
		};
	}

	method _multipart_boundary ( body, boundary_override := null ) {
		if ( not( boundary_override == null ) ) {
			return self._validate_boundary(boundary_override);
		}

		let data := body.to_Dict();
		let boundary := data{boundary};

		if ( boundary == null or boundary eq "" ) {
			if ( generate_boundary ) {
				return self._generated_boundary();
			}
			self._fail("multipart body missing boundary");
		}
		if ( not( boundary instanceof String ) ) {
			self._fail("multipart boundary expects String");
		}
		return self._validate_boundary(boundary);
	}

	method _serialize_body ( body, boundary_override := null ) {
		let data := body.to_Dict();
		let kind := data{kind};

		if ( kind eq "bytes" ) {
			return body.encoded();
		}
		if ( kind eq "nested" ) {
			let nested := body.nested();
			if ( nested == null ) {
				self._fail("message/rfc822 body missing nested message");
			}
			return self.serialize(nested);
		}
		if ( kind ne "multipart" ) {
			self._fail("unsupported body kind '" _ kind _ "'");
		}

		let boundary := self._multipart_boundary( body, boundary_override );
		let out := [];

		for ( let part in body.parts() ) {
			_mail_serialize_append_text( out, "--" _ boundary, "boundary" );
			_mail_serialize_append_text( out, newline, "newline" );
			_mail_serialize_append_binary( out, self.serialize(part) );
			_mail_serialize_append_text( out, newline, "newline" );
		}

		_mail_serialize_append_text( out, "--" _ boundary _ "--", "boundary" );
		_mail_serialize_append_text( out, newline, "newline" );

		return _bytes_to_binary(out);
	}

	method serialize_body ( Message message, ... PairList options ) {
		self._reject_method_options(options);
		return self._serialize_body( message.body() );
	}

	method serialize ( Message message, ... PairList options ) {
		self._reject_method_options(options);
		let out := [];
		let context := self._full_context(message);
		let saw_content_type := false;

		for ( let pair in message.head().to_PairList().to_Array() ) {
			self._validate_header_pair(pair);
			let value := pair.value;
			if ( context{multipart}
				and lc(pair.key) eq "content-type"
				and not saw_content_type
				and not( context{boundary} == null ) ) {
				value := self._content_type_with_boundary(
					message.body(),
					value,
					context{boundary},
				);
				saw_content_type := true;
			}
			else if ( lc(pair.key) eq "content-type" ) {
				saw_content_type := true;
			}
			self._append_header( out, pair.key, value );
		}

		if ( context{multipart}
			and not saw_content_type
			and not( context{boundary} == null ) ) {
			self._append_header(
				out,
				"Content-Type",
				self._generated_content_type(
					message.body(),
					context{boundary},
				),
			);
		}

		_mail_serialize_append_text( out, newline, "newline" );
		_mail_serialize_append_binary(
			out,
			self._serialize_body( message.body(), context{boundary} ),
		);

		return _bytes_to_binary(out);
	}
}

function _mail_send_serialize_body ( message ) {
	return ( new Serializer() ).serialize_body(message);
}