1 /++
2 	Create MIME emails with things like HTML, attachments, and send with convenience wrappers around std.net.curl's SMTP function, or read email from an mbox file.
3 
4 	For preparing and sending outgoing email, see [EmailMessage]. For processing incoming email or opening .eml files, mbox files, etc., see [IncomingEmailMessage].
5 
6 	History:
7 		Originally released as open source on August 11, 2012. The last-modified date of its predecessor file was January 2011.
8 
9 		Many of the public string members were overhauled on May 13, 2024. Compatibility methods are provided so your code will hopefully still work, but this also results in some stricter adherence to email encoding rules, so you should retest if you update after then.
10 
11 	Future_Directions:
12 		I might merge `IncomingEmailMessage` and `EmailMessage` some day, it seems silly to have them completely separate like this.
13 +/
14 module arsd.email;
15 
16 import std.net.curl;
17 pragma(lib, "curl");
18 
19 import std.base64;
20 import std.string;
21 import std.range;
22 import std.utf;
23 import std.array;
24 import std.algorithm.iteration;
25 
26 import arsd.characterencodings;
27 
28 public import arsd.core : FilePath;
29 
30 //         import std.uuid;
31 // smtpMessageBoundary = randomUUID().toString();
32 
33 // SEE ALSO: std.net.curl.SMTP
34 
35 /++
36 	Credentials for a SMTP relay, as passed to [std.net.curl.SMTP].
37 +/
38 struct RelayInfo {
39 	/++
40 		Should be as a url, such as `smtp://example.com` or `smtps://example.com`. You normally want smtp:// - even if you want TLS encryption, smtp uses STARTTLS so it gets that. smtps will only work if the server supports tls from the start, which is not always the case.
41 	+/
42 	string server;
43 	string username; ///
44 	string password; ///
45 }
46 
47 /++
48 	Representation of an email attachment.
49 +/
50 struct MimeAttachment {
51 	string type; /// e.g. `text/plain`
52 	string filename; ///
53 	const(ubyte)[] content; ///
54 	string id; ///
55 }
56 
57 ///
58 enum ToType {
59 	to,
60 	cc,
61 	bcc
62 }
63 
64 /++
65 	Structured representation of email users, including the name and email address as separate components.
66 
67 	`EmailRecipient` represents a single user, and `RecipientList` represents multiple users. A "recipient" may also be a from or reply to address.
68 
69 
70 	`RecipientList` is a wrapper over `EmailRecipient[]` that provides overloads that take string arguments, for compatibility for users of previous versions of the `arsd.email` api. It should generally work as you expect if you just pretend it is a normal array though (and if it doesn't, you can get the internal array via the `recipients` member.)
71 
72 	History:
73 		Added May 13, 2024 (dub v12.0) to replace the old plain, public strings and arrays of strings.
74 +/
75 struct EmailRecipient {
76 	/++
77 		The email user's name. It should not have quotes or any other encoding.
78 
79 		For example, `Adam D. Ruppe`.
80 	+/
81 	string name;
82 	/++
83 		The email address. It should not have brackets or any other encoding.
84 
85 		For example, `destructionator@gmail.com`.
86 	+/
87 	string address;
88 
89 	/++
90 		Returns a string representing this email address, in a format suitable for inclusion in a message about to be saved or transmitted.
91 
92 		In many cases, this is easy to read for people too, but not in all cases.
93 	+/
94 	string toProtocolString(string linesep = "\r\n") {
95 		if(name.length)
96 			return "\"" ~ encodeEmailHeaderContentForTransmit(name, linesep) ~ "\" <" ~ address ~ ">";
97 		return address;
98 	}
99 
100 	/++
101 		Returns a string representing this email address, in a format suitable for being read by people. This is not necessarily reversible.
102 	+/
103 	string toReadableString() {
104 		if(name.length)
105 			return "\"" ~ name ~ "\" <" ~ address ~ ">";
106 		return address;
107 	}
108 
109 	/++
110 		Construct an `EmailRecipient` either from a name and address (preferred!) or from an encoded string as found in an email header.
111 
112 		Examples:
113 
114 		`EmailRecipient("Adam D. Ruppe", "destructionator@gmail.com")` or `EmailRecipient(`"Adam D. Ruppe" <destructionator@gmail.com>`);
115 	+/
116 	this(string name, string address) {
117 		this.name = name;
118 		this.address = address;
119 	}
120 
121 	/// ditto
122 	this(string str) {
123 		this = str;
124 	}
125 
126 	/++
127 		Provided for compatibility for users of old versions of `arsd.email` - does implicit conversion from `EmailRecipient` to a plain string (in protocol format), as was present in previous versions of the api.
128 	+/
129 	alias toProtocolString this;
130 
131 	/// ditto
132 	void opAssign(string str) {
133 		auto idx = str.indexOf("<");
134 		if(idx == -1) {
135 			name = null;
136 			address = str;
137 		} else {
138 			name = decodeEncodedWord(unquote(str[0 .. idx].strip));
139 			address = str[idx + 1 .. $ - 1];
140 		}
141 
142 	}
143 }
144 
145 /// ditto
146 struct RecipientList {
147 	EmailRecipient[] recipients;
148 
149 	void opAssign(string[] strings) {
150 		recipients = null;
151 		foreach(s; strings)
152 			recipients ~= EmailRecipient(s);
153 	}
154 	void opAssign(EmailRecipient[] recpts) {
155 		this.recipients = recpts;
156 	}
157 
158 	void opOpAssign(string op : "~")(EmailRecipient r) {
159 		recipients ~= r;
160 	}
161 	void opOpAssign(string op : "~")(string s) {
162 		recipients ~= EmailRecipient(s);
163 	}
164 	int opApply(int delegate(size_t idx, EmailRecipient rcp) dg) {
165 		foreach(idx, item; recipients)
166 			if(auto result = dg(idx, item))
167 				return result;
168 		return 0;
169 	}
170 	int opApply(int delegate(EmailRecipient rcp) dg) {
171 		foreach(item; recipients)
172 			if(auto result = dg(item))
173 				return result;
174 		return 0;
175 	}
176 
177 	size_t length() {
178 		return recipients.length;
179 	}
180 
181 	string toProtocolString(string linesep = "\r\n") {
182 		string ret;
183 		foreach(idx, item; recipients) {
184 			if(idx)
185 				ret ~= ", ";
186 			ret ~= item.toProtocolString(linesep);
187 		}
188 		return ret;
189 	}
190 
191 	EmailRecipient front() { return recipients[0]; }
192 	void popFront() { recipients = recipients[1 .. $]; }
193 	bool empty() { return recipients.length == 0; }
194 	RecipientList save() { return this; }
195 }
196 
197 private string unquote(string s) {
198 	if(s.length == 0)
199 		return s;
200 	if(s[0] != '"')
201 		return s;
202 	s = s[1 .. $-1]; // strip the quotes
203 	// FIXME: possible to have \" escapes in there too
204 	return s;
205 }
206 
207 private struct CaseInsensitiveString {
208 	string actual;
209 
210 	size_t toHash() const {
211 		string l = actual.toLower;
212 		return typeid(string).getHash(&l);
213 	}
214 	bool opEquals(ref const typeof(this) s) const {
215 		return icmp(s.actual, this.actual) == 0;
216 	}
217 	bool opEquals(string s) const {
218 		return icmp(s, this.actual) == 0;
219 	}
220 
221 	alias actual this;
222 }
223 
224 /++
225 	A type that acts similarly to a `string[string]` to hold email headers in a case-insensitive way.
226 +/
227 struct HeadersHash {
228 	string[CaseInsensitiveString] hash;
229 
230 	string opIndex(string key) const {
231 		return hash[CaseInsensitiveString(key)];
232 	}
233 	string opIndexAssign(string value, string key) {
234 		return hash[CaseInsensitiveString(key)] = value;
235 	}
236 	inout(string)* opBinaryRight(string op : "in")(string key) inout {
237 		return CaseInsensitiveString(key) in hash;
238 	}
239 	alias hash this;
240 }
241 
242 unittest {
243 	HeadersHash h;
244 	h["From"] = "test";
245 	h["from"] = "other";
246 	foreach(k, v; h) {
247 		assert(k == "From");
248 		assert(v == "other");
249 	}
250 
251 	assert("from" in h);
252 	assert("From" in h);
253 	assert(h["from"] == "other");
254 
255 	const(HeadersHash) ch = HeadersHash([CaseInsensitiveString("From") : "test"]);
256 	assert(ch["from"] == "test");
257 	assert("From" in ch);
258 }
259 
260 /++
261 	For OUTGOING email
262 
263 
264 	To use:
265 
266 	---
267 	auto message = new EmailMessage();
268 	message.to ~= "someuser@example.com";
269 	message.from = "youremail@example.com";
270 	message.subject = "My Subject";
271 	message.setTextBody("hi there");
272 	//message.toString(); // get string to send externally
273 	message.send(); // send via some relay
274 	// may also set replyTo, etc
275 	---
276 
277 	History:
278 		This class got an API overhaul on May 13, 2024. Some undocumented members were removed, and some public members got changed (albeit in a mostly compatible way).
279 +/
280 class EmailMessage {
281 	/++
282 		Adds a custom header to the message. The header name should not include a colon and must not duplicate a header set elsewhere in the class; for example, do not use this to set `To`, and instead use the [to] field.
283 
284 		Setting the same header multiple times will overwrite the old value. It will not set duplicate headers and does not retain the specific order of which you added headers.
285 
286 		History:
287 			Prior to May 13, 2024, this assumed the value was previously encoded. This worked most the time but also left open the possibility of incorrectly encoded values, including the possibility of injecting inappropriate headers.
288 
289 			Since May 13, 2024, it now encodes the header content internally. You should NOT pass pre-encoded values to this function anymore.
290 
291 			It also would previously allow you to set repeated headers like `Subject` or `To`. These now throw exceptions.
292 
293 			It previously also allowed duplicate headers. Adding the same thing twice will now silently overwrite the old value instead.
294 	+/
295 	void setHeader(string name, string value, string file = __FILE__, size_t line = __LINE__) {
296 		import arsd.core;
297 		if(name.length == 0)
298 			throw new InvalidArgumentsException("name", "name cannot be an empty string", LimitedVariant(name), "setHeader", file, line);
299 		if(name.indexOf(":") != -1)
300 			throw new InvalidArgumentsException("name", "do not put a colon in the header name", LimitedVariant(name), "setHeader", file, line);
301 		if(!headerSettableThroughAA(name))
302 			throw new InvalidArgumentsException("name", "use named methods/properties for this header instead of setHeader", LimitedVariant(name), "setHeader", file, line);
303 
304 		headers_[name] = value;
305 	}
306 
307 	protected bool headerSettableThroughAA(string name) {
308 		switch(name.toLower) {
309 			case "to", "cc", "bcc":
310 			case "from", "reply-to", "in-reply-to":
311 			case "subject":
312 			case "content-type", "content-transfer-encoding", "mime-version":
313 			case "received", "return-path": // set by the MTA
314 				return false;
315 			default:
316 				return true;
317 		}
318 	}
319 
320 	/++
321 		Recipients of the message. You can use operator `~=` to add people to this list, or you can also use [addRecipient] to achieve the same result.
322 
323 		---
324 		message.to ~= EmailRecipient("Adam D. Ruppe", "destructionator@gmail.com");
325 		message.cc ~= EmailRecipient("John Doe", "john.doe@example.com");
326 		// or, same result as the above two lines:
327 		message.addRecipient("Adam D. Ruppe", "destructionator@gmail.com");
328 		message.addRecipient("John Doe", "john.doe@example.com", ToType.cc);
329 
330 		// or, the old style code that still works, but is not recommended, since
331 		// it is harder to encode properly for anything except pure ascii names:
332 		message.to ~= `"Adam D. Ruppe" <destructionator@gmail.com>`
333 		---
334 
335 		History:
336 			On May 13, 2024, the types of these changed. Before, they were `public string[]`; plain string arrays. This put the burden of proper encoding on the user, increasing the probability of bugs. Now, they are [RecipientList]s - internally, an array of `EmailRecipient` objects, but with a wrapper to provide compatibility with the old string-based api.
337 	+/
338 	RecipientList to;
339 	/// ditto
340 	RecipientList cc;
341 	/// ditto
342 	RecipientList bcc;
343 
344 	/++
345 		Represents the `From:` and `Reply-To:` header values in the email.
346 
347 
348 		Note that the `from` member is the "From:" header, which is not necessarily the same as the "envelope from". The "envelope from" is set by the email server usually based on your login credentials. The email server may or may not require these to match.
349 
350 		History:
351 			On May 13, 2024, the types of these changed from plain `string` to [EmailRecipient], to try to get the encoding easier to use correctly. `EmailRecipient` offers overloads for string parameters for compatibility, so your code should not need changing, however if you use non-ascii characters in your names, you should retest to ensure it still works correctly.
352 	+/
353 	EmailRecipient from;
354 	/// ditto
355 	EmailRecipient replyTo;
356 	/// The `Subject:` header value in the email.
357 	string subject;
358 	/// The `In-Reply-to:` header value. This should be set to the same value as the `Message-ID` header from the message you're replying to.
359 	string inReplyTo;
360 
361 	private string textBody_;
362 	private string htmlBody_;
363 
364 	private HeadersHash headers_;
365 
366 	/++
367 		Gets and sets the current text body.
368 
369 		History:
370 			Prior to May 13, 2024, this was a simple `public string` member, but still had a [setTextBody] method too. It now is a public property that works through that method.
371 	+/
372 	string textBody() {
373 		return textBody_;
374 	}
375 	/// ditto
376 	void textBody(string text) {
377 		setTextBody(text);
378 	}
379 	/++
380 		Gets the current html body, if any.
381 
382 		There is no setter for this property, use [setHtmlBody] instead.
383 
384 		History:
385 			Prior to May 13, 2024, this was a simple `public string` member. This let you easily get the `EmailMessage` object into an inconsistent state.
386 	+/
387 	string htmlBody() {
388 		return htmlBody_;
389 	}
390 
391 	/++
392 		If you use the send method with an SMTP server, you don't want to change this.
393 		While RFC 2045 mandates CRLF as a lineseperator, there are some edge-cases where this won't work.
394 		When passing the E-Mail string to a unix program which handles communication with the SMTP server, some (i.e. qmail)
395 		expect the system lineseperator (LF) instead.
396 		Notably, the google mail REST API will choke on CRLF lineseps and produce strange emails (as of 2024).
397 
398 		Do not change this after calling other methods, since it might break presaved values.
399 	+/
400 	string linesep = "\r\n";
401 
402 	/++
403 		History:
404 			Added May 13, 2024
405 	+/
406 	this(string linesep = "\r\n") {
407 		this.linesep = linesep;
408 	}
409 
410 	private bool isMime = false;
411 	private bool isHtml = false;
412 
413 	///
414 	void addRecipient(string name, string email, ToType how = ToType.to) {
415 		addRecipient(`"`~name~`" <`~email~`>`, how);
416 	}
417 
418 	///
419 	void addRecipient(string who, ToType how = ToType.to) {
420 		final switch(how) {
421 			case ToType.to:
422 				to ~= who;
423 			break;
424 			case ToType.cc:
425 				cc ~= who;
426 			break;
427 			case ToType.bcc:
428 				bcc ~= who;
429 			break;
430 		}
431 	}
432 
433 	/++
434 		Sets the plain text body of the email. You can also separately call [setHtmlBody] to set a HTML body.
435 	+/
436 	void setTextBody(string text) {
437 		textBody_ = text.strip;
438 	}
439 	/++
440 		Sets the HTML body to the mail, which can support rich text, inline images (see [addInlineImage]), etc.
441 
442 		Automatically sets a text fallback if you haven't already, unless you pass `false` as the `addFallback` template value. Adding the fallback requires [arsd.htmltotext].
443 
444 		History:
445 			The `addFallback` parameter was added on May 13, 2024.
446 	+/
447 	void setHtmlBody(bool addFallback = true)(string html) {
448 		isMime = true;
449 		isHtml = true;
450 		htmlBody_ = html;
451 
452 		static if(addFallback) {
453 			import arsd.htmltotext;
454 			if(textBody_ is null)
455 				textBody_ = htmlToText(html);
456 		}
457 	}
458 
459 	const(MimeAttachment)[] attachments;
460 
461 	/++
462 		The attachmentFileName is what is shown to the user, not the file on your sending computer. It should NOT have a path in it.
463 		If you want a filename from your computer, try [addFileAsAttachment].
464 
465 		The `mimeType` can be excluded if the filename has a common extension supported by the library.
466 
467 		---
468 			message.addAttachment("text/plain", "something.txt", std.file.read("/path/to/local/something.txt"));
469 		---
470 
471 		History:
472 			The overload without `mimeType` was added October 28, 2024.
473 
474 			The parameter `attachmentFileName` was previously called `filename`. This was changed for clarity and consistency with other overloads on October 28, 2024.
475 	+/
476 	void addAttachment(string mimeType, string attachmentFileName, const void[] content, string id = null) {
477 		isMime = true;
478 		attachments ~= MimeAttachment(mimeType, attachmentFileName, cast(const(ubyte)[]) content, id);
479 	}
480 
481 
482 	/// ditto
483 	void addAttachment(string attachmentFileName, const void[] content, string id = null) {
484 		import arsd.core;
485 		addAttachment(FilePath(attachmentFileName).contentTypeFromFileExtension, attachmentFileName, content, id);
486 	}
487 
488 	/++
489 		Reads the local file and attaches it.
490 
491 		If `attachmentFileName` is null, it uses the filename of `localFileName`, without the directory.
492 
493 		If `mimeType` is null, it guesses one based on the local file name's file extension.
494 
495 		If these cannot be determined, it will throw an `InvalidArgumentsException`.
496 
497 		History:
498 			Added October 28, 2024
499 	+/
500 	void addFileAsAttachment(FilePath localFileName, string attachmentFileName = null, string mimeType = null, string id = null) {
501 		if(mimeType is null)
502 			mimeType = localFileName.contentTypeFromFileExtension;
503 		if(attachmentFileName is null)
504 			attachmentFileName = localFileName.filename;
505 
506 		import std.file;
507 
508 		addAttachment(mimeType, attachmentFileName, std.file.read(localFileName.toString()), id);
509 
510 		// see also: curl.h :1877    CURLOPT(CURLOPT_XOAUTH2_BEARER, CURLOPTTYPE_STRINGPOINT, 220),
511 		// also option to force STARTTLS
512 	}
513 
514 	/// in the html, use img src="cid:ID_GIVEN_HERE"
515 	void addInlineImage(string id, string mimeType, string filename, const void[] content) {
516 		assert(isHtml);
517 		isMime = true;
518 		inlineImages ~= MimeAttachment(mimeType, filename, cast(const(ubyte)[]) content, id);
519 	}
520 
521 	const(MimeAttachment)[] inlineImages;
522 
523 
524 	/* we should build out the mime thingy
525 		related
526 			mixed
527 			alternate
528 	*/
529 
530 	/// Returns the MIME formatted email string, including encoded attachments
531 	override string toString() {
532 		assert(!isHtml || (isHtml && isMime));
533 
534 		string[] headers;
535 		foreach(k, v; this.headers_) {
536 			if(headerSettableThroughAA(k))
537 				headers ~= k ~ ": " ~ encodeEmailHeaderContentForTransmit(v, this.linesep);
538 		}
539 
540 		if(to.length)
541 			headers ~= "To: " ~ to.toProtocolString(this.linesep);
542 		if(cc.length)
543 			headers ~= "Cc: " ~ cc.toProtocolString(this.linesep);
544 
545 		if(from.length)
546 			headers ~= "From: " ~ from.toProtocolString(this.linesep);
547 
548 			//assert(0, headers[$-1]);
549 
550 		if(subject !is null)
551 			headers ~= "Subject: " ~ encodeEmailHeaderContentForTransmit(subject, this.linesep);
552 		if(replyTo !is null)
553 			headers ~= "Reply-To: " ~ replyTo.toProtocolString(this.linesep);
554 		if(inReplyTo !is null)
555 			headers ~= "In-Reply-To: " ~ encodeEmailHeaderContentForTransmit(inReplyTo, this.linesep);
556 
557 		if(isMime)
558 			headers ~= "MIME-Version: 1.0";
559 
560 	/+
561 		if(inlineImages.length) {
562 			headers ~= "Content-Type: multipart/related; boundary=" ~ boundary;
563 			// so we put the alternative inside asthe first attachment with as seconary boundary
564 			// then we do the images
565 		} else
566 		if(attachments.length)
567 			headers ~= "Content-Type: multipart/mixed; boundary=" ~ boundary;
568 		else if(isHtml)
569 			headers ~= "Content-Type: multipart/alternative; boundary=" ~ boundary;
570 		else
571 			headers ~= "Content-Type: text/plain; charset=UTF-8";
572 	+/
573 
574 
575 		string msgContent;
576 
577 		if(isMime) {
578 			MimeContainer top;
579 
580 			{
581 				MimeContainer mimeMessage;
582 				enum NO_TRANSFER_ENCODING = "Content-Transfer-Encoding: 8bit";
583 				if(isHtml) {
584 					auto alternative = new MimeContainer("multipart/alternative");
585 					alternative.stuff ~= new MimeContainer("text/plain; charset=UTF-8", textBody_).with_header(NO_TRANSFER_ENCODING);
586 					alternative.stuff ~= new MimeContainer("text/html; charset=UTF-8", htmlBody_).with_header(NO_TRANSFER_ENCODING);
587 					mimeMessage = alternative;
588 				} else {
589 					mimeMessage = new MimeContainer("text/plain; charset=UTF-8", textBody_).with_header(NO_TRANSFER_ENCODING);
590 				}
591 				top = mimeMessage;
592 			}
593 
594 			{
595 				MimeContainer mimeRelated;
596 				if(inlineImages.length) {
597 					mimeRelated = new MimeContainer("multipart/related");
598 
599 					mimeRelated.stuff ~= top;
600 					top = mimeRelated;
601 
602 					foreach(attachment; inlineImages) {
603 						auto mimeAttachment = new MimeContainer(attachment.type ~ "; name=\""~attachment.filename~"\"");
604 						mimeAttachment.headers ~= "Content-Transfer-Encoding: base64";
605 						mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">";
606 						mimeAttachment.content = encodeBase64Mime(cast(const(ubyte)[]) attachment.content, this.linesep);
607 
608 						mimeRelated.stuff ~= mimeAttachment;
609 					}
610 				}
611 			}
612 
613 			{
614 				MimeContainer mimeMixed;
615 				if(attachments.length) {
616 					mimeMixed = new MimeContainer("multipart/mixed");
617 
618 					mimeMixed.stuff ~= top;
619 					top = mimeMixed;
620 
621 					foreach(attachment; attachments) {
622 						auto mimeAttachment = new MimeContainer(attachment.type);
623 						mimeAttachment.headers ~= "Content-Disposition: attachment; filename=\""~encodeEmailHeaderContentForTransmit(attachment.filename, this.linesep)~"\"";
624 						mimeAttachment.headers ~= "Content-Transfer-Encoding: base64";
625 						if(attachment.id.length)
626 							mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">";
627 
628 						mimeAttachment.content = encodeBase64Mime(cast(const(ubyte)[]) attachment.content, this.linesep);
629 
630 						mimeMixed.stuff ~= mimeAttachment;
631 					}
632 				}
633 			}
634 
635 			headers ~= top.contentType;
636 			msgContent = top.toMimeString(true, this.linesep);
637 		} else {
638 			headers ~= "Content-Type: text/plain; charset=UTF-8";
639 			msgContent = textBody_;
640 		}
641 
642 
643 		string msg;
644 		msg.reserve(htmlBody_.length + textBody_.length + 1024);
645 
646 		foreach(header; headers)
647 			msg ~= header ~ this.linesep;
648 		if(msg.length) // has headers
649 			msg ~= this.linesep;
650 
651 		msg ~= msgContent;
652 
653 		return msg;
654 	}
655 
656 	/// Sends via a given SMTP relay
657 	void send(RelayInfo mailServer = RelayInfo("smtp://localhost")) {
658 		auto smtp = SMTP(mailServer.server);
659 
660 		smtp.verifyHost = false;
661 		smtp.verifyPeer = false;
662 		//smtp.verbose = true;
663 
664 		{
665 			// std.net.curl doesn't work well with STARTTLS if you don't
666 			// put smtps://... and if you do, it errors if you can't start
667 			// with a TLS connection from the beginning.
668 
669 			// This change allows ssl if it can.
670 			import std.net.curl;
671 			import etc.c.curl;
672 			smtp.handle.set(CurlOption.use_ssl, CurlUseSSL.tryssl);
673 		}
674 
675 		if(mailServer.username.length)
676 			smtp.setAuthentication(mailServer.username, mailServer.password);
677 
678 		const(char)[][] allRecipients;
679 		void processPerson(string person) {
680 			auto idx = person.indexOf("<");
681 			if(idx == -1)
682 				allRecipients ~= person;
683 			else {
684 				person = person[idx + 1 .. $];
685 				idx = person.indexOf(">");
686 				if(idx != -1)
687 					person = person[0 .. idx];
688 
689 				allRecipients ~= person;
690 			}
691 		}
692 		foreach(person; to) processPerson(person);
693 		foreach(person; cc) processPerson(person);
694 		foreach(person; bcc) processPerson(person);
695 
696 		smtp.mailTo(allRecipients);
697 
698 		auto mailFrom = from;
699 		auto idx = mailFrom.indexOf("<");
700 		if(idx != -1)
701 			mailFrom = mailFrom[idx + 1 .. $];
702 		idx = mailFrom.indexOf(">");
703 		if(idx != -1)
704 			mailFrom = mailFrom[0 .. idx];
705 
706 		smtp.mailFrom = mailFrom;
707 		smtp.message = this.toString();
708 		smtp.perform();
709 	}
710 }
711 
712 ///
713 void email(string to, string subject, string message, string from, RelayInfo mailServer = RelayInfo("smtp://localhost")) {
714 	auto msg = new EmailMessage();
715 	msg.from = from;
716 	msg.to = [to];
717 	msg.subject = subject;
718 	msg.textBody_ = message;
719 	msg.send(mailServer);
720 }
721 
722 // private:
723 
724 import std.conv;
725 
726 /// for reading
727 class MimePart {
728 	string[] headers;
729 	immutable(ubyte)[] content;
730 	immutable(ubyte)[] encodedContent; // usually valid only for GPG, and will be cleared by creator; canonical form
731 	string textContent;
732 	MimePart[] stuff;
733 
734 	string name;
735 	string charset;
736 	string type;
737 	string transferEncoding;
738 	string disposition;
739 	string id;
740 	string filename;
741 	// gpg signatures
742 	string gpgalg;
743 	string gpgproto;
744 
745 	MimeAttachment toMimeAttachment() {
746 		if(type == "multipart/mixed" && stuff.length == 1)
747 			return stuff[0].toMimeAttachment;
748 
749 		MimeAttachment att;
750 		att.type = type;
751 		if(att.type == "application/octet-stream" && filename.length == 0 && name.length > 0 ) {
752 			att.filename = name;
753 		} else {
754 			att.filename = filename;
755 		}
756 		att.id = id;
757 		att.content = content;
758 		return att;
759 	}
760 
761 	this(immutable(ubyte)[][] lines, string contentType = null) {
762 		string boundary;
763 
764 		void parseContentType(string content) {
765 			//{ import std.stdio; writeln("c=[", content, "]"); }
766 			foreach(k, v; breakUpHeaderParts(content)) {
767 				//{ import std.stdio; writeln("  k=[", k, "]; v=[", v, "]"); }
768 				switch(k) {
769 					case "root":
770 						type = v;
771 					break;
772 					case "name":
773 						name = v;
774 					break;
775 					case "charset":
776 						charset = v;
777 					break;
778 					case "boundary":
779 						boundary = v;
780 					break;
781 					default:
782 					case "micalg":
783 						gpgalg = v;
784 					break;
785 					case "protocol":
786 						gpgproto = v;
787 					break;
788 				}
789 			}
790 		}
791 
792 		if(contentType is null) {
793 			// read headers immediately...
794 			auto copyOfLines = lines;
795 			immutable(ubyte)[] currentHeader;
796 
797 			void commitHeader() {
798 				if(currentHeader.length == 0)
799 					return;
800 				string h = decodeEncodedWord(cast(string) currentHeader);
801 				headers ~= h;
802 				currentHeader = null;
803 
804 				auto idx = h.indexOf(":");
805 				if(idx != -1) {
806 					auto name = h[0 .. idx].strip.toLower;
807 					auto content = h[idx + 1 .. $].strip;
808 
809 					string[4] filenames_found;
810 
811 					switch(name) {
812 						case "content-type":
813 							parseContentType(content);
814 						break;
815 						case "content-transfer-encoding":
816 							transferEncoding = content.toLower;
817 						break;
818 						case "content-disposition":
819 							foreach(k, v; breakUpHeaderParts(content)) {
820 								switch(k) {
821 									case "root":
822 										disposition = v;
823 									break;
824 									case "filename":
825 										filename = v;
826 									break;
827 									// FIXME: https://datatracker.ietf.org/doc/html/rfc2184#section-3 is what it is SUPPOSED to do
828 									case "filename*0":
829 										filenames_found[0] = v;
830 									break;
831 									case "filename*1":
832 										filenames_found[1] = v;
833 									break;
834 									case "filename*2":
835 										filenames_found[2] = v;
836 									break;
837 									case "filename*3":
838 										filenames_found[3] = v;
839 									break;
840 									default:
841 								}
842 							}
843 						break;
844 						case "content-id":
845 							id = content;
846 						break;
847 						default:
848 					}
849 
850 					if (filenames_found[0] != "") {
851 						foreach (string v; filenames_found) {
852 							this.filename ~= v;
853 						}
854 					}
855 				}
856 			}
857 
858 			foreach(line; copyOfLines) {
859 				lines = lines[1 .. $];
860 				if(line.length == 0)
861 					break;
862 
863 				if(line[0] == ' ' || line[0] == '\t')
864 					currentHeader ~= (cast(string) line).stripLeft();
865 				else {
866 					if(currentHeader.length) {
867 						commitHeader();
868 					}
869 					currentHeader = line;
870 				}
871 			}
872 
873 			commitHeader();
874 		} else {
875 			parseContentType(contentType);
876 		}
877 
878 		// if it is multipart, find the start boundary. we'll break it up and fill in stuff
879 		// otherwise, all the data that follows is just content
880 
881 		if(boundary.length) {
882 			immutable(ubyte)[][] partLines;
883 			bool inPart;
884 			foreach(line; lines) {
885 				if(line.startsWith("--" ~ boundary)) {
886 					if(inPart)
887 						stuff ~= new MimePart(partLines);
888 					inPart = true;
889 					partLines = null;
890 
891 					if(line == "--" ~ boundary ~ "--")
892 						break; // all done
893 				}
894 
895 				if(inPart) {
896 					partLines ~= line;
897 				} else {
898 					content ~= line ~ '\n';
899 				}
900 			}
901 		} else {
902 			foreach(line; lines) {
903 				content ~= line;
904 
905 				if(transferEncoding != "base64")
906 					content ~= '\n';
907 			}
908 		}
909 
910 		// store encoded content for GPG (should be cleared by caller if necessary)
911 		encodedContent = content;
912 
913 		// decode the content..
914 		switch(transferEncoding) {
915 			case "base64":
916 				content = Base64.decode(cast(string) content);
917 			break;
918 			case "quoted-printable":
919 				content = decodeQuotedPrintable(cast(string) content);
920 			break;
921 			default:
922 				// no change needed (I hope)
923 		}
924 
925 		if(type.indexOf("text/") == 0) {
926 			if(charset.length == 0)
927 				charset = "latin1";
928 			textContent = convertToUtf8Lossy(content, charset);
929 		}
930 	}
931 }
932 
933 string[string] breakUpHeaderParts(string headerContent) {
934 	string[string] ret;
935 
936 	string currentName = "root";
937 	string currentContent;
938 	bool inQuote = false;
939 	bool gettingName = false;
940 	bool ignoringSpaces = false;
941 	foreach(char c; headerContent) {
942 		if(ignoringSpaces) {
943 			if(c == ' ')
944 				continue;
945 			else
946 				ignoringSpaces = false;
947 		}
948 
949 		if(gettingName) {
950 			if(c == '=') {
951 				gettingName = false;
952 				continue;
953 			}
954 			currentName ~= c;
955 		}
956 
957 		if(c == '"') {
958 			inQuote = !inQuote;
959 			continue;
960 		}
961 
962 		if(!inQuote && c == ';') {
963 			ret[currentName] = currentContent;
964 			ignoringSpaces = true;
965 			currentName = null;
966 			currentContent = null;
967 
968 			gettingName = true;
969 			continue;
970 		}
971 
972 		if(!gettingName)
973 			currentContent ~= c;
974 	}
975 
976 	if(currentName.length)
977 		ret[currentName] = currentContent;
978 
979 	return ret;
980 }
981 
982 // for writing
983 class MimeContainer {
984 	private static int sequence;
985 
986 	immutable string _contentType;
987 	immutable string boundary;
988 
989 	string[] headers; // NOT including content-type
990 	string content;
991 	MimeContainer[] stuff;
992 
993 	this(string contentType, string content = null) {
994 		this._contentType = contentType;
995 		this.content = content;
996 		sequence++;
997 		if(_contentType.indexOf("multipart/") == 0)
998 			boundary = "0016e64be86203dd36047610926a" ~ to!string(sequence);
999 	}
1000 
1001 	@property string contentType() {
1002 		string ct = "Content-Type: "~_contentType;
1003 		if(boundary.length)
1004 			ct ~= "; boundary=" ~ boundary;
1005 		return ct;
1006 	}
1007 
1008 
1009 	string toMimeString(bool isRoot = false, string linesep="\r\n") {
1010 		string ret;
1011 
1012 		if(!isRoot) {
1013 			ret ~= contentType;
1014 			foreach(header; headers) {
1015 				ret ~= linesep;
1016 				ret ~= encodeEmailHeaderForTransmit(header, linesep);
1017 			}
1018 			ret ~= linesep ~ linesep;
1019 		}
1020 
1021 		ret ~= content;
1022 
1023 		foreach(idx, thing; stuff) {
1024 			assert(boundary.length);
1025 			ret ~= linesep ~ "--" ~ boundary ~ linesep;
1026 			ret ~= thing.toMimeString(false, linesep);
1027 		}
1028 
1029 		if(boundary.length)
1030 			ret ~= linesep ~ "--" ~ boundary ~ "--";
1031 
1032 		return ret;
1033 	}
1034 }
1035 
1036 import std.algorithm : startsWith;
1037 /++
1038 	Represents a single email from an incoming or saved source consisting of the raw data. Such saved sources include mbox files (which are several concatenated together, see [MboxMessages] for a full reader of these files), .eml files, and Maildir entries.
1039 +/
1040 class IncomingEmailMessage : EmailMessage {
1041 	/++
1042 		Various constructors for parsing an email message.
1043 
1044 
1045 		The `ref immutable(ubyte)[][]` one is designed for reading a pre-loaded mbox file. It updates the ref variable to the point at the next message in the file as it processes. You probably should use [MboxMessages] in a `foreach` loop instead of calling this directly most the time.
1046 
1047 		The `string[]` one takes an ascii or utf-8 file of a single email pre-split into lines.
1048 
1049 		The `immutable(ubyte)[]` one is designed for reading an individual message in its own file in the easiest way. Try `new IncomingEmailMessage(cast(immutable(ubyte)[]) std.file.read("filename.eml"));` to use this. You can also use `IncomingEmailMessage.fromFile("filename.eml")` as well.
1050 
1051 		History:
1052 			The `immutable(ubyte)[]` overload for a single file was added on May 14, 2024.
1053 	+/
1054 	this(ref immutable(ubyte)[][] mboxLines, bool asmbox=true) @trusted {
1055 
1056 		enum ParseState {
1057 			lookingForFrom,
1058 			readingHeaders,
1059 			readingBody
1060 		}
1061 
1062 		auto state = (asmbox ? ParseState.lookingForFrom : ParseState.readingHeaders);
1063 		string contentType;
1064 
1065 		bool isMultipart;
1066 		bool isHtml;
1067 		immutable(ubyte)[][] mimeLines;
1068 
1069 		string charset = "latin-1";
1070 
1071 		string contentTransferEncoding;
1072 
1073 		string headerName;
1074 		string headerContent;
1075 		void commitHeader() {
1076 			if(headerName is null)
1077 				return;
1078 
1079 			auto originalHeaderName = headerName;
1080 			headerName = headerName.toLower();
1081 			headerContent = headerContent.strip();
1082 
1083 			headerContent = decodeEncodedWord(headerContent);
1084 
1085 			if(headerName == "content-type") {
1086 				contentType = headerContent;
1087 				if(contentType.indexOf("multipart/") != -1)
1088 					isMultipart = true;
1089 				else if(contentType.indexOf("text/html") != -1)
1090 					isHtml = true;
1091 
1092 				auto charsetIdx = contentType.indexOf("charset=");
1093 				if(charsetIdx != -1) {
1094 					string cs = contentType[charsetIdx + "charset=".length .. $];
1095 					if(cs.length && cs[0] == '\"')
1096 						cs = cs[1 .. $];
1097 
1098 					auto quoteIdx = cs.indexOf("\"");
1099 					if(quoteIdx != -1)
1100 						cs = cs[0 .. quoteIdx];
1101 					auto semicolonIdx = cs.indexOf(";");
1102 					if(semicolonIdx != -1)
1103 						cs = cs[0 .. semicolonIdx];
1104 
1105 					cs = cs.strip();
1106 					if(cs.length)
1107 						charset = cs.toLower();
1108 				}
1109 			} else if(headerName == "from") {
1110 				this.from = headerContent;
1111 			} else if(headerName == "to") {
1112 				this.to ~= headerContent;
1113 			} else if(headerName == "subject") {
1114 				this.subject = headerContent;
1115 			} else if(headerName == "content-transfer-encoding") {
1116 				contentTransferEncoding = headerContent;
1117 			}
1118 
1119 			headers_[originalHeaderName] = headerContent;
1120 			headerName = null;
1121 			headerContent = null;
1122 		}
1123 
1124 		lineLoop: while(mboxLines.length) {
1125 			// this can needlessly convert headers too, but that won't harm anything since they are 7 bit anyway
1126 			auto line = convertToUtf8Lossy(mboxLines[0], charset);
1127 			auto origline = line;
1128 			line = line.stripRight;
1129 
1130 			final switch(state) {
1131 				case ParseState.lookingForFrom:
1132 					if(line.startsWith("From "))
1133 						state = ParseState.readingHeaders;
1134 				break;
1135 				case ParseState.readingHeaders:
1136 					if(line.length == 0) {
1137 						commitHeader();
1138 						state = ParseState.readingBody;
1139 					} else {
1140 						if(line[0] == ' ' || line[0] == '\t') {
1141 							headerContent ~= " " ~ line.stripLeft();
1142 						} else {
1143 							commitHeader();
1144 
1145 							auto idx = line.indexOf(":");
1146 							if(idx == -1)
1147 								headerName = line;
1148 							else {
1149 								headerName = line[0 .. idx];
1150 								headerContent = line[idx + 1 .. $].stripLeft();
1151 							}
1152 						}
1153 					}
1154 				break;
1155 				case ParseState.readingBody:
1156 					if (asmbox) {
1157 						if(line.startsWith("From ")) {
1158 							break lineLoop; // we're at the beginning of the next messsage
1159 						}
1160 						if(line.startsWith(">>From") || line.startsWith(">From")) {
1161 							line = line[1 .. $];
1162 						}
1163 					}
1164 
1165 					if(isMultipart) {
1166 						mimeLines ~= mboxLines[0];
1167 					} else if(isHtml) {
1168 						// html with no alternative and no attachments
1169 						this.htmlBody_ ~= line ~ "\n";
1170 					} else {
1171 						// plain text!
1172 						// we want trailing spaces for "format=flowed", for example, so...
1173 						line = origline;
1174 						size_t epos = line.length;
1175 						while (epos > 0) {
1176 							char ch = line.ptr[epos-1];
1177 							if (ch >= ' ' || ch == '\t') break;
1178 							--epos;
1179 						}
1180 						line = line.ptr[0..epos];
1181 						this.textBody_ ~= line ~ "\n";
1182 					}
1183 				break;
1184 			}
1185 
1186 			mboxLines = mboxLines[1 .. $];
1187 		}
1188 
1189 		if(mimeLines.length) {
1190 			auto part = new MimePart(mimeLines, contentType);
1191 			deeperInTheMimeTree:
1192 			switch(part.type) {
1193 				case "text/html":
1194 					this.htmlBody_ = part.textContent;
1195 				break;
1196 				case "text/plain":
1197 					this.textBody_ = part.textContent;
1198 				break;
1199 				case "multipart/alternative":
1200 					foreach(p; part.stuff) {
1201 						if(p.type == "text/html")
1202 							this.htmlBody_ = p.textContent;
1203 						else if(p.type == "text/plain")
1204 							this.textBody_ = p.textContent;
1205 					}
1206 				break;
1207 				case "multipart/related":
1208 					// the first one is the message itself
1209 					// after that comes attachments that can be rendered inline
1210 					if(part.stuff.length) {
1211 						auto msg = part.stuff[0];
1212 						foreach(thing; part.stuff[1 .. $]) {
1213 							// FIXME: should this be special?
1214 							attachments ~= thing.toMimeAttachment();
1215 						}
1216 						part = msg;
1217 						goto deeperInTheMimeTree;
1218 					}
1219 				break;
1220 				case "multipart/mixed":
1221 					if(part.stuff.length) {
1222 						auto msg = part.stuff[0];
1223 						foreach(thing; part.stuff[1 .. $]) {
1224 							attachments ~= thing.toMimeAttachment();
1225 						}
1226 						part = msg;
1227 						goto deeperInTheMimeTree;
1228 					}
1229 
1230 					// FIXME: the more proper way is:
1231 					// check the disposition
1232 					// if none, concat it to make a text message body
1233 					// if inline it is prolly an image to be concated in the other body
1234 					// if attachment, it is an attachment
1235 				break;
1236 				case "multipart/signed":
1237 					// FIXME: it would be cool to actually check the signature
1238 					if (part.stuff.length) {
1239 						auto msg = part.stuff[0];
1240 						//{ import std.stdio; writeln("hdrs: ", part.stuff[0].headers); }
1241 						gpgalg = part.gpgalg;
1242 						gpgproto = part.gpgproto;
1243 						gpgmime = part;
1244 						foreach (thing; part.stuff[1 .. $]) {
1245 							attachments ~= thing.toMimeAttachment();
1246 						}
1247 						part = msg;
1248 						goto deeperInTheMimeTree;
1249 					}
1250 				break;
1251 				default:
1252 					// FIXME: correctly handle more
1253 					if(part.stuff.length) {
1254 						part = part.stuff[0];
1255 						goto deeperInTheMimeTree;
1256 					}
1257 			}
1258 		} else {
1259 			switch(contentTransferEncoding) {
1260 				case "quoted-printable":
1261 					if(this.textBody_.length)
1262 						this.textBody_ = convertToUtf8Lossy(decodeQuotedPrintable(this.textBody_), charset);
1263 					if(this.htmlBody_.length)
1264 						this.htmlBody_ = convertToUtf8Lossy(decodeQuotedPrintable(this.htmlBody_), charset);
1265 				break;
1266 				case "base64":
1267 					if(this.textBody_.length) {
1268 						this.textBody_ = this.textBody_.decodeBase64Mime.convertToUtf8Lossy(charset);
1269 					}
1270 					if(this.htmlBody_.length) {
1271 						this.htmlBody_ = this.htmlBody_.decodeBase64Mime.convertToUtf8Lossy(charset);
1272 					}
1273 
1274 				break;
1275 				default:
1276 					// nothing needed
1277 			}
1278 		}
1279 
1280 		if(this.htmlBody_.length > 0 && this.textBody_.length == 0) {
1281 			import arsd.htmltotext;
1282 			this.textBody_ = htmlToText(this.htmlBody_);
1283 			textAutoConverted = true;
1284 		}
1285 	}
1286 
1287 	/// ditto
1288 	this(string[] lines) {
1289 		auto lns = cast(immutable(ubyte)[][])lines;
1290 		this(lns, false);
1291 	}
1292 
1293 	/// ditto
1294 	this(immutable(ubyte)[] fileContent) {
1295 		auto lns = splitLinesWithoutDecoding(fileContent);
1296 		this(lns, false);
1297 	}
1298 
1299 	/++
1300 		Convenience method that takes a filename instead of the content.
1301 
1302 		Its implementation is simply `return new IncomingEmailMessage(cast(immutable(ubyte)[]) std.file.read(filename));`
1303 		(though i reserve the right to use a different file loading library later, still the same idea)
1304 
1305 		History:
1306 			Added May 14, 2024
1307 	+/
1308 	static IncomingEmailMessage fromFile(string filename) {
1309 		import std.file;
1310 		return new IncomingEmailMessage(cast(immutable(ubyte)[]) std.file.read(filename));
1311 	}
1312 
1313 	///
1314 	@property bool hasGPGSignature () const nothrow @trusted @nogc {
1315 		MimePart mime = cast(MimePart)gpgmime; // sorry
1316 		if (mime is null) return false;
1317 		if (mime.type != "multipart/signed") return false;
1318 		if (mime.stuff.length != 2) return false;
1319 		if (mime.stuff[1].type != "application/pgp-signature") return false;
1320 		if (mime.stuff[0].type.length <= 5 && mime.stuff[0].type[0..5] != "text/") return false;
1321 		return true;
1322 	}
1323 
1324 	///
1325 	ubyte[] extractGPGData () const nothrow @trusted {
1326 		if (!hasGPGSignature) return null;
1327 		MimePart mime = cast(MimePart)gpgmime; // sorry
1328 		char[] res;
1329 		res.reserve(mime.stuff[0].encodedContent.length); // more, actually
1330 		foreach (string s; mime.stuff[0].headers[1..$]) {
1331 			while (s.length && s[$-1] <= ' ') s = s[0..$-1];
1332 			if (s.length == 0) return null; // wtf?! empty headers?
1333 			res ~= s;
1334 			res ~= "\r\n";
1335 		}
1336 		res ~= "\r\n";
1337 		// extract content (see rfc3156)
1338 		size_t pos = 0;
1339 		auto ctt = mime.stuff[0].encodedContent;
1340 		// last CR/LF is a part of mime signature, actually, so remove it
1341 		if (ctt.length && ctt[$-1] == '\n') {
1342 			ctt = ctt[0..$-1];
1343 			if (ctt.length && ctt[$-1] == '\r') ctt = ctt[0..$-1];
1344 		}
1345 		while (pos < ctt.length) {
1346 			auto epos = pos;
1347 			while (epos < ctt.length && ctt.ptr[epos] != '\n') ++epos;
1348 			auto xpos = epos;
1349 			while (xpos > pos && ctt.ptr[xpos-1] <= ' ') --xpos; // according to rfc
1350 			res ~= ctt[pos..xpos].dup;
1351 			res ~= "\r\n"; // according to rfc
1352 			pos = epos+1;
1353 		}
1354 		return cast(ubyte[])res;
1355 	}
1356 
1357 	///
1358 	immutable(ubyte)[] extractGPGSignature () const nothrow @safe @nogc {
1359 		if (!hasGPGSignature) return null;
1360 		return gpgmime.stuff[1].content;
1361 	}
1362 
1363 	/++
1364 		Allows access to the headers in the email as a key/value hash.
1365 
1366 		The hash allows access as if it was case-insensitive, but it also still keeps the original case when you loop through it.
1367 
1368 		Bugs:
1369 			Duplicate headers are lost in the current implementation; only the most recent copy of any given name is retained.
1370 	+/
1371 	const(HeadersHash) headers() {
1372 		return headers_;
1373 	}
1374 
1375 	/++
1376 		Returns the message body as either HTML or text. Gives the same results as through the parent interface, [EmailMessage.htmlBody] and [EmailMessage.textBody].
1377 
1378 		If the message was multipart/alternative, both of these will be populated with content from the message. They are supposed to be both the same, but not all senders respect this so you might want to check both anyway.
1379 
1380 		If the message was just plain text, `htmlMessageBody` will be `null` and `textMessageBody` will have the original message.
1381 
1382 		If the message was just HTML, `htmlMessageBody` contains the original message and `textMessageBody` will contain an automatically converted version (using [arsd.htmltotext]). [textAutoConverted] will be set to `true`.
1383 
1384 		History:
1385 			Were public strings until May 14, 2024, when it was changed to property getters instead.
1386 	+/
1387 	string htmlMessageBody() {
1388 		return this.htmlBody_;
1389 	}
1390 	/// ditto
1391 	string textMessageBody() {
1392 		return this.textBody_;
1393 	}
1394 	/// ditto
1395 	bool textAutoConverted;
1396 
1397 	// gpg signature fields
1398 	string gpgalg; ///
1399 	string gpgproto; ///
1400 	MimePart gpgmime; ///
1401 
1402 	///
1403 	string fromEmailAddress() {
1404 		return from.address;
1405 	}
1406 
1407 	///
1408 	string toEmailAddress() {
1409 		if(to.recipients.length)
1410 			return to.recipients[0].address;
1411 		return null;
1412 	}
1413 }
1414 
1415 /++
1416 	An mbox file is a concatenated list of individual email messages. This is a range of messages given the content of one of those files.
1417 +/
1418 struct MboxMessages {
1419 	immutable(ubyte)[][] linesRemaining;
1420 
1421 	///
1422 	this(immutable(ubyte)[] data) {
1423 		linesRemaining = splitLinesWithoutDecoding(data);
1424 		popFront();
1425 	}
1426 
1427 	IncomingEmailMessage currentFront;
1428 
1429 	///
1430 	IncomingEmailMessage front() {
1431 		return currentFront;
1432 	}
1433 
1434 	///
1435 	bool empty() {
1436 		return currentFront is null;
1437 	}
1438 
1439 	///
1440 	void popFront() {
1441 		if(linesRemaining.length)
1442 			currentFront = new IncomingEmailMessage(linesRemaining);
1443 		else
1444 			currentFront = null;
1445 	}
1446 }
1447 
1448 ///
1449 MboxMessages processMboxData(immutable(ubyte)[] data) {
1450 	return MboxMessages(data);
1451 }
1452 
1453 immutable(ubyte)[][] splitLinesWithoutDecoding(immutable(ubyte)[] data) {
1454 	immutable(ubyte)[][] ret;
1455 
1456 	size_t starting = 0;
1457 	bool justSaw13 = false;
1458 	foreach(idx, b; data) {
1459 		if(b == 13)
1460 			justSaw13 = true;
1461 
1462 		if(b == 10) {
1463 			auto use = idx;
1464 			if(justSaw13)
1465 				use--;
1466 
1467 			ret ~= data[starting .. use];
1468 			starting = idx + 1;
1469 		}
1470 
1471 		if(b != 13)
1472 			justSaw13 = false;
1473 	}
1474 
1475 	if(starting < data.length)
1476 		ret ~= data[starting .. $];
1477 
1478 	return ret;
1479 }
1480 
1481 string decodeEncodedWord(string data) {
1482 	string originalData = data;
1483 
1484 	auto delimiter = data.indexOf("=?");
1485 	if(delimiter == -1)
1486 		return data;
1487 
1488 	string ret;
1489 
1490 	while(delimiter != -1) {
1491 		ret ~= data[0 .. delimiter];
1492 		data = data[delimiter + 2 .. $];
1493 
1494 		string charset;
1495 		string encoding;
1496 		string encodedText;
1497 
1498 		// FIXME: the insane things should probably throw an
1499 		// exception that keeps a copy of orignal data for use later
1500 
1501 		auto questionMark = data.indexOf("?");
1502 		if(questionMark == -1) return originalData; // not sane
1503 
1504 		charset = data[0 .. questionMark];
1505 		data = data[questionMark + 1 .. $];
1506 
1507 		questionMark = data.indexOf("?");
1508 		if(questionMark == -1) return originalData; // not sane
1509 
1510 		encoding = data[0 .. questionMark];
1511 		data = data[questionMark + 1 .. $];
1512 
1513 		questionMark = data.indexOf("?=");
1514 		if(questionMark == -1) return originalData; // not sane
1515 
1516 		encodedText = data[0 .. questionMark];
1517 		data = data[questionMark + 2 .. $];
1518 
1519 		delimiter = data.indexOf("=?");
1520 		if (delimiter == 1 && data[0] == ' ') {
1521 			// a single space between encoded words must be ignored because it is
1522 			// used to separate multiple encoded words (RFC2047 says CRLF SPACE but a most clients
1523 			// just use a space)
1524 			data = data[1..$];
1525 			delimiter = 0;
1526 		}
1527 
1528 		immutable(ubyte)[] decodedText;
1529 		if(encoding == "Q" || encoding == "q")
1530 			decodedText = decodeQuotedPrintable(encodedText);
1531 		else if(encoding == "B" || encoding == "b") {
1532 			decodedText = cast(typeof(decodedText)) Base64.decode(encodedText);
1533 		} else
1534 			return originalData; // wtf
1535 
1536 		ret ~= convertToUtf8Lossy(decodedText, charset);
1537 	}
1538 
1539 	ret ~= data; // keep the rest since there could be trailing stuff
1540 
1541 	return ret;
1542 }
1543 
1544 immutable(ubyte)[] decodeQuotedPrintable(string text) {
1545 	immutable(ubyte)[] ret;
1546 
1547 	int state = 0;
1548 	ubyte hexByte;
1549 	foreach(b; cast(immutable(ubyte)[]) text) {
1550 		switch(state) {
1551 			case 0:
1552 				if(b == '=') {
1553 					state++;
1554 					hexByte = 0;
1555 				} else if (b == '_') { // RFC2047 4.2.2: a _ may be used to represent a space
1556 					ret ~= ' ';
1557 				} else
1558 					ret ~= b;
1559 			break;
1560 			case 1:
1561 				if(b == '\n') {
1562 					state = 0;
1563 					continue;
1564 				}
1565 				goto case;
1566 			case 2:
1567 				int value;
1568 				if(b >= '0' && b <= '9')
1569 					value = b - '0';
1570 				else if(b >= 'A' && b <= 'F')
1571 					value = b - 'A' + 10;
1572 				else if(b >= 'a' && b <= 'f')
1573 					value = b - 'a' + 10;
1574 				if(state == 1) {
1575 					hexByte |= value << 4;
1576 					state++;
1577 				} else {
1578 					hexByte |= value;
1579 					ret ~= hexByte;
1580 					state = 0;
1581 				}
1582 			break;
1583 			default: assert(0);
1584 		}
1585 	}
1586 
1587 	return ret;
1588 }
1589 
1590 /// Add header UFCS helper
1591 auto with_header(MimeContainer container, string header){
1592 	container.headers ~= header;
1593 	return container;
1594 }
1595 
1596 /// Base64 range encoder UFCS helper.
1597 alias base64encode = Base64.encoder;
1598 
1599 /// Base64 encoded data with line length of 76 as mandated by RFC 2045 Section 6.8
1600 string encodeBase64Mime(const(ubyte[]) content, string LINESEP = "\r\n") {
1601 	enum LINE_LENGTH = 76;
1602 	/// Only 6 bit of every byte are used; log2(64) = 6
1603 	enum int SOURCE_CHUNK_LENGTH = LINE_LENGTH * 6/8;
1604 
1605 	return cast(immutable(char[]))content.chunks(SOURCE_CHUNK_LENGTH).base64encode.join(LINESEP);
1606 }
1607 
1608 
1609 /// Base64 range decoder UFCS helper.
1610 alias base64decode = Base64.decoder;
1611 
1612 /// Base64 decoder, ignoring linebreaks which are mandated by RFC2045
1613 immutable(ubyte[]) decodeBase64Mime(string encodedPart) {
1614 	return cast(immutable(ubyte[])) encodedPart
1615 		.byChar // prevent Autodecoding, which will break Base64 decoder. Since its base64, it's guarenteed to be 7bit ascii
1616 		.filter!((c) => (c != '\r') & (c != '\n'))
1617 		.base64decode
1618 		.array;
1619 }
1620 
1621 unittest {
1622 	// Mime base64 roundtrip
1623 	import std.algorithm.comparison;
1624 	string source = chain(
1625 		repeat('n', 1200), //long line
1626 		"\r\n",
1627 		"äöü\r\n",
1628 		"ඞ\rn",
1629 		).byChar.array;
1630 	assert( source.representation.encodeBase64Mime.decodeBase64Mime.equal(source));
1631 }
1632 
1633 unittest {
1634 	import std.algorithm;
1635 	import std.string;
1636 	// Mime message roundtrip
1637 	auto mail = new EmailMessage();
1638 	mail.to = ["recipient@example.org"];
1639 	mail.from = "sender@example.org";
1640 	mail.subject = "Subject";
1641 
1642 	auto text = cast(string) chain(
1643 			repeat('n', 1200),
1644 			"\r\n",
1645 			"äöü\r\n",
1646 			"ඞ\r\nlast",
1647 			).byChar.array;
1648 	mail.setTextBody(text);
1649 	mail.addAttachment("text/plain", "attachment.txt", text.representation);
1650 	// In case binary and plaintext get handled differently one day
1651 	mail.addAttachment("application/octet-stream", "attachment.bin", text.representation);
1652 
1653 	auto result = new IncomingEmailMessage(mail.toString().split("\r\n"));
1654 
1655 	assert(result.subject.equal(mail.subject));
1656 	assert(mail.to.canFind(result.to));
1657 	assert(result.from == mail.from.toString);
1658 
1659 	// This roundtrip works modulo trailing newline on the parsed message and LF vs CRLF
1660 	assert(result.textMessageBody.replace("\n", "\r\n").stripRight().equal(mail.textBody_));
1661 	assert(result.attachments.equal(mail.attachments));
1662 }
1663 
1664 private bool hasAllPrintableAscii(in char[] s) {
1665 	foreach(ch; s) {
1666 		if(ch < 32)
1667 			return false;
1668 		if(ch >= 127)
1669 			return false;
1670 	}
1671 	return true;
1672 }
1673 
1674 private string encodeEmailHeaderContentForTransmit(string value, string linesep, bool prechecked = false) {
1675 	if(!prechecked && value.length < 998 && hasAllPrintableAscii(value))
1676 		return value;
1677 
1678 	return "=?UTF-8?B?" ~
1679 		encodeBase64Mime(cast(const(ubyte)[]) value, "?=" ~ linesep ~ " =?UTF-8?B?") ~
1680 		"?=";
1681 }
1682 
1683 private string encodeEmailHeaderForTransmit(string completeHeader, string linesep) {
1684 	if(completeHeader.length < 998 && hasAllPrintableAscii(completeHeader))
1685 		return completeHeader;
1686 
1687 	// note that we are here if there's a newline embedded in the content as well
1688 	auto colon = completeHeader.indexOf(":");
1689 	if(colon == -1) // should never happen!
1690 		throw new Exception("invalid email header - no colon in " ~ completeHeader); // but exception instead of assert since this might happen as result of public data manip
1691 
1692 	auto name = completeHeader[0 .. colon + 1];
1693 	if(!hasAllPrintableAscii(name)) // should never happen!
1694 		throw new Exception("invalid email header - improper name: " ~ name); // ditto
1695 
1696 	auto value = completeHeader[colon + 1 .. $].strip;
1697 
1698 	return
1699 		name ~
1700 		" " ~ // i like that leading space after the colon but it was stripped out of value
1701 		encodeEmailHeaderContentForTransmit(value, linesep, true);
1702 }
1703 
1704 unittest {
1705 	auto linesep = "\r\n";
1706 	string test = "Subject: This is an ordinary subject line with no special characters and not exceeding the maximum line length limit.";
1707 	assert(test is encodeEmailHeaderForTransmit(test, linesep)); // returned by identity
1708 
1709 	test = "Subject: foo\nbar";
1710 	assert(test !is encodeEmailHeaderForTransmit(test, linesep)); // a newline forces encoding
1711 }
1712 
1713 /+
1714 void main() {
1715 	import std.file;
1716 	import std.stdio;
1717 
1718 	auto data = cast(immutable(ubyte)[]) std.file.read("/home/me/test_email_data");
1719 	foreach(message; processMboxData(data)) {
1720 		writeln(message.subject);
1721 		writeln(message.textMessageBody);
1722 		writeln("**************** END MESSSAGE **************");
1723 	}
1724 }
1725 +/