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