from std/mail import
	Address,
	Body,
	Head,
	Message,
	Parser,
	Serializer,
	parse_datetime,
	format_datetime;
from std/time import Time;
from test/more import *;

is( typeof Address, "Class", "Address is exported" );
is( typeof Body, "Class", "Body is exported" );
is( typeof Head, "Class", "Head is exported" );
is( typeof Message, "Class", "Message is exported" );
is( typeof Parser, "Class", "Parser is exported" );
is( typeof Serializer, "Class", "Serializer is exported" );
is( typeof parse_datetime, "Function", "parse_datetime is exported" );
is( typeof format_datetime, "Function", "format_datetime is exported" );

let addr := new Address(
	local: "alice",
	domain: "example.test",
	display_name: "Alice Example",
);
is( addr.local(), "alice", "Address local accessor" );
is( addr.domain(), "example.test", "Address domain accessor" );
is( addr.address(), "alice@example.test", "Address address()" );
is(
	addr.to_header(),
	"Alice Example <alice@example.test>",
	"Address to_header display form",
);
is( addr.to_String(), addr.to_header(), "Address to_String aliases header" );
is( addr.to_Dict(){address}, "alice@example.test", "Address to_Dict" );

let plain := Address.parse("bob@example.test");
is( plain.display_name(), null, "plain address has no display name" );
is( plain.address(), "bob@example.test", "plain address parses" );

let display := Address.parse("\"Doe, Jane\" <jane@example.test>");
is( display.display_name(), "Doe, Jane", "quoted display name parses" );
is(
	display.to_header(),
	"\"Doe, Jane\" <jane@example.test>",
	"quoted display name serializes safely",
);

let unquoted_display := Address.parse("Ada Lovelace <ada@example.test>");
is(
	unquoted_display.display_name(),
	"Ada Lovelace",
	"unquoted display name parses",
);

let quoted_local := Address.parse("\"weird local\"@[127.0.0.1]");
is( quoted_local.local(), "weird local", "quoted local parses" );
is( quoted_local.domain(), "[127.0.0.1]", "domain literal parses" );
is(
	quoted_local.address(),
	"\"weird local\"@[127.0.0.1]",
	"quoted local serializes",
);

let listed := Address.parse_list(
	"Team: Ada <ada@example.test>, \"B\" <b@example.test>; solo@example.test",
);
is( listed.length(), 3, "address list flattens group syntax" );
is( listed[0].display_name(), "Ada", "group first display name" );
is( listed[1].address(), "b@example.test", "group second address" );
is( listed[2].address(), "solo@example.test", "post-group address" );

let comma_list := Address.parse_list(
	"\"Doe, Jane\" <jane@example.test>, bob@example.test",
);
is(
	comma_list[0].display_name(),
	"Doe, Jane",
	"quoted display name with comma parses inside list",
);
is( comma_list[1].address(), "bob@example.test", "list continues after comma" );

let empty_group := Address.parse_list(
	"Undisclosed recipients:; visible@example.test",
);
is( empty_group.length(), 1, "empty group flattens to no addresses" );
is(
	empty_group[0].address(),
	"visible@example.test",
	"address after empty group parses",
);

like(
	exception( function () {
		Address.parse("bad\nname@example.test");
	} ).to_String(),
	/CR or LF/,
	"Address rejects CRLF injection",
);
like(
	exception( function () {
		Address.parse_list("alice@example.test,\r\nBcc: evil@example.test");
	} ).to_String(),
	/CR or LF/,
	"Address parse_list rejects CRLF injection",
);
like(
	exception( function () {
		Address.parse("Ada (comment) <ada@example.test>");
	} ).to_String(),
	/comment syntax/,
	"Address rejects obsolete comment syntax clearly",
);
like(
	exception( function () {
		Address.parse("<@old.example:ada@example.test>");
	} ).to_String(),
	/route syntax/,
	"Address rejects obsolete route syntax clearly",
);

let pairs := new PairList();
pairs.add( "X-Dup", "one" );
pairs.add( "x-dup", "two" );
pairs.add( "Subject", "=?UTF-8?Q?Hello?= \t =?UTF-8?Q?_World?=" );
pairs.add( "X-Latin", "=?ISO-8859-1?B?Q2Fm6Q==?=" );
pairs.add( "X-Bad", "=?X-UNKNOWN?Q?abc?=" );
pairs.add(
	"Content-Type",
	"text/plain; charset=UTF-8; boundary=\"mail-boundary\"",
);
pairs.add( "Content-Transfer-Encoding", "Quoted-Printable" );
pairs.add( "From", "Ada <ada@example.test>" );
pairs.add( "To", "Team: Bob <bob@example.test>, cara@example.test;" );
pairs.add( "Cc", "\"Dee\" <dee@example.test>" );
pairs.add( "Bcc", "eve@example.test" );
pairs.add( "Date", "Fri, 08 May 2026 12:00:00 +0000" );
pairs.add( "Message-ID", "<mail-phase8@example.test>" );

let head := new Head( fields: pairs );
pairs.add( "X-Dup", "late" );
is( head.fields()[0], "X-Dup", "Head fields preserve order" );
is( head.raw_all("x-dup"), [ "one", "two" ], "Head preserves duplicates" );
is( head.raw("SUBJECT"), pairs.get("Subject"), "Head raw is case-insensitive" );
is(
	head.subject(),
	"Hello World",
	"Head decodes adjacent RFC 2047 encoded words",
);
is( head.get("X-Latin"), "Café", "Head decodes Latin-1 encoded words" );
is(
	head.get("X-Bad"),
	"=?X-UNKNOWN?Q?abc?=",
	"Head preserves unsupported encoded words",
);
is( head.get("Subject"), head.subject(), "Head get aliases decoded" );
is( head.get_all("X-Dup"), [ "one", "two" ], "Head get_all alias" );
ok( head.has("message-id"), "Head has is case-insensitive" );
is( head.content_type(), "text/plain", "Head content_type helper" );
is( head.charset(), "utf-8", "Head charset helper" );
is( head.boundary(), "mail-boundary", "Head boundary helper" );
is(
	head.content_transfer_encoding(),
	"quoted-printable",
	"Head content_transfer_encoding helper",
);
is( head.message_id(), "<mail-phase8@example.test>", "message_id helper" );
is( head.date(), "Fri, 08 May 2026 12:00:00 +0000", "date helper" );
is( head.from()[0].address(), "ada@example.test", "from helper parses" );
is( head.to().length(), 2, "to helper parses flattened groups" );
is( head.cc()[0].display_name(), "Dee", "cc helper parses" );
is( head.bcc()[0].local(), "eve", "bcc helper parses" );

let copied := head.to_PairList();
copied.add( "X-Dup", "mutated" );
is( head.raw_all("X-Dup"), [ "one", "two" ], "to_PairList is defensive" );

let iterated_pairs := [];
for ( let pair in head.to_Iterator() ) {
	iterated_pairs.push(pair);
}
let first_pair := iterated_pairs[0];
is( first_pair.key, "X-Dup", "Head iterator yields Pair key" );
is( first_pair.value, "one", "Head iterator yields Pair value" );

head.set( "x-dup", "replacement" );
is( head.raw_all("X-Dup"), [ "replacement" ], "Head set replaces duplicates" );
head.add( "X-Dup", "second" );
head.remove("X-DUP");
ok( not head.has("x-dup"), "Head remove is case-insensitive" );
like(
	exception( function () {
		head.add( "Bad:Name", "value" );
	} ).to_String(),
	/invalid header name/,
	"Head rejects invalid header names",
);
like(
	exception( function () {
		head.add( "X-Bad", "bad\rvalue" );
	} ).to_String(),
	/CR or LF/,
	"Head rejects header value injection",
);

let b64 := Body.bytes(
	to_binary("SGVsbG8="),
	transfer_encoding: "base64",
	content_type: "text/plain",
);
is( b64.is_multipart(), false, "leaf body is not multipart" );
is( b64.is_nested(), false, "leaf body is not nested" );
is( to_string( b64.decoded() ), "Hello", "Body decodes base64 leaf" );
is( to_string( b64.encoded() ), "SGVsbG8=", "Body encoded returns raw bytes" );
is( b64.content_type(), "text/plain", "Body content_type option" );
is( b64.transfer_encoding(), "base64", "Body transfer_encoding option" );

let qp := Body.bytes(
	to_binary("Hello=20world=0D=0A"),
	transfer_encoding: "quoted-printable",
);
is(
	to_string( qp.decoded() ),
	"Hello world\r\n",
	"Body decodes quoted-printable leaf",
);

let identity := Body.bytes(to_binary("Raw"));
is(
	identity.decoded(),
	identity.bytes(),
	"identity decoded returns raw bytes",
);

let part1 := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Part 1")),
);
let part2 := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Part 2")),
);
let multipart := Body.multipart( [ part1, part2 ], "boundary-1" );
let parent := new Message( head: new Head(), body: multipart );
is( multipart.is_multipart(), true, "multipart body reports multipart" );
is( multipart.count(), 2, "multipart body count" );
is( multipart.part(1), part2, "multipart part accessor" );
is( part1.container(), multipart, "multipart child has weak container" );
is( part1.toplevel(), parent, "part toplevel walks through body owner" );
let visible_parts := multipart.parts();
visible_parts.clear();
is( multipart.count(), 2, "Body parts() returns defensive array" );
like(
	exception( function () {
		multipart.decoded();
	} ).to_String(),
	/Phase 10/,
	"multipart decoded is a clear later-phase error",
);

let nested_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Nested")),
);
let nested_body := Body.nested(nested_message);
let wrapper := new Message( head: new Head(), body: nested_body );
is( nested_body.is_nested(), true, "nested body reports nested" );
is( nested_body.nested(), nested_message, "nested body exposes message" );
is( nested_message.container(), nested_body, "nested message container set" );
is( nested_message.toplevel(), wrapper, "nested toplevel walks to wrapper" );

let message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Message")),
);
message.set_header( "Subject", "Original" );
message.add_header( "To", "reader@example.test" );
is( message.header("Subject"), "Original", "Message header convenience" );
is( message.subject(), "Original", "Message subject convenience" );
is( message.to()[0].address(), "reader@example.test", "Message to helper" );
message.remove_header("Subject");
is( message.subject(), null, "Message remove_header convenience" );
is( message.is_part(), false, "top-level message is not a part" );
is( message.container(), null, "top-level message has no container" );
is( message.toplevel(), message, "top-level message returns itself" );
is( message.to_Dict(){body}{kind}, "bytes", "Message to_Dict includes body" );

class FakeMailer {
	let calls := [];
	let result := null;

	method __build__ () {
		result := { status: "sent" } if result == null;
	}

	method send (
		envelope_from,
		envelope_to,
		headers,
		body,
		options
	) {
		calls.push(
			{
				envelope_from: envelope_from,
				envelope_to: envelope_to,
				headers: headers,
				body: body,
				options: options,
			},
		);
		return result;
	}

	method call () {
		return calls[0];
	}

	method call_count () {
		return calls.length();
	}
}

function send_header_names ( call ) {
	let out := [];
	for ( let pair in call{headers}.to_Array() ) {
		out.push(pair.key);
	}
	return out;
}

function send_header_pairs ( call ) {
	let out := [];
	for ( let pair in call{headers}.to_Array() ) {
		out.push( pair.key _ "=" _ pair.value );
	}
	return out;
}

let sender_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Sender wins\r\n")),
);
sender_message.add_header( "Sender", "sender@example.test" );
sender_message.add_header( "From", "from@example.test" );
sender_message.add_header( "To", "to@example.test" );
let sender_mailer := new FakeMailer();
let sender_result := sender_message.send(sender_mailer);
is( sender_result, sender_mailer{result}, "Message.send returns mailer result" );
is(
	sender_mailer.call(){envelope_from},
	"sender@example.test",
	"Message.send prefers Sender over From",
);

let from_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("From fallback\r\n")),
);
from_message.add_header( "From", "author@example.test" );
from_message.add_header( "To", "reader@example.test" );
let from_mailer := new FakeMailer();
from_message.send(from_mailer);
is(
	from_mailer.call(){envelope_from},
	"author@example.test",
	"Message.send falls back to From",
);

let multiple_from := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Bad From\r\n")),
);
multiple_from.add_header( "From", "one@example.test" );
multiple_from.add_header( "From", "two@example.test" );
multiple_from.add_header( "To", "reader@example.test" );
like(
	exception( function () {
		multiple_from.send( new FakeMailer() );
	} ).to_String(),
	/mail\.invalid_address: Message\.send requires exactly one From/,
	"Message.send rejects multiple From without Sender",
);

let recipients_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Recipients\r\n")),
);
recipients_message.add_header( "From", "sender@example.test" );
recipients_message.add_header(
	"To",
	"Team: to@example.test, dup@example.test;",
);
recipients_message.add_header( "Cc", "cc@example.test, dup@example.test" );
recipients_message.add_header( "Bcc", "bcc@example.test" );
let recipients_mailer := new FakeMailer();
recipients_message.send(recipients_mailer);
is(
	recipients_mailer.call(){envelope_to},
	[
		"to@example.test",
		"dup@example.test",
		"cc@example.test",
		"bcc@example.test",
	],
	"Message.send derives, flattens, and deduplicates recipients",
);
is(
	send_header_names( recipients_mailer.call() ),
	[ "From", "To", "Cc" ],
	"Message.send removes Bcc from delivery headers",
);
is(
	recipients_message.head().raw_all("Bcc"),
	[ "bcc@example.test" ],
	"Message.send does not mutate original Bcc header",
);

let mixed_bcc_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Mixed Bcc\r\n")),
);
mixed_bcc_message.add_header( "From", "sender@example.test" );
mixed_bcc_message.add_header( "To", "visible@example.test" );
mixed_bcc_message.add_header( "bCc", "hidden-one@example.test" );
mixed_bcc_message.add_header( "BCC", "hidden-two@example.test" );
let mixed_bcc_mailer := new FakeMailer();
mixed_bcc_message.send(mixed_bcc_mailer);
is(
	mixed_bcc_mailer.call(){envelope_to},
	[
		"visible@example.test",
		"hidden-one@example.test",
		"hidden-two@example.test",
	],
	"Message.send uses case-insensitive Bcc recipients in envelope",
);
is(
	send_header_names( mixed_bcc_mailer.call() ),
	[ "From", "To" ],
	"Message.send removes all case-insensitive Bcc headers",
);
is(
	mixed_bcc_message.head().raw_all("Bcc"),
	[ "hidden-one@example.test", "hidden-two@example.test" ],
	"Message.send leaves case-insensitive Bcc headers in original head",
);

let override_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Overrides\r\n")),
);
override_message.add_header( "From", "header@example.test" );
let override_mailer := new FakeMailer();
override_message.send(
	override_mailer,
	envelope_from: "bounce@example.test",
	envelope_to: "override@example.test",
);
is(
	override_mailer.call(){envelope_from},
	"bounce@example.test",
	"Message.send accepts explicit envelope_from",
);
is(
	override_mailer.call(){envelope_to},
	[ "override@example.test" ],
	"Message.send accepts explicit envelope_to String",
);

let override_array_mailer := new FakeMailer();
override_message.send(
	override_array_mailer,
	envelope_from: "bounce@example.test",
	envelope_to: [ "array-one@example.test", "array-two@example.test" ],
);
is(
	override_array_mailer.call(){envelope_to},
	[ "array-one@example.test", "array-two@example.test" ],
	"Message.send accepts explicit envelope_to Array",
);

let send_options := new PairList();
send_options.add( "reject_partial", true );
let options_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Options\r\n")),
);
options_message.add_header( "From", "sender@example.test" );
options_message.add_header( "To", "reader@example.test" );
let options_mailer := new FakeMailer();
options_message.send( options_mailer, send_options: send_options );
is(
	options_mailer.call(){options}.get("reject_partial"),
	true,
	"Message.send forwards send_options as fifth argument",
);

let dict_options_mailer := new FakeMailer();
options_message.send( dict_options_mailer, send_options: { trace: true } );
is(
	dict_options_mailer.call(){options}{trace},
	true,
	"Message.send forwards Dict send_options as fifth argument",
);

like(
	exception( function () {
		options_message.send( new FakeMailer(), priority: "high" );
	} ).to_String(),
	/mail\.send: unsupported option 'priority'/,
	"Message.send rejects unknown options",
);
like(
	exception( function () {
		options_message.send( new FakeMailer(), envelope_from: 1 );
	} ).to_String(),
	/mail\.send: envelope_from expects String/,
	"Message.send validates envelope_from option type",
);
like(
	exception( function () {
		options_message.send( new FakeMailer(), envelope_to: [ "ok", 1 ] );
	} ).to_String(),
	/mail\.send: envelope_to expects String or Array of String/,
	"Message.send validates envelope_to array item type",
);
like(
	exception( function () {
		options_message.send( new FakeMailer(), send_options: true );
	} ).to_String(),
	/mail\.send: send_options expects Dict or PairList/,
	"Message.send validates send_options type",
);
like(
	exception( function () {
		options_message.send(null);
	} ).to_String(),
	/mail\.unsupported: Message\.send requires a compatible mailer/,
	"Message.send rejects null mailer",
);
like(
	exception( function () {
		options_message.send({});
	} ).to_String(),
	/mail\.unsupported: Message\.send requires a compatible mailer/,
	"Message.send rejects incompatible mailer",
);

let no_sender_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("No sender\r\n")),
);
no_sender_message.add_header( "To", "reader@example.test" );
like(
	exception( function () {
		no_sender_message.send( new FakeMailer() );
	} ).to_String(),
	/mail\.invalid_address: Message\.send requires a Sender or From address/,
	"Message.send rejects missing envelope sender",
);

let no_recipient_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("No recipient\r\n")),
);
no_recipient_message.add_header( "From", "sender@example.test" );
like(
	exception( function () {
		no_recipient_message.send( new FakeMailer() );
	} ).to_String(),
	/mail\.invalid_address: Message\.send requires at least one envelope recipient/,
	"Message.send rejects missing envelope recipient",
);

let body_message := new Message(
	head: new Head(),
	body: Body.bytes(to_binary("Simple body\r\n")),
);
body_message.add_header( "From", "sender@example.test" );
body_message.add_header( "To", "reader@example.test" );
let body_mailer := new FakeMailer();
body_message.send(body_mailer);
is(
	to_string( body_mailer.call(){body} ),
	"Simple body\r\n",
	"Message.send serializes simple body",
);
is(
	body_mailer.call(){body},
	( new Serializer() ).serialize_body(body_message),
	"Message.send simple body exactly matches Serializer.serialize_body",
);

let send_part1_headers := new PairList();
send_part1_headers.add( "Content-Type", "text/plain" );
let send_part2_headers := new PairList();
send_part2_headers.add( "Content-Type", "text/plain" );
let multipart_message := new Message(
	head: new Head(),
	body: Body.multipart(
		[
			new Message(
				head: new Head( fields: send_part1_headers ),
				body: Body.bytes(to_binary("One")),
			),
			new Message(
				head: new Head( fields: send_part2_headers ),
				body: Body.bytes(to_binary("Two")),
			),
		],
		"send-boundary",
	),
);
multipart_message.add_header( "From", "sender@example.test" );
multipart_message.add_header( "To", "reader@example.test" );
multipart_message.add_header(
	"Content-Type",
	"multipart/mixed; boundary=\"send-boundary\"",
);
let multipart_mailer := new FakeMailer();
multipart_message.send(multipart_mailer);
is(
	to_string( multipart_mailer.call(){body} ),
	"--send-boundary\r\n"
		_ "Content-Type: text/plain\r\n"
		_ "\r\n"
		_ "One\r\n"
		_ "--send-boundary\r\n"
		_ "Content-Type: text/plain\r\n"
		_ "\r\n"
		_ "Two\r\n"
		_ "--send-boundary--\r\n",
	"Message.send serializes multipart body with explicit boundary",
);
is(
	multipart_mailer.call(){body},
	( new Serializer() ).serialize_body(multipart_message),
	"Message.send multipart body exactly matches Serializer.serialize_body",
);
is(
	send_header_pairs( multipart_mailer.call() ),
	[
		"From=sender@example.test",
		"To=reader@example.test",
		"Content-Type=multipart/mixed; boundary=\"send-boundary\"",
	],
	"Message.send preserves non-Bcc sending headers",
);

let missing_send_boundary := new Message(
	head: new Head(),
	body: Body.multipart( [ part1 ], "" ),
);
missing_send_boundary.add_header( "From", "sender@example.test" );
missing_send_boundary.add_header( "To", "reader@example.test" );
missing_send_boundary.add_header(
	"Content-Type",
	"multipart/mixed; boundary=\"header-only\"",
);
let missing_boundary_mailer := new FakeMailer();
like(
	exception( function () {
		missing_send_boundary.send(missing_boundary_mailer);
	} ).to_String(),
	/mail\.serialize: multipart body missing boundary/,
	"Message.send does not generate a mismatched multipart boundary",
);
is(
	missing_boundary_mailer.call_count(),
	0,
	"Message.send does not call mailer after boundary serialization failure",
);

let parsed_lf := ( new Parser() ).parse( to_binary(
	"Subject: Hello\n"
	_ "X-Dup: one\n"
	_ "X-Dup: two\n"
	_ "X-Fold: first\n"
	_ "\tsecond\n"
	_ "\n"
	_ "Line 1\nLine 2\n"
) );
is( parsed_lf.subject(), "Hello", "Parser reads LF Subject" );
is(
	parsed_lf.head().raw_all("X-Dup"),
	[ "one", "two" ],
	"Parser preserves duplicate headers",
);
is(
	parsed_lf.head().raw("X-Fold"),
	"first second",
	"Parser unfolds folded headers",
);
is(
	to_string( parsed_lf.body().bytes() ),
	"Line 1\nLine 2\n",
	"Parser preserves LF leaf body bytes",
);

let parsed_crlf := ( new Parser() ).parse( to_binary(
	"Subject: CRLF\r\n"
	_ "\r\n"
	_ "First\r\n"
	_ "\r\n"
	_ "Second\r\n"
) );
is(
	to_string( parsed_crlf.body().bytes() ),
	"First\r\n\r\nSecond\r\n",
	"Parser splits CRLF message on first blank and keeps body blanks",
);

let parsed_b64 := ( new Parser( decode_transfer: true ) ).parse( to_binary(
	"Content-Transfer-Encoding: base64\n"
	_ "\n"
	_ "SGVsbG8="
) );
is(
	to_string( parsed_b64.body().bytes() ),
	"SGVsbG8=",
	"Parser decode_transfer does not decode stored bytes",
);
is(
	to_string( parsed_b64.body().decoded() ),
	"Hello",
	"Parser leaves Body.decoded to decode transfer encoding",
);

let parsed_unknown_cte := ( new Parser() ).parse( to_binary(
	"Content-Transfer-Encoding: x-token\n"
	_ "\n"
	_ "opaque"
) );
is(
	parsed_unknown_cte.body().transfer_encoding(),
	"x-token",
	"Parser preserves unknown transfer encoding",
);
is(
	to_string( parsed_unknown_cte.body().bytes() ),
	"opaque",
	"Parser keeps unknown transfer body raw",
);

let parsed_multi := ( new Parser() ).parse( to_binary(
	"Content-Type: multipart/mixed; boundary=\"mail-b\"\n"
	_ "\n"
	_ "--mail-b\n"
	_ "Content-Type: text/plain\n"
	_ "\n"
	_ "Part one\n"
	_ "--mail-b\n"
	_ "Content-Type: text/plain\n"
	_ "\n"
	_ "Part two\n"
	_ "--mail-b--\n"
) );
ok( parsed_multi.body().is_multipart(), "Parser creates multipart body" );
is( parsed_multi.body().count(), 2, "Parser preserves multipart order" );
is(
	to_string( parsed_multi.body().part(0).body().bytes() ),
	"Part one",
	"Parser reads first multipart part body",
);
is(
	to_string( parsed_multi.body().part(1).body().bytes() ),
	"Part two",
	"Parser reads second multipart part body",
);
is(
	parsed_multi.body().part(0).container(),
	parsed_multi.body(),
	"Parser multipart part has container",
);
is(
	parsed_multi.body().part(0).toplevel(),
	parsed_multi,
	"Parser multipart part toplevel is parent message",
);

let preamble_parser := new Parser();
let parsed_preamble := preamble_parser.parse( to_binary(
	"Content-Type: multipart/mixed; boundary=preamble-b\n"
	_ "\n"
	_ "ignored preamble\n"
	_ "--preamble-b\n"
	_ "\n"
	_ "Part\n"
	_ "--preamble-b--\n"
	_ "ignored epilogue"
) );
ok(
	parsed_preamble.body().is_multipart(),
	"Parser still parses multipart with preamble and epilogue",
);
like(
	preamble_parser.warnings()[0],
	/preamble ignored/,
	"Parser warns when multipart preamble is ignored",
);
like(
	preamble_parser.warnings()[1],
	/epilogue ignored/,
	"Parser warns when multipart epilogue is ignored",
);

let parsed_nested := ( new Parser() ).parse( to_binary(
	"Content-Type: message/rfc822\n"
	_ "\n"
	_ "Subject: Inner\n"
	_ "\n"
	_ "Nested body"
) );
ok( parsed_nested.body().is_nested(), "Parser creates nested message body" );
is(
	parsed_nested.body().nested().subject(),
	"Inner",
	"Parser reads nested message headers",
);
is(
	to_string( parsed_nested.body().nested().body().bytes() ),
	"Nested body",
	"Parser reads nested message body",
);
is(
	parsed_nested.body().nested().container(),
	parsed_nested.body(),
	"Parser nested message has container",
);
is(
	parsed_nested.body().nested().toplevel(),
	parsed_nested,
	"Parser nested toplevel is wrapper message",
);

let encoded_nested_parser := new Parser();
let encoded_nested := encoded_nested_parser.parse( to_binary(
	"Content-Type: message/rfc822\n"
	_ "Content-Transfer-Encoding: base64\n"
	_ "\n"
	_ "U3ViamVjdDogSW5uZXIKCkJvZHk="
) );
ok(
	not encoded_nested.body().is_nested(),
	"Parser keeps encoded message/rfc822 as a leaf body",
);
is(
	encoded_nested.body().transfer_encoding(),
	"base64",
	"Parser preserves encoded message/rfc822 transfer encoding",
);
like(
	encoded_nested_parser.warnings()[0],
	/encoded message\/rfc822/,
	"Parser warns when encoded message/rfc822 is kept as leaf body",
);
like(
	exception( function () {
		( new Parser( strict: true ) ).parse( to_binary(
			"Content-Type: message/rfc822\n"
			_ "Content-Transfer-Encoding: base64\n"
			_ "\n"
			_ "U3ViamVjdDogSW5uZXIKCkJvZHk="
		) );
	} ).to_String(),
	/encoded message\/rfc822/,
	"Parser strict encoded message/rfc822 throws",
);

let loose_parser := new Parser();
let loose_missing_boundary := loose_parser.parse( to_binary(
	"Content-Type: multipart/mixed\n"
	_ "\n"
	_ "--missing\n"
	_ "\n"
	_ "Body"
) );
ok(
	not loose_missing_boundary.body().is_multipart(),
	"Parser non-strict missing boundary falls back to leaf body",
);
like(
	loose_parser.warnings()[0],
	/boundary/,
	"Parser non-strict missing boundary records warning",
);

let loose_malformed_header := new Parser();
let loose_header_msg := loose_malformed_header.parse( to_binary(
	"Bad header\n\nBody"
) );
ok(
	not loose_header_msg.body().is_multipart(),
	"Parser non-strict malformed header falls back to leaf body",
);
like(
	loose_malformed_header.warnings()[0],
	/malformed header/,
	"Parser non-strict malformed header records warning",
);

like(
	exception( function () {
		( new Parser( strict: true ) ).parse( to_binary(
			"Bad header\n\nBody"
		) );
	} ).to_String(),
	/mail\.parse: malformed header/,
	"Parser strict malformed header throws",
);
like(
	exception( function () {
		( new Parser( strict: true ) ).parse( to_binary(
			"Content-Type: multipart/mixed\n"
			_ "\n"
			_ "Body"
		) );
	} ).to_String(),
	/missing boundary/,
	"Parser strict malformed multipart boundary throws",
);
like(
	exception( function () {
		( new Parser( max_header_bytes: 10 ) ).parse( to_binary(
			"Subject: too long\n\nBody"
		) );
	} ).to_String(),
	/max_header_bytes/,
	"Parser enforces max_header_bytes",
);
like(
	exception( function () {
		( new Parser( max_depth: 0 ) ).parse( to_binary(
			"Content-Type: multipart/mixed; boundary=z\n"
			_ "\n"
			_ "--z\n"
			_ "\n"
			_ "Part\n"
			_ "--z--\n"
		) );
	} ).to_String(),
	/max_depth/,
	"Parser enforces max_depth",
);

let s10_headers := new PairList();
s10_headers.add( "From", "sender@example.test" );
s10_headers.add( "Subject", "Hello" );
let s10_message := new Message(
	head: new Head( fields: s10_headers ),
	body: Body.bytes( to_binary("Body\n") ),
);
let s10_serializer := new Serializer();
is(
	to_string( s10_serializer.serialize(s10_message) ),
	"From: sender@example.test\r\n"
		_ "Subject: Hello\r\n"
		_ "\r\n"
		_ "Body\n",
	"Serializer emits simple full message with default CRLF",
);
is(
	s10_serializer.serialize_body(s10_message),
	to_binary("Body\n"),
	"Serializer emits leaf body only",
);
is(
	to_string( ( new Serializer( newline: "\n" ) ).serialize(s10_message) ),
	"From: sender@example.test\n"
		_ "Subject: Hello\n"
		_ "\n"
		_ "Body\n",
	"Serializer supports LF newline option",
);

let s10_raw_headers := new PairList();
s10_raw_headers.add( "X-Dup", "one" );
s10_raw_headers.add( "X-Dup", "two" );
s10_raw_headers.add(
	"Subject",
	"=?UTF-8?Q?Hello?= \t =?UTF-8?Q?_World?=",
);
let s10_raw_message := new Message(
	head: new Head( fields: s10_raw_headers ),
	body: Body.bytes( to_binary("") ),
);
is(
	to_string( s10_serializer.serialize(s10_raw_message) ),
	"X-Dup: one\r\n"
		_ "X-Dup: two\r\n"
		_ "Subject: =?UTF-8?Q?Hello?= \t =?UTF-8?Q?_World?=\r\n"
		_ "\r\n",
	"Serializer preserves duplicate headers and raw encoded words",
);

let s10_fold_headers := new PairList();
s10_fold_headers.add( "Subject", "alpha beta gamma delta" );
let s10_fold_message := new Message(
	head: new Head( fields: s10_fold_headers ),
	body: Body.bytes( to_binary("") ),
);
is(
	to_string(
		( new Serializer( line_length: 24 ) ).serialize(s10_fold_message),
	),
	"Subject: alpha beta\r\n"
		_ "\tgamma delta\r\n"
		_ "\r\n",
	"Serializer folds long header values at whitespace",
);
is(
	to_string(
		( new Serializer(
			line_length: 24,
			fold_headers: false,
		) ).serialize(s10_fold_message),
	),
	"Subject: alpha beta gamma delta\r\n\r\n",
	"Serializer fold_headers false leaves long headers unfolded",
);

let s10_long_word_headers := new PairList();
s10_long_word_headers.add( "Subject", "supercalifragilistic" );
let s10_long_word_message := new Message(
	head: new Head( fields: s10_long_word_headers ),
	body: Body.bytes( to_binary("") ),
);
is(
	to_string(
		( new Serializer( line_length: 12 ) ).serialize(
			s10_long_word_message,
		),
	),
	"Subject: supercalifragilistic\r\n\r\n",
	"Serializer does not split words when folding has no safe whitespace",
);

let s10_qp_message := new Message(
	head: new Head(),
	body: Body.bytes(
		to_binary("Hello=20world=0D=0A"),
		transfer_encoding: "quoted-printable",
	),
);
is(
	s10_serializer.serialize_body(s10_qp_message),
	to_binary("Hello=20world=0D=0A"),
	"Serializer leaves quoted-printable leaf bytes unchanged",
);
let s10_b64_message := new Message(
	head: new Head(),
	body: Body.bytes(
		to_binary("SGVsbG8="),
		transfer_encoding: "base64",
	),
);
is(
	s10_serializer.serialize_body(s10_b64_message),
	to_binary("SGVsbG8="),
	"Serializer leaves base64 leaf bytes unchanged",
);

let s10_part1_headers := new PairList();
s10_part1_headers.add( "Content-Type", "text/plain" );
let s10_part1 := new Message(
	head: new Head( fields: s10_part1_headers ),
	body: Body.bytes(to_binary("Part one")),
);
let s10_part2_headers := new PairList();
s10_part2_headers.add( "Content-Type", "text/plain" );
let s10_part2 := new Message(
	head: new Head( fields: s10_part2_headers ),
	body: Body.bytes(to_binary("Part two")),
);
let s10_multi_headers := new PairList();
s10_multi_headers.add(
	"Content-Type",
	"multipart/mixed; boundary=\"s10-b\"",
);
let s10_multi_message := new Message(
	head: new Head( fields: s10_multi_headers ),
	body: Body.multipart( [ s10_part1, s10_part2 ], "s10-b" ),
);
let s10_multi_body := "--s10-b\r\n"
	_ "Content-Type: text/plain\r\n"
	_ "\r\n"
	_ "Part one\r\n"
	_ "--s10-b\r\n"
	_ "Content-Type: text/plain\r\n"
	_ "\r\n"
	_ "Part two\r\n"
	_ "--s10-b--\r\n";
is(
	to_string( s10_serializer.serialize_body(s10_multi_message) ),
	s10_multi_body,
	"Serializer emits exact multipart body framing",
);
let s10_multi_roundtrip := ( new Parser() ).parse(
	s10_serializer.serialize(s10_multi_message),
);
ok(
	s10_multi_roundtrip.body().is_multipart(),
	"Serializer multipart output parses as multipart",
);
is(
	s10_multi_roundtrip.body().count(),
	2,
	"Serializer multipart round-trip preserves part count",
);
is(
	to_string( s10_multi_roundtrip.body().part(1).body().bytes() ),
	"Part two",
	"Serializer multipart round-trip preserves part body",
);

let s10_inner_headers := new PairList();
s10_inner_headers.add( "Subject", "Inner" );
let s10_inner := new Message(
	head: new Head( fields: s10_inner_headers ),
	body: Body.bytes(to_binary("Nested body")),
);
let s10_nested_headers := new PairList();
s10_nested_headers.add( "Content-Type", "message/rfc822" );
let s10_nested_message := new Message(
	head: new Head( fields: s10_nested_headers ),
	body: Body.nested(s10_inner),
);
is(
	to_string( s10_serializer.serialize_body(s10_nested_message) ),
	"Subject: Inner\r\n\r\nNested body",
	"Serializer emits nested message/rfc822 body as complete message",
);
let s10_nested_roundtrip := ( new Parser() ).parse(
	s10_serializer.serialize(s10_nested_message),
);
ok(
	s10_nested_roundtrip.body().is_nested(),
	"Serializer nested output parses as message/rfc822",
);
is(
	s10_nested_roundtrip.body().nested().subject(),
	"Inner",
	"Serializer nested round-trip preserves nested header",
);
is(
	to_string( s10_nested_roundtrip.body().nested().body().bytes() ),
	"Nested body",
	"Serializer nested round-trip preserves nested body",
);

let s10_parsed_source := ( new Parser() ).parse( to_binary(
	"Subject: Parsed\n"
	_ "X-Dup: one\n"
	_ "X-Dup: two\n"
	_ "X-Fold: alpha\n"
	_ "\tbeta\n"
	_ "\n"
	_ "Payload\n"
) );
let s10_reparsed := ( new Parser() ).parse(
	s10_serializer.serialize(s10_parsed_source),
);
is(
	s10_reparsed.head().raw_all("X-Dup"),
	[ "one", "two" ],
	"Parser-serializer-parser preserves duplicate headers",
);
is(
	s10_reparsed.head().raw("X-Fold"),
	"alpha beta",
	"Parser-serializer-parser preserves unfolded header semantics",
);
is(
	to_string( s10_reparsed.body().bytes() ),
	"Payload\n",
	"Parser-serializer-parser preserves leaf body bytes",
);

let s10_constructed_headers := new PairList();
s10_constructed_headers.add( "X-Dup", "left" );
s10_constructed_headers.add( "X-Dup", "right" );
let s10_constructed := new Message(
	head: new Head( fields: s10_constructed_headers ),
	body: Body.bytes(to_binary("Built")),
);
let s10_constructed_parsed := ( new Parser() ).parse(
	s10_serializer.serialize(s10_constructed),
);
is(
	s10_constructed_parsed.head().raw_all("X-Dup"),
	[ "left", "right" ],
	"Serializer-parser preserves duplicate constructed headers",
);
is(
	to_string( s10_constructed_parsed.body().bytes() ),
	"Built",
	"Serializer-parser preserves constructed body",
);

let s10_missing_boundary := new Message(
	head: new Head(),
	body: Body.multipart( [ s10_part1 ], "" ),
);
like(
	exception( function () {
		s10_serializer.serialize_body(s10_missing_boundary);
	} ).to_String(),
	/mail\.serialize: multipart body missing boundary/,
	"Serializer missing multipart boundary throws by default",
);
let s10_generated_serializer := new Serializer( generate_boundary: true );
is(
	to_string( s10_generated_serializer.serialize_body(s10_missing_boundary) ),
	"--zuzu-boundary-1\r\n"
		_ "Content-Type: text/plain\r\n"
		_ "\r\n"
		_ "Part one\r\n"
		_ "--zuzu-boundary-1--\r\n",
	"Serializer can generate deterministic missing boundary",
);
is(
	to_string( s10_generated_serializer.serialize_body(s10_missing_boundary) ),
	"--zuzu-boundary-2\r\n"
		_ "Content-Type: text/plain\r\n"
		_ "\r\n"
		_ "Part one\r\n"
		_ "--zuzu-boundary-2--\r\n",
	"Serializer generated boundary counter is deterministic",
);
let s10_body_only_generated := ( new Serializer(
	generate_boundary: true,
) ).serialize_body(s10_missing_boundary);
let s10_body_only_parsed := ( new Parser() ).parse(s10_body_only_generated);
ok(
	not s10_body_only_parsed.body().is_multipart(),
	"Serializer generated body-only boundary needs matching external header",
);

let s10_generated_full_headers := new PairList();
s10_generated_full_headers.add( "X-Keep", "before" );
s10_generated_full_headers.add( "Content-Type", "multipart/mixed" );
s10_generated_full_headers.add( "X-Keep", "after" );
let s10_generated_full_message := new Message(
	head: new Head( fields: s10_generated_full_headers ),
	body: Body.multipart( [ s10_part1, s10_part2 ], "" ),
);
let s10_generated_full_serializer := new Serializer( generate_boundary: true );
let s10_generated_full_bytes := s10_generated_full_serializer.serialize(
	s10_generated_full_message,
);
is(
	to_string(s10_generated_full_bytes),
	"X-Keep: before\r\n"
		_ "Content-Type: multipart/mixed; boundary=\"zuzu-boundary-1\"\r\n"
		_ "X-Keep: after\r\n"
		_ "\r\n"
		_ "--zuzu-boundary-1\r\n"
		_ "Content-Type: text/plain\r\n"
		_ "\r\n"
		_ "Part one\r\n"
		_ "--zuzu-boundary-1\r\n"
		_ "Content-Type: text/plain\r\n"
		_ "\r\n"
		_ "Part two\r\n"
		_ "--zuzu-boundary-1--\r\n",
	"Serializer full message writes generated boundary into Content-Type",
);
is(
	s10_generated_full_message.head().raw("Content-Type"),
	"multipart/mixed",
	"Serializer generated Content-Type adjustment does not mutate message",
);
let s10_generated_full_roundtrip := ( new Parser() ).parse(
	s10_generated_full_bytes,
);
is(
	s10_generated_full_roundtrip.head().raw_all("X-Keep"),
	[ "before", "after" ],
	"Serializer generated Content-Type keeps surrounding duplicate headers",
);
ok(
	s10_generated_full_roundtrip.body().is_multipart(),
	"Serializer generated-boundary full message parses as multipart",
);
is(
	s10_generated_full_roundtrip.body().count(),
	2,
	"Serializer generated-boundary full round-trip preserves parts",
);

like(
	exception( function () {
		new Serializer( newline: 10 );
	} ).to_String(),
	/mail\.serialize: newline expects String/,
	"Serializer validates newline option type",
);
like(
	exception( function () {
		new Serializer( newline: "\r" );
	} ).to_String(),
	/mail\.serialize: newline must be CRLF or LF/,
	"Serializer validates newline option value",
);
like(
	exception( function () {
		new Serializer( fold_headers: "yes" );
	} ).to_String(),
	/mail\.serialize: fold_headers expects Boolean/,
	"Serializer validates fold_headers option",
);
like(
	exception( function () {
		new Serializer( line_length: "wide" );
	} ).to_String(),
	/mail\.serialize: line_length expects Number/,
	"Serializer validates line_length option type",
);
like(
	exception( function () {
		new Serializer( line_length: 0 );
	} ).to_String(),
	/mail\.serialize: line_length must be positive/,
	"Serializer validates line_length option value",
);
like(
	exception( function () {
		new Serializer( generate_boundary: "yes" );
	} ).to_String(),
	/mail\.serialize: generate_boundary expects Boolean/,
	"Serializer validates generate_boundary option",
);
like(
	exception( function () {
		s10_serializer.serialize( s10_message, newline: "\n" );
	} ).to_String(),
	/mail\.serialize: unsupported method option/,
	"Serializer rejects method options in favour of constructor named args",
);

let rfc_datetime := parse_datetime("Fri, 21 Nov 1997 09:55:06 -0600");
ok( rfc_datetime instanceof Time, "parse_datetime returns Time" );
is( rfc_datetime.epoch(), 880127706, "RFC numeric-offset example parses" );
is(
	parse_datetime("Fri, 21 Nov 97 09:55:06 -0600").epoch(),
	880127706,
	"two-digit year maps 97 to 1997",
);
is(
	parse_datetime("Tue, 15 Nov 1994 08:12:31 GMT").epoch(),
	784887151,
	"GMT date parses",
);
is(
	parse_datetime("08 May 26 12:00 UT").epoch(),
	1778241600,
	"UT date without weekday or seconds parses",
);
is(
	parse_datetime("Fri, 08 May 2026 12:00:00 UTC").epoch(),
	1778241600,
	"UTC zone parses",
);
is(
	parse_datetime("Fri, 08 May 2026 12:00:00 -0000").epoch(),
	1778241600,
	"-0000 numeric zone is treated as zero offset",
);
is(
	parse_datetime("Fri, 08 May 2026 14:30:00 +0230").epoch(),
	1778241600,
	"positive numeric offset parses",
);
is(
	parse_datetime("Fri, 08 May 2026 07:30:00 -0200").epoch(),
	1778232600,
	"negative numeric offset parses",
);
is(
	parse_datetime("Fri, 08 May 2026 07:00:00 EST").epoch(),
	1778241600,
	"US legacy zone parses",
);
is(
	parse_datetime("Fri, 08 May 2026 13:00:00 A").epoch(),
	1778241600,
	"military A zone parses",
);
is(
	parse_datetime("Fri, 08 May 2026 00:00:00 Y").epoch(),
	1778241600,
	"military Y zone parses as negative offset",
);
is(
	parse_datetime("Tue, 08 Sept. 2026 12:00:00 Z").epoch(),
	1788868800,
	"month spelling variant and Z zone parse",
);
like(
	exception( function () {
		parse_datetime("Sat, 29 Feb 2025 12:00:00 +0000");
	} ).to_String(),
	/mail\.invalid_datetime: invalid date/,
	"parse_datetime rejects invalid date",
);
like(
	exception( function () {
		parse_datetime("Fri, 08 May 2026 24:00:00 +0000");
	} ).to_String(),
	/mail\.invalid_datetime: invalid time/,
	"parse_datetime rejects invalid time",
);
like(
	exception( function () {
		parse_datetime("Fri, 08 May 2026 12:00:00 J");
	} ).to_String(),
	/mail\.invalid_datetime: invalid time zone/,
	"parse_datetime rejects military J zone",
);
like(
	exception( function () {
		parse_datetime("Fri, 08 May 2026 12:00:00 +2460");
	} ).to_String(),
	/mail\.invalid_datetime: invalid time zone/,
	"parse_datetime rejects invalid numeric zone",
);
like(
	exception( function () {
		parse_datetime("Fri, 08 May 2026\r\n 12:00:00 +0000");
	} ).to_String(),
	/CR or LF/,
	"parse_datetime rejects CRLF",
);

let format_source := new Time(1778241600);
is(
	format_datetime(format_source),
	"Fri, 08 May 2026 12:00:00 +0000",
	"format_datetime defaults to UTC with weekday",
);
is(
	format_datetime( format_source, offset: "+0100" ),
	"Fri, 08 May 2026 13:00:00 +0100",
	"format_datetime supports named offset option",
);
is(
	format_datetime(
		format_source,
		{ offset: "-0330", include_weekday: false },
	),
	"08 May 2026 08:30:00 -0330",
	"format_datetime supports dict options and no weekday",
);
is(
	format_datetime( format_source, { include_weekday: false } ),
	"08 May 2026 12:00:00 +0000",
	"format_datetime can omit weekday while staying UTC",
);
like(
	exception( function () {
		format_datetime( format_source, bogus: true );
	} ).to_String(),
	/mail\.invalid_datetime: unknown format option/,
	"format_datetime rejects unknown options",
);
like(
	exception( function () {
		format_datetime( format_source, utc: true, offset: "+0100" );
	} ).to_String(),
	/mail\.invalid_datetime: format options 'utc' and 'offset' conflict/,
	"format_datetime rejects conflicting utc and offset options",
);
like(
	exception( function () {
		format_datetime( format_source, offset: "UTC" );
	} ).to_String(),
	/mail\.invalid_datetime: format option 'offset'/,
	"format_datetime rejects non-numeric offset option",
);
like(
	exception( function () {
		format_datetime(null);
	} ).to_String(),
	/mail\.invalid_datetime: format_datetime expects Time/,
	"format_datetime rejects non-Time input",
);

let round_trip_text := "Fri, 08 May 2026 13:00:00 +0100";
let round_trip_time := parse_datetime(round_trip_text);
is(
	format_datetime( round_trip_time, offset: "+0100" ),
	round_trip_text,
	"parse-format round-trip preserves requested offset",
);

done_testing();
