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 module arsd.email;
5 
6 import std.net.curl;
7 pragma(lib, "curl");
8 
9 import std.base64;
10 import std.string;
11 
12 import arsd.characterencodings;
13 
14 //         import std.uuid;
15 // smtpMessageBoundary = randomUUID().toString();
16 
17 // SEE ALSO: std.net.curl.SMTP
18 
19 ///
20 struct RelayInfo {
21 	string server; ///
22 	string username; ///
23 	string password; ///
24 }
25 
26 ///
27 struct MimeAttachment {
28 	string type; ///
29 	string filename; ///
30 	const(ubyte)[] content; ///
31 	string id; ///
32 }
33 
34 ///
35 enum ToType {
36 	to,
37 	cc,
38 	bcc
39 }
40 
41 
42 /++
43 	For OUTGOING email
44 
45 
46 	To use:
47 
48 	---
49 	auto message = new EmailMessage();
50 	message.to ~= "someuser@example.com";
51 	message.from = "youremail@example.com";
52 	message.subject = "My Subject";
53 	message.setTextBody("hi there");
54 	//message.toString(); // get string to send externally
55 	message.send(); // send via some relay
56 	// may also set replyTo, etc
57 	---
58 +/
59 class EmailMessage {
60 	///
61 	void setHeader(string name, string value) {
62 		headers ~= name ~ ": " ~ value;
63 	}
64 
65 	string[] to;  ///
66 	string[] cc;  ///
67 	string[] bcc;  ///
68 	string from;  ///
69 	string replyTo;  ///
70 	string inReplyTo;  ///
71 	string textBody;
72 	string htmlBody;
73 	string subject;  ///
74 
75 	string[] headers;
76 
77 	private bool isMime = false;
78 	private bool isHtml = false;
79 
80 	///
81 	void addRecipient(string name, string email, ToType how = ToType.to) {
82 		addRecipient(`"`~name~`" <`~email~`>`, how);
83 	}
84 
85 	///
86 	void addRecipient(string who, ToType how = ToType.to) {
87 		final switch(how) {
88 			case ToType.to:
89 				to ~= who;
90 			break;
91 			case ToType.cc:
92 				cc ~= who;
93 			break;
94 			case ToType.bcc:
95 				bcc ~= who;
96 			break;
97 		}
98 	}
99 
100 	///
101 	void setTextBody(string text) {
102 		textBody = text.strip;
103 	}
104 	/// automatically sets a text fallback if you haven't already
105 	void setHtmlBody()(string html) {
106 		isMime = true;
107 		isHtml = true;
108 		htmlBody = html;
109 
110 		import arsd.htmltotext;
111 		if(textBody is null)
112 			textBody = htmlToText(html);
113 	}
114 
115 	const(MimeAttachment)[] attachments;
116 
117 	/++
118 		The filename is what is shown to the user, not the file on your sending computer. It should NOT have a path in it.
119 
120 		---
121 			message.addAttachment("text/plain", "something.txt", std.file.read("/path/to/local/something.txt"));
122 		---
123 	+/
124 	void addAttachment(string mimeType, string filename, const void[] content, string id = null) {
125 		isMime = true;
126 		attachments ~= MimeAttachment(mimeType, filename, cast(const(ubyte)[]) content, id);
127 	}
128 
129 	/// in the html, use img src="cid:ID_GIVEN_HERE"
130 	void addInlineImage(string id, string mimeType, string filename, const void[] content) {
131 		assert(isHtml);
132 		isMime = true;
133 		inlineImages ~= MimeAttachment(mimeType, filename, cast(const(ubyte)[]) content, id);
134 	}
135 
136 	const(MimeAttachment)[] inlineImages;
137 
138 
139 	/* we should build out the mime thingy
140 		related
141 			mixed
142 			alternate
143 	*/
144 
145 	/// Returns the MIME formatted email string, including encoded attachments
146 	override string toString() {
147 		assert(!isHtml || (isHtml && isMime));
148 
149 		auto headers = this.headers;
150 
151 		if(to.length)
152 			headers ~= "To: " ~ join(to, ", ");
153 		if(cc.length)
154 			headers ~= "Cc: " ~ join(cc, ", ");
155 
156 		if(from.length)
157 			headers ~= "From: " ~ from;
158 
159 		if(subject !is null)
160 			headers ~= "Subject: " ~ subject;
161 		if(replyTo !is null)
162 			headers ~= "Reply-To: " ~ replyTo;
163 		if(inReplyTo !is null)
164 			headers ~= "In-Reply-To: " ~ inReplyTo;
165 
166 		if(isMime)
167 			headers ~= "MIME-Version: 1.0";
168 
169 	/+
170 		if(inlineImages.length) {
171 			headers ~= "Content-Type: multipart/related; boundary=" ~ boundary;
172 			// so we put the alternative inside asthe first attachment with as seconary boundary
173 			// then we do the images
174 		} else
175 		if(attachments.length)
176 			headers ~= "Content-Type: multipart/mixed; boundary=" ~ boundary;
177 		else if(isHtml)
178 			headers ~= "Content-Type: multipart/alternative; boundary=" ~ boundary;
179 		else
180 			headers ~= "Content-Type: text/plain; charset=UTF-8";
181 	+/
182 
183 
184 		string msgContent;
185 
186 		if(isMime) {
187 			MimeContainer top;
188 
189 			{
190 				MimeContainer mimeMessage;
191 				if(isHtml) {
192 					auto alternative = new MimeContainer("multipart/alternative");
193 					alternative.stuff ~= new MimeContainer("text/plain; charset=UTF-8", textBody);
194 					alternative.stuff ~= new MimeContainer("text/html; charset=UTF-8", htmlBody);
195 					mimeMessage = alternative;
196 				} else {
197 					mimeMessage = new MimeContainer("text/plain; charset=UTF-8", textBody);
198 				}
199 				top = mimeMessage;
200 			}
201 
202 			{
203 				MimeContainer mimeRelated;
204 				if(inlineImages.length) {
205 					mimeRelated = new MimeContainer("multipart/related");
206 
207 					mimeRelated.stuff ~= top;
208 					top = mimeRelated;
209 
210 					foreach(attachment; inlineImages) {
211 						auto mimeAttachment = new MimeContainer(attachment.type ~ "; name=\""~attachment.filename~"\"");
212 						mimeAttachment.headers ~= "Content-Transfer-Encoding: base64";
213 						mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">";
214 						mimeAttachment.content = Base64.encode(cast(const(ubyte)[]) attachment.content);
215 
216 						mimeRelated.stuff ~= mimeAttachment;
217 					}
218 				}
219 			}
220 
221 			{
222 				MimeContainer mimeMixed;
223 				if(attachments.length) {
224 					mimeMixed = new MimeContainer("multipart/mixed");
225 
226 					mimeMixed.stuff ~= top;
227 					top = mimeMixed;
228 
229 					foreach(attachment; attachments) {
230 						auto mimeAttachment = new MimeContainer(attachment.type);
231 						mimeAttachment.headers ~= "Content-Disposition: attachment; filename=\""~attachment.filename~"\"";
232 						mimeAttachment.headers ~= "Content-Transfer-Encoding: base64";
233 						if(attachment.id.length)
234 							mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">";
235 
236 						mimeAttachment.content = Base64.encode(cast(const(ubyte)[]) attachment.content);
237 
238 						mimeMixed.stuff ~= mimeAttachment;
239 					}
240 				}
241 			}
242 
243 			headers ~= top.contentType;
244 			msgContent = top.toMimeString(true);
245 		} else {
246 			headers ~= "Content-Type: text/plain; charset=UTF-8";
247 			msgContent = textBody;
248 		}
249 
250 
251 		string msg;
252 		msg.reserve(htmlBody.length + textBody.length + 1024);
253 
254 		foreach(header; headers)
255 			msg ~= header ~ "\r\n";
256 		if(msg.length) // has headers
257 			msg ~= "\r\n";
258 
259 		msg ~= msgContent;
260 
261 		return msg;
262 	}
263 
264 	/// Sends via a given SMTP relay
265 	void send(RelayInfo mailServer = RelayInfo("smtp://localhost")) {
266 		auto smtp = SMTP(mailServer.server);
267 
268 		smtp.verifyHost = false;
269 		smtp.verifyPeer = false;
270 		//smtp.verbose = true;
271 
272 		{
273 			// std.net.curl doesn't work well with STARTTLS if you don't
274 			// put smtps://... and if you do, it errors if you can't start
275 			// with a TLS connection from the beginning.
276 
277 			// This change allows ssl if it can.
278 			import std.net.curl;
279 			import etc.c.curl;
280 			smtp.handle.set(CurlOption.use_ssl, CurlUseSSL.tryssl);
281 		}
282 
283 		if(mailServer.username.length)
284 			smtp.setAuthentication(mailServer.username, mailServer.password);
285 
286 		const(char)[][] allRecipients;
287 		void processPerson(string person) {
288 			auto idx = person.indexOf("<");
289 			if(idx == -1)
290 				allRecipients ~= person;
291 			else {
292 				person = person[idx + 1 .. $];
293 				idx = person.indexOf(">");
294 				if(idx != -1)
295 					person = person[0 .. idx];
296 
297 				allRecipients ~= person;
298 			}
299 		}
300 		foreach(person; to) processPerson(person);
301 		foreach(person; cc) processPerson(person);
302 		foreach(person; bcc) processPerson(person);
303 
304 		smtp.mailTo(allRecipients);
305 
306 		auto mailFrom = from;
307 		auto idx = mailFrom.indexOf("<");
308 		if(idx != -1)
309 			mailFrom = mailFrom[idx + 1 .. $];
310 		idx = mailFrom.indexOf(">");
311 		if(idx != -1)
312 			mailFrom = mailFrom[0 .. idx];
313 
314 		smtp.mailFrom = mailFrom;
315 		smtp.message = this.toString();
316 		smtp.perform();
317 	}
318 }
319 
320 ///
321 void email(string to, string subject, string message, string from, RelayInfo mailServer = RelayInfo("smtp://localhost")) {
322 	auto msg = new EmailMessage();
323 	msg.from = from;
324 	msg.to = [to];
325 	msg.subject = subject;
326 	msg.textBody = message;
327 	msg.send(mailServer);
328 }
329 
330 // private:
331 
332 import std.conv;
333 
334 /// for reading
335 class MimePart {
336 	string[] headers;
337 	immutable(ubyte)[] content;
338 	immutable(ubyte)[] encodedContent; // usually valid only for GPG, and will be cleared by creator; canonical form
339 	string textContent;
340 	MimePart[] stuff;
341 
342 	string name;
343 	string charset;
344 	string type;
345 	string transferEncoding;
346 	string disposition;
347 	string id;
348 	string filename;
349 	// gpg signatures
350 	string gpgalg;
351 	string gpgproto;
352 
353 	MimeAttachment toMimeAttachment() {
354 		if(type == "multipart/mixed" && stuff.length == 1)
355 			return stuff[0].toMimeAttachment;
356 
357 		MimeAttachment att;
358 		att.type = type;
359 		if(att.type == "application/octet-stream" && filename.length == 0 && name.length > 0 ) {
360 			att.filename = name;
361 		} else {
362 			att.filename = filename;
363 		}
364 		att.id = id;
365 		att.content = content;
366 		return att;
367 	}
368 
369 	this(immutable(ubyte)[][] lines, string contentType = null) {
370 		string boundary;
371 
372 		void parseContentType(string content) {
373 			//{ import std.stdio; writeln("c=[", content, "]"); }
374 			foreach(k, v; breakUpHeaderParts(content)) {
375 				//{ import std.stdio; writeln("  k=[", k, "]; v=[", v, "]"); }
376 				switch(k) {
377 					case "root":
378 						type = v;
379 					break;
380 					case "name":
381 						name = v;
382 					break;
383 					case "charset":
384 						charset = v;
385 					break;
386 					case "boundary":
387 						boundary = v;
388 					break;
389 					default:
390 					case "micalg":
391 						gpgalg = v;
392 					break;
393 					case "protocol":
394 						gpgproto = v;
395 					break;
396 				}
397 			}
398 		}
399 
400 		if(contentType is null) {
401 			// read headers immediately...
402 			auto copyOfLines = lines;
403 			immutable(ubyte)[] currentHeader;
404 
405 			void commitHeader() {
406 				if(currentHeader.length == 0)
407 					return;
408 				string h = decodeEncodedWord(cast(string) currentHeader);
409 				headers ~= h;
410 				currentHeader = null;
411 
412 				auto idx = h.indexOf(":");
413 				if(idx != -1) {
414 					auto name = h[0 .. idx].strip.toLower;
415 					auto content = h[idx + 1 .. $].strip;
416 
417 					string[4] filenames_found;
418 
419 					switch(name) {
420 						case "content-type":
421 							parseContentType(content);
422 						break;
423 						case "content-transfer-encoding":
424 							transferEncoding = content.toLower;
425 						break;
426 						case "content-disposition":
427 							foreach(k, v; breakUpHeaderParts(content)) {
428 								switch(k) {
429 									case "root":
430 										disposition = v;
431 									break;
432 									case "filename":
433 										filename = v;
434 									break;
435 									// FIXME: https://datatracker.ietf.org/doc/html/rfc2184#section-3 is what it is SUPPOSED to do
436 									case "filename*0":
437 										filenames_found[0] = v;
438 									break;
439 									case "filename*1":
440 										filenames_found[1] = v;
441 									break;
442 									case "filename*2":
443 										filenames_found[2] = v;
444 									break;
445 									case "filename*3":
446 										filenames_found[3] = v;
447 									break;
448 									default:
449 								}
450 							}
451 						break;
452 						case "content-id":
453 							id = content;
454 						break;
455 						default:
456 					}
457 
458 					if (filenames_found[0] != "") {
459 						foreach (string v; filenames_found) {
460 							this.filename ~= v;
461 						}
462 					}
463 				}
464 			}
465 
466 			foreach(line; copyOfLines) {
467 				lines = lines[1 .. $];
468 				if(line.length == 0)
469 					break;
470 
471 				if(line[0] == ' ' || line[0] == '\t')
472 					currentHeader ~= (cast(string) line).stripLeft();
473 				else {
474 					if(currentHeader.length) {
475 						commitHeader();
476 					}
477 					currentHeader = line;
478 				}
479 			}
480 
481 			commitHeader();
482 		} else {
483 			parseContentType(contentType);
484 		}
485 
486 		// if it is multipart, find the start boundary. we'll break it up and fill in stuff
487 		// otherwise, all the data that follows is just content
488 
489 		if(boundary.length) {
490 			immutable(ubyte)[][] partLines;
491 			bool inPart;
492 			foreach(line; lines) {
493 				if(line.startsWith("--" ~ boundary)) {
494 					if(inPart)
495 						stuff ~= new MimePart(partLines);
496 					inPart = true;
497 					partLines = null;
498 
499 					if(line == "--" ~ boundary ~ "--")
500 						break; // all done
501 				}
502 
503 				if(inPart) {
504 					partLines ~= line;
505 				} else {
506 					content ~= line ~ '\n';
507 				}
508 			}
509 		} else {
510 			foreach(line; lines) {
511 				content ~= line;
512 
513 				if(transferEncoding != "base64")
514 					content ~= '\n';
515 			}
516 		}
517 
518 		// store encoded content for GPG (should be cleared by caller if necessary)
519 		encodedContent = content;
520 
521 		// decode the content..
522 		switch(transferEncoding) {
523 			case "base64":
524 				content = Base64.decode(cast(string) content);
525 			break;
526 			case "quoted-printable":
527 				content = decodeQuotedPrintable(cast(string) content);
528 			break;
529 			default:
530 				// no change needed (I hope)
531 		}
532 
533 		if(type.indexOf("text/") == 0) {
534 			if(charset.length == 0)
535 				charset = "latin1";
536 			textContent = convertToUtf8Lossy(content, charset);
537 		}
538 	}
539 }
540 
541 string[string] breakUpHeaderParts(string headerContent) {
542 	string[string] ret;
543 
544 	string currentName = "root";
545 	string currentContent;
546 	bool inQuote = false;
547 	bool gettingName = false;
548 	bool ignoringSpaces = false;
549 	foreach(char c; headerContent) {
550 		if(ignoringSpaces) {
551 			if(c == ' ')
552 				continue;
553 			else
554 				ignoringSpaces = false;
555 		}
556 
557 		if(gettingName) {
558 			if(c == '=') {
559 				gettingName = false;
560 				continue;
561 			}
562 			currentName ~= c;
563 		}
564 
565 		if(c == '"') {
566 			inQuote = !inQuote;
567 			continue;
568 		}
569 
570 		if(!inQuote && c == ';') {
571 			ret[currentName] = currentContent;
572 			ignoringSpaces = true;
573 			currentName = null;
574 			currentContent = null;
575 
576 			gettingName = true;
577 			continue;
578 		}
579 
580 		if(!gettingName)
581 			currentContent ~= c;
582 	}
583 
584 	if(currentName.length)
585 		ret[currentName] = currentContent;
586 
587 	return ret;
588 }
589 
590 // for writing
591 class MimeContainer {
592 	private static int sequence;
593 
594 	immutable string _contentType;
595 	immutable string boundary;
596 
597 	string[] headers; // NOT including content-type
598 	string content;
599 	MimeContainer[] stuff;
600 
601 	this(string contentType, string content = null) {
602 		this._contentType = contentType;
603 		this.content = content;
604 		sequence++;
605 		if(_contentType.indexOf("multipart/") == 0)
606 			boundary = "0016e64be86203dd36047610926a" ~ to!string(sequence);
607 	}
608 
609 	@property string contentType() {
610 		string ct = "Content-Type: "~_contentType;
611 		if(boundary.length)
612 			ct ~= "; boundary=" ~ boundary;
613 		return ct;
614 	}
615 
616 
617 	string toMimeString(bool isRoot = false) {
618 		string ret;
619 
620 		if(!isRoot) {
621 			ret ~= contentType;
622 			foreach(header; headers) {
623 				ret ~= "\r\n";
624 				ret ~= header;
625 			}
626 			ret ~= "\r\n\r\n";
627 		}
628 
629 		ret ~= content;
630 
631 		foreach(idx, thing; stuff) {
632 			assert(boundary.length);
633 			ret ~= "\r\n--" ~ boundary ~ "\r\n";
634 			ret ~= thing.toMimeString(false);
635 		}
636 
637 		if(boundary.length)
638 			ret ~= "\r\n--" ~ boundary ~ "--";
639 
640 		return ret;
641 	}
642 }
643 
644 import std.algorithm : startsWith;
645 ///
646 class IncomingEmailMessage {
647 	///
648 	this(string[] lines) {
649 		auto lns = cast(immutable(ubyte)[][])lines;
650 		this(lns, false);
651 	}
652 
653 	///
654 	this(ref immutable(ubyte)[][] mboxLines, bool asmbox=true) {
655 
656 		enum ParseState {
657 			lookingForFrom,
658 			readingHeaders,
659 			readingBody
660 		}
661 
662 		auto state = (asmbox ? ParseState.lookingForFrom : ParseState.readingHeaders);
663 		string contentType;
664 
665 		bool isMultipart;
666 		bool isHtml;
667 		immutable(ubyte)[][] mimeLines;
668 
669 		string charset = "latin-1";
670 
671 		string contentTransferEncoding;
672 
673 		string headerName;
674 		string headerContent;
675 		void commitHeader() {
676 			if(headerName is null)
677 				return;
678 
679 			headerName = headerName.toLower();
680 			headerContent = headerContent.strip();
681 
682 			headerContent = decodeEncodedWord(headerContent);
683 
684 			if(headerName == "content-type") {
685 				contentType = headerContent;
686 				if(contentType.indexOf("multipart/") != -1)
687 					isMultipart = true;
688 				else if(contentType.indexOf("text/html") != -1)
689 					isHtml = true;
690 
691 				auto charsetIdx = contentType.indexOf("charset=");
692 				if(charsetIdx != -1) {
693 					string cs = contentType[charsetIdx + "charset=".length .. $];
694 					if(cs.length && cs[0] == '\"')
695 						cs = cs[1 .. $];
696 
697 					auto quoteIdx = cs.indexOf("\"");
698 					if(quoteIdx != -1)
699 						cs = cs[0 .. quoteIdx];
700 					auto semicolonIdx = cs.indexOf(";");
701 					if(semicolonIdx != -1)
702 						cs = cs[0 .. semicolonIdx];
703 
704 					cs = cs.strip();
705 					if(cs.length)
706 						charset = cs.toLower();
707 				}
708 			} else if(headerName == "from") {
709 				this.from = headerContent;
710 			} else if(headerName == "to") {
711 				this.to = headerContent;
712 			} else if(headerName == "subject") {
713 				this.subject = headerContent;
714 			} else if(headerName == "content-transfer-encoding") {
715 				contentTransferEncoding = headerContent;
716 			}
717 
718 			headers[headerName] = headerContent;
719 			headerName = null;
720 			headerContent = null;
721 		}
722 
723 		lineLoop: while(mboxLines.length) {
724 			// this can needlessly convert headers too, but that won't harm anything since they are 7 bit anyway
725 			auto line = convertToUtf8Lossy(mboxLines[0], charset);
726 			auto origline = line;
727 			line = line.stripRight;
728 
729 			final switch(state) {
730 				case ParseState.lookingForFrom:
731 					if(line.startsWith("From "))
732 						state = ParseState.readingHeaders;
733 				break;
734 				case ParseState.readingHeaders:
735 					if(line.length == 0) {
736 						commitHeader();
737 						state = ParseState.readingBody;
738 					} else {
739 						if(line[0] == ' ' || line[0] == '\t') {
740 							headerContent ~= " " ~ line.stripLeft();
741 						} else {
742 							commitHeader();
743 
744 							auto idx = line.indexOf(":");
745 							if(idx == -1)
746 								headerName = line;
747 							else {
748 								headerName = line[0 .. idx];
749 								headerContent = line[idx + 1 .. $].stripLeft();
750 							}
751 						}
752 					}
753 				break;
754 				case ParseState.readingBody:
755 					if (asmbox) {
756 						if(line.startsWith("From ")) {
757 							break lineLoop; // we're at the beginning of the next messsage
758 						}
759 						if(line.startsWith(">>From") || line.startsWith(">From")) {
760 							line = line[1 .. $];
761 						}
762 					}
763 
764 					if(isMultipart) {
765 						mimeLines ~= mboxLines[0];
766 					} else if(isHtml) {
767 						// html with no alternative and no attachments
768 						htmlMessageBody ~= line ~ "\n";
769 					} else {
770 						// plain text!
771 						// we want trailing spaces for "format=flowed", for example, so...
772 						line = origline;
773 						size_t epos = line.length;
774 						while (epos > 0) {
775 							char ch = line.ptr[epos-1];
776 							if (ch >= ' ' || ch == '\t') break;
777 							--epos;
778 						}
779 						line = line.ptr[0..epos];
780 						textMessageBody ~= line ~ "\n";
781 					}
782 				break;
783 			}
784 
785 			mboxLines = mboxLines[1 .. $];
786 		}
787 
788 		if(mimeLines.length) {
789 			auto part = new MimePart(mimeLines, contentType);
790 			deeperInTheMimeTree:
791 			switch(part.type) {
792 				case "text/html":
793 					htmlMessageBody = part.textContent;
794 				break;
795 				case "text/plain":
796 					textMessageBody = part.textContent;
797 				break;
798 				case "multipart/alternative":
799 					foreach(p; part.stuff) {
800 						if(p.type == "text/html")
801 							htmlMessageBody = p.textContent;
802 						else if(p.type == "text/plain")
803 							textMessageBody = p.textContent;
804 					}
805 				break;
806 				case "multipart/related":
807 					// the first one is the message itself
808 					// after that comes attachments that can be rendered inline
809 					if(part.stuff.length) {
810 						auto msg = part.stuff[0];
811 						foreach(thing; part.stuff[1 .. $]) {
812 							// FIXME: should this be special?
813 							attachments ~= thing.toMimeAttachment();
814 						}
815 						part = msg;
816 						goto deeperInTheMimeTree;
817 					}
818 				break;
819 				case "multipart/mixed":
820 					if(part.stuff.length) {
821 						auto msg = part.stuff[0];
822 						foreach(thing; part.stuff[1 .. $]) {
823 							attachments ~= thing.toMimeAttachment();
824 						}
825 						part = msg;
826 						goto deeperInTheMimeTree;
827 					}
828 
829 					// FIXME: the more proper way is:
830 					// check the disposition
831 					// if none, concat it to make a text message body
832 					// if inline it is prolly an image to be concated in the other body
833 					// if attachment, it is an attachment
834 				break;
835 				case "multipart/signed":
836 					// FIXME: it would be cool to actually check the signature
837 					if (part.stuff.length) {
838 						auto msg = part.stuff[0];
839 						//{ import std.stdio; writeln("hdrs: ", part.stuff[0].headers); }
840 						gpgalg = part.gpgalg;
841 						gpgproto = part.gpgproto;
842 						gpgmime = part;
843 						foreach (thing; part.stuff[1 .. $]) {
844 							attachments ~= thing.toMimeAttachment();
845 						}
846 						part = msg;
847 						goto deeperInTheMimeTree;
848 					}
849 				break;
850 				default:
851 					// FIXME: correctly handle more
852 					if(part.stuff.length) {
853 						part = part.stuff[0];
854 						goto deeperInTheMimeTree;
855 					}
856 			}
857 		} else {
858 			switch(contentTransferEncoding) {
859 				case "quoted-printable":
860 					if(textMessageBody.length)
861 						textMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(textMessageBody), charset);
862 					if(htmlMessageBody.length)
863 						htmlMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(htmlMessageBody), charset);
864 				break;
865 				case "base64":
866 					if(textMessageBody.length) {
867 						// alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here
868 						char[] mmb;
869 						mmb.reserve(textMessageBody.length);
870 						foreach (char ch; textMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch;
871 						textMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset);
872 					}
873 					if(htmlMessageBody.length) {
874 						// alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here
875 						char[] mmb;
876 						mmb.reserve(htmlMessageBody.length);
877 						foreach (char ch; htmlMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch;
878 						htmlMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset);
879 					}
880 
881 				break;
882 				default:
883 					// nothing needed
884 			}
885 		}
886 
887 		if(htmlMessageBody.length > 0 && textMessageBody.length == 0) {
888 			import arsd.htmltotext;
889 			textMessageBody = htmlToText(htmlMessageBody);
890 			textAutoConverted = true;
891 		}
892 	}
893 
894 	///
895 	@property bool hasGPGSignature () const nothrow @trusted @nogc {
896 		MimePart mime = cast(MimePart)gpgmime; // sorry
897 		if (mime is null) return false;
898 		if (mime.type != "multipart/signed") return false;
899 		if (mime.stuff.length != 2) return false;
900 		if (mime.stuff[1].type != "application/pgp-signature") return false;
901 		if (mime.stuff[0].type.length <= 5 && mime.stuff[0].type[0..5] != "text/") return false;
902 		return true;
903 	}
904 
905 	///
906 	ubyte[] extractGPGData () const nothrow @trusted {
907 		if (!hasGPGSignature) return null;
908 		MimePart mime = cast(MimePart)gpgmime; // sorry
909 		char[] res;
910 		res.reserve(mime.stuff[0].encodedContent.length); // more, actually
911 		foreach (string s; mime.stuff[0].headers[1..$]) {
912 			while (s.length && s[$-1] <= ' ') s = s[0..$-1];
913 			if (s.length == 0) return null; // wtf?! empty headers?
914 			res ~= s;
915 			res ~= "\r\n";
916 		}
917 		res ~= "\r\n";
918 		// extract content (see rfc3156)
919 		size_t pos = 0;
920 		auto ctt = mime.stuff[0].encodedContent;
921 		// last CR/LF is a part of mime signature, actually, so remove it
922 		if (ctt.length && ctt[$-1] == '\n') {
923 			ctt = ctt[0..$-1];
924 			if (ctt.length && ctt[$-1] == '\r') ctt = ctt[0..$-1];
925 		}
926 		while (pos < ctt.length) {
927 			auto epos = pos;
928 			while (epos < ctt.length && ctt.ptr[epos] != '\n') ++epos;
929 			auto xpos = epos;
930 			while (xpos > pos && ctt.ptr[xpos-1] <= ' ') --xpos; // according to rfc
931 			res ~= ctt[pos..xpos].dup;
932 			res ~= "\r\n"; // according to rfc
933 			pos = epos+1;
934 		}
935 		return cast(ubyte[])res;
936 	}
937 
938 	///
939 	immutable(ubyte)[] extractGPGSignature () const nothrow @safe @nogc {
940 		if (!hasGPGSignature) return null;
941 		return gpgmime.stuff[1].content;
942 	}
943 
944 	string[string] headers; ///
945 
946 	string subject; ///
947 
948 	string htmlMessageBody; ///
949 	string textMessageBody; ///
950 
951 	string from; ///
952 	string to; ///
953 
954 	bool textAutoConverted; ///
955 
956 	MimeAttachment[] attachments; ///
957 
958 	// gpg signature fields
959 	string gpgalg; ///
960 	string gpgproto; ///
961 	MimePart gpgmime; ///
962 
963 	///
964 	string fromEmailAddress() {
965 		auto i = from.indexOf("<");
966 		if(i == -1)
967 			return from;
968 		auto e = from.indexOf(">");
969 		return from[i + 1 .. e];
970 	}
971 
972 	///
973 	string toEmailAddress() {
974 		auto i = to.indexOf("<");
975 		if(i == -1)
976 			return to;
977 		auto e = to.indexOf(">");
978 		return to[i + 1 .. e];
979 	}
980 }
981 
982 ///
983 struct MboxMessages {
984 	immutable(ubyte)[][] linesRemaining;
985 
986 	///
987 	this(immutable(ubyte)[] data) {
988 		linesRemaining = splitLinesWithoutDecoding(data);
989 		popFront();
990 	}
991 
992 	IncomingEmailMessage currentFront;
993 
994 	///
995 	IncomingEmailMessage front() {
996 		return currentFront;
997 	}
998 
999 	///
1000 	bool empty() {
1001 		return currentFront is null;
1002 	}
1003 
1004 	///
1005 	void popFront() {
1006 		if(linesRemaining.length)
1007 			currentFront = new IncomingEmailMessage(linesRemaining);
1008 		else
1009 			currentFront = null;
1010 	}
1011 }
1012 
1013 ///
1014 MboxMessages processMboxData(immutable(ubyte)[] data) {
1015 	return MboxMessages(data);
1016 }
1017 
1018 immutable(ubyte)[][] splitLinesWithoutDecoding(immutable(ubyte)[] data) {
1019 	immutable(ubyte)[][] ret;
1020 
1021 	size_t starting = 0;
1022 	bool justSaw13 = false;
1023 	foreach(idx, b; data) {
1024 		if(b == 13)
1025 			justSaw13 = true;
1026 
1027 		if(b == 10) {
1028 			auto use = idx;
1029 			if(justSaw13)
1030 				use--;
1031 
1032 			ret ~= data[starting .. use];
1033 			starting = idx + 1;
1034 		}
1035 
1036 		if(b != 13)
1037 			justSaw13 = false;
1038 	}
1039 
1040 	if(starting < data.length)
1041 		ret ~= data[starting .. $];
1042 
1043 	return ret;
1044 }
1045 
1046 string decodeEncodedWord(string data) {
1047 	string originalData = data;
1048 
1049 	auto delimiter = data.indexOf("=?");
1050 	if(delimiter == -1)
1051 		return data;
1052 
1053 	string ret;
1054 
1055 	while(delimiter != -1) {
1056 		ret ~= data[0 .. delimiter];
1057 		data = data[delimiter + 2 .. $];
1058 
1059 		string charset;
1060 		string encoding;
1061 		string encodedText;
1062 
1063 		// FIXME: the insane things should probably throw an
1064 		// exception that keeps a copy of orignal data for use later
1065 
1066 		auto questionMark = data.indexOf("?");
1067 		if(questionMark == -1) return originalData; // not sane
1068 
1069 		charset = data[0 .. questionMark];
1070 		data = data[questionMark + 1 .. $];
1071 
1072 		questionMark = data.indexOf("?");
1073 		if(questionMark == -1) return originalData; // not sane
1074 
1075 		encoding = data[0 .. questionMark];
1076 		data = data[questionMark + 1 .. $];
1077 
1078 		questionMark = data.indexOf("?=");
1079 		if(questionMark == -1) return originalData; // not sane
1080 
1081 		encodedText = data[0 .. questionMark];
1082 		data = data[questionMark + 2 .. $];
1083 
1084 		delimiter = data.indexOf("=?");
1085 		if (delimiter == 1 && data[0] == ' ') {
1086 			// a single space between encoded words must be ignored because it is
1087 			// used to separate multiple encoded words (RFC2047 says CRLF SPACE but a most clients
1088 			// just use a space)
1089 			data = data[1..$];
1090 			delimiter = 0;
1091 		}
1092 
1093 		immutable(ubyte)[] decodedText;
1094 		if(encoding == "Q" || encoding == "q")
1095 			decodedText = decodeQuotedPrintable(encodedText);
1096 		else if(encoding == "B" || encoding == "b")
1097 			decodedText = cast(typeof(decodedText)) Base64.decode(encodedText);
1098 		else
1099 			return originalData; // wtf
1100 
1101 		ret ~= convertToUtf8Lossy(decodedText, charset);
1102 	}
1103 
1104 	ret ~= data; // keep the rest since there could be trailing stuff
1105 
1106 	return ret;
1107 }
1108 
1109 immutable(ubyte)[] decodeQuotedPrintable(string text) {
1110 	immutable(ubyte)[] ret;
1111 
1112 	int state = 0;
1113 	ubyte hexByte;
1114 	foreach(b; cast(immutable(ubyte)[]) text) {
1115 		switch(state) {
1116 			case 0:
1117 				if(b == '=') {
1118 					state++;
1119 					hexByte = 0;
1120 				} else if (b == '_') { // RFC2047 4.2.2: a _ may be used to represent a space
1121 					ret ~= ' ';
1122 				} else
1123 					ret ~= b;
1124 			break;
1125 			case 1:
1126 				if(b == '\n') {
1127 					state = 0;
1128 					continue;
1129 				}
1130 				goto case;
1131 			case 2:
1132 				int value;
1133 				if(b >= '0' && b <= '9')
1134 					value = b - '0';
1135 				else if(b >= 'A' && b <= 'F')
1136 					value = b - 'A' + 10;
1137 				else if(b >= 'a' && b <= 'f')
1138 					value = b - 'a' + 10;
1139 				if(state == 1) {
1140 					hexByte |= value << 4;
1141 					state++;
1142 				} else {
1143 					hexByte |= value;
1144 					ret ~= hexByte;
1145 					state = 0;
1146 				}
1147 			break;
1148 			default: assert(0);
1149 		}
1150 	}
1151 
1152 	return ret;
1153 }
1154 
1155 /+
1156 void main() {
1157 	import std.file;
1158 	import std.stdio;
1159 
1160 	auto data = cast(immutable(ubyte)[]) std.file.read("/home/me/test_email_data");
1161 	foreach(message; processMboxData(data)) {
1162 		writeln(message.subject);
1163 		writeln(message.textMessageBody);
1164 		writeln("**************** END MESSSAGE **************");
1165 	}
1166 }
1167 +/