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 		if(mailServer.username.length)
272 			smtp.setAuthentication(mailServer.username, mailServer.password);
273 		const(char)[][] allRecipients = cast(const(char)[][]) (to ~ cc ~ bcc); // WTF cast
274 		smtp.mailTo(allRecipients);
275 
276 		auto mailFrom = from;
277 		auto idx = mailFrom.indexOf("<");
278 		if(idx != -1)
279 			mailFrom = mailFrom[idx + 1 .. $];
280 		idx = mailFrom.indexOf(">");
281 		if(idx != -1)
282 			mailFrom = mailFrom[0 .. idx];
283 
284 		smtp.mailFrom = mailFrom;
285 		smtp.message = this.toString();
286 		smtp.perform();
287 	}
288 }
289 
290 ///
291 void email(string to, string subject, string message, string from, RelayInfo mailServer = RelayInfo("smtp://localhost")) {
292 	auto msg = new EmailMessage();
293 	msg.from = from;
294 	msg.to = [to];
295 	msg.subject = subject;
296 	msg.textBody = message;
297 	msg.send(mailServer);
298 }
299 
300 // private:
301 
302 import std.conv;
303 
304 /// for reading
305 class MimePart {
306 	string[] headers;
307 	immutable(ubyte)[] content;
308 	immutable(ubyte)[] encodedContent; // usually valid only for GPG, and will be cleared by creator; canonical form
309 	string textContent;
310 	MimePart[] stuff;
311 
312 	string name;
313 	string charset;
314 	string type;
315 	string transferEncoding;
316 	string disposition;
317 	string id;
318 	string filename;
319 	// gpg signatures
320 	string gpgalg;
321 	string gpgproto;
322 
323 	MimeAttachment toMimeAttachment() {
324 		MimeAttachment att;
325 		att.type = type;
326 		att.filename = filename;
327 		att.id = id;
328 		att.content = content;
329 		return att;
330 	}
331 
332 	this(immutable(ubyte)[][] lines, string contentType = null) {
333 		string boundary;
334 
335 		void parseContentType(string content) {
336 			//{ import std.stdio; writeln("c=[", content, "]"); }
337 			foreach(k, v; breakUpHeaderParts(content)) {
338 				//{ import std.stdio; writeln("  k=[", k, "]; v=[", v, "]"); }
339 				switch(k) {
340 					case "root":
341 						type = v;
342 					break;
343 					case "name":
344 						name = v;
345 					break;
346 					case "charset":
347 						charset = v;
348 					break;
349 					case "boundary":
350 						boundary = v;
351 					break;
352 					default:
353 					case "micalg":
354 						gpgalg = v;
355 					break;
356 					case "protocol":
357 						gpgproto = v;
358 					break;
359 				}
360 			}
361 		}
362 
363 		if(contentType is null) {
364 			// read headers immediately...
365 			auto copyOfLines = lines;
366 			immutable(ubyte)[] currentHeader;
367 
368 			void commitHeader() {
369 				if(currentHeader.length == 0)
370 					return;
371 				string h = decodeEncodedWord(cast(string) currentHeader);
372 				headers ~= h;
373 				currentHeader = null;
374 
375 				auto idx = h.indexOf(":");
376 				if(idx != -1) {
377 					auto name = h[0 .. idx].strip.toLower;
378 					auto content = h[idx + 1 .. $].strip;
379 
380 					switch(name) {
381 						case "content-type":
382 							parseContentType(content);
383 						break;
384 						case "content-transfer-encoding":
385 							transferEncoding = content.toLower;
386 						break;
387 						case "content-disposition":
388 							foreach(k, v; breakUpHeaderParts(content)) {
389 								switch(k) {
390 									case "root":
391 										disposition = v;
392 									break;
393 									case "filename":
394 										filename = v;
395 									break;
396 									default:
397 								}
398 							}
399 						break;
400 						case "content-id":
401 							id = content;
402 						break;
403 						default:
404 					}
405 				}
406 			}
407 
408 			foreach(line; copyOfLines) {
409 				lines = lines[1 .. $];
410 				if(line.length == 0)
411 					break;
412 
413 				if(line[0] == ' ' || line[0] == '\t')
414 					currentHeader ~= (cast(string) line).stripLeft();
415 				else {
416 					if(currentHeader.length) {
417 						commitHeader();
418 					}
419 					currentHeader = line;
420 				}
421 			}
422 
423 			commitHeader();
424 		} else {
425 			parseContentType(contentType);
426 		}
427 
428 		// if it is multipart, find the start boundary. we'll break it up and fill in stuff
429 		// otherwise, all the data that follows is just content
430 
431 		if(boundary.length) {
432 			immutable(ubyte)[][] partLines;
433 			bool inPart;
434 			foreach(line; lines) {
435 				if(line.startsWith("--" ~ boundary)) {
436 					if(inPart)
437 						stuff ~= new MimePart(partLines);
438 					inPart = true;
439 					partLines = null;
440 
441 					if(line == "--" ~ boundary ~ "--")
442 						break; // all done
443 				}
444 
445 				if(inPart) {
446 					partLines ~= line;
447 				} else {
448 					content ~= line ~ '\n';
449 				}
450 			}
451 		} else {
452 			foreach(line; lines) {
453 				content ~= line;
454 
455 				if(transferEncoding != "base64")
456 					content ~= '\n';
457 			}
458 		}
459 
460 		// store encoded content for GPG (should be cleared by caller if necessary)
461 		encodedContent = content;
462 
463 		// decode the content..
464 		switch(transferEncoding) {
465 			case "base64":
466 				content = Base64.decode(cast(string) content);
467 			break;
468 			case "quoted-printable":
469 				content = decodeQuotedPrintable(cast(string) content);
470 			break;
471 			default:
472 				// no change needed (I hope)
473 		}
474 
475 		if(type.indexOf("text/") == 0) {
476 			if(charset.length == 0)
477 				charset = "latin1";
478 			textContent = convertToUtf8Lossy(content, charset);
479 		}
480 	}
481 }
482 
483 string[string] breakUpHeaderParts(string headerContent) {
484 	string[string] ret;
485 
486 	string currentName = "root";
487 	string currentContent;
488 	bool inQuote = false;
489 	bool gettingName = false;
490 	bool ignoringSpaces = false;
491 	foreach(char c; headerContent) {
492 		if(ignoringSpaces) {
493 			if(c == ' ')
494 				continue;
495 			else
496 				ignoringSpaces = false;
497 		}
498 
499 		if(gettingName) {
500 			if(c == '=') {
501 				gettingName = false;
502 				continue;
503 			}
504 			currentName ~= c;
505 		}
506 
507 		if(c == '"') {
508 			inQuote = !inQuote;
509 			continue;
510 		}
511 
512 		if(!inQuote && c == ';') {
513 			ret[currentName] = currentContent;
514 			ignoringSpaces = true;
515 			currentName = null;
516 			currentContent = null;
517 
518 			gettingName = true;
519 			continue;
520 		}
521 
522 		if(!gettingName)
523 			currentContent ~= c;
524 	}
525 
526 	if(currentName.length)
527 		ret[currentName] = currentContent;
528 
529 	return ret;
530 }
531 
532 // for writing
533 class MimeContainer {
534 	private static int sequence;
535 
536 	immutable string _contentType;
537 	immutable string boundary;
538 
539 	string[] headers; // NOT including content-type
540 	string content;
541 	MimeContainer[] stuff;
542 
543 	this(string contentType, string content = null) {
544 		this._contentType = contentType;
545 		this.content = content;
546 		sequence++;
547 		if(_contentType.indexOf("multipart/") == 0)
548 			boundary = "0016e64be86203dd36047610926a" ~ to!string(sequence);
549 	}
550 
551 	@property string contentType() {
552 		string ct = "Content-Type: "~_contentType;
553 		if(boundary.length)
554 			ct ~= "; boundary=" ~ boundary;
555 		return ct;
556 	}
557 
558 
559 	string toMimeString(bool isRoot = false) {
560 		string ret;
561 
562 		if(!isRoot) {
563 			ret ~= contentType;
564 			foreach(header; headers) {
565 				ret ~= "\r\n";
566 				ret ~= header;
567 			}
568 			ret ~= "\r\n\r\n";
569 		}
570 
571 		ret ~= content;
572 
573 		foreach(idx, thing; stuff) {
574 			assert(boundary.length);
575 			ret ~= "\r\n--" ~ boundary ~ "\r\n";
576 			ret ~= thing.toMimeString(false);
577 		}
578 
579 		if(boundary.length)
580 			ret ~= "\r\n--" ~ boundary ~ "--";
581 
582 		return ret;
583 	}
584 }
585 
586 import std.algorithm : startsWith;
587 ///
588 class IncomingEmailMessage {
589 	///
590 	this(string[] lines) {
591 		auto lns = cast(immutable(ubyte)[][])lines;
592 		this(lns, false);
593 	}
594 
595 	///
596 	this(ref immutable(ubyte)[][] mboxLines, bool asmbox=true) {
597 
598 		enum ParseState {
599 			lookingForFrom,
600 			readingHeaders,
601 			readingBody
602 		}
603 
604 		auto state = (asmbox ? ParseState.lookingForFrom : ParseState.readingHeaders);
605 		string contentType;
606 
607 		bool isMultipart;
608 		bool isHtml;
609 		immutable(ubyte)[][] mimeLines;
610 
611 		string charset = "latin-1";
612 
613 		string contentTransferEncoding;
614 
615 		string headerName;
616 		string headerContent;
617 		void commitHeader() {
618 			if(headerName is null)
619 				return;
620 
621 			headerName = headerName.toLower();
622 			headerContent = headerContent.strip();
623 
624 			headerContent = decodeEncodedWord(headerContent);
625 
626 			if(headerName == "content-type") {
627 				contentType = headerContent;
628 				if(contentType.indexOf("multipart/") != -1)
629 					isMultipart = true;
630 				else if(contentType.indexOf("text/html") != -1)
631 					isHtml = true;
632 
633 				auto charsetIdx = contentType.indexOf("charset=");
634 				if(charsetIdx != -1) {
635 					string cs = contentType[charsetIdx + "charset=".length .. $];
636 					if(cs.length && cs[0] == '\"')
637 						cs = cs[1 .. $];
638 
639 					auto quoteIdx = cs.indexOf("\"");
640 					if(quoteIdx != -1)
641 						cs = cs[0 .. quoteIdx];
642 					auto semicolonIdx = cs.indexOf(";");
643 					if(semicolonIdx != -1)
644 						cs = cs[0 .. semicolonIdx];
645 
646 					cs = cs.strip();
647 					if(cs.length)
648 						charset = cs.toLower();
649 				}
650 			} else if(headerName == "from") {
651 				this.from = headerContent;
652 			} else if(headerName == "to") {
653 				this.to = headerContent;
654 			} else if(headerName == "subject") {
655 				this.subject = headerContent;
656 			} else if(headerName == "content-transfer-encoding") {
657 				contentTransferEncoding = headerContent;
658 			}
659 
660 			headers[headerName] = headerContent;
661 			headerName = null;
662 			headerContent = null;
663 		}
664 
665 		lineLoop: while(mboxLines.length) {
666 			// this can needlessly convert headers too, but that won't harm anything since they are 7 bit anyway
667 			auto line = convertToUtf8Lossy(mboxLines[0], charset);
668 			auto origline = line;
669 			line = line.stripRight;
670 
671 			final switch(state) {
672 				case ParseState.lookingForFrom:
673 					if(line.startsWith("From "))
674 						state = ParseState.readingHeaders;
675 				break;
676 				case ParseState.readingHeaders:
677 					if(line.length == 0) {
678 						commitHeader();
679 						state = ParseState.readingBody;
680 					} else {
681 						if(line[0] == ' ' || line[0] == '\t') {
682 							headerContent ~= " " ~ line.stripLeft();
683 						} else {
684 							commitHeader();
685 
686 							auto idx = line.indexOf(":");
687 							if(idx == -1)
688 								headerName = line;
689 							else {
690 								headerName = line[0 .. idx];
691 								headerContent = line[idx + 1 .. $].stripLeft();
692 							}
693 						}
694 					}
695 				break;
696 				case ParseState.readingBody:
697 					if (asmbox) {
698 						if(line.startsWith("From ")) {
699 							break lineLoop; // we're at the beginning of the next messsage
700 						}
701 						if(line.startsWith(">>From") || line.startsWith(">From")) {
702 							line = line[1 .. $];
703 						}
704 					}
705 
706 					if(isMultipart) {
707 						mimeLines ~= mboxLines[0];
708 					} else if(isHtml) {
709 						// html with no alternative and no attachments
710 						htmlMessageBody ~= line ~ "\n";
711 					} else {
712 						// plain text!
713 						// we want trailing spaces for "format=flowed", for example, so...
714 						line = origline;
715 						size_t epos = line.length;
716 						while (epos > 0) {
717 							char ch = line.ptr[epos-1];
718 							if (ch >= ' ' || ch == '\t') break;
719 							--epos;
720 						}
721 						line = line.ptr[0..epos];
722 						textMessageBody ~= line ~ "\n";
723 					}
724 				break;
725 			}
726 
727 			mboxLines = mboxLines[1 .. $];
728 		}
729 
730 		if(mimeLines.length) {
731 			auto part = new MimePart(mimeLines, contentType);
732 			deeperInTheMimeTree:
733 			switch(part.type) {
734 				case "text/html":
735 					htmlMessageBody = part.textContent;
736 				break;
737 				case "text/plain":
738 					textMessageBody = part.textContent;
739 				break;
740 				case "multipart/alternative":
741 					foreach(p; part.stuff) {
742 						if(p.type == "text/html")
743 							htmlMessageBody = p.textContent;
744 						else if(p.type == "text/plain")
745 							textMessageBody = p.textContent;
746 					}
747 				break;
748 				case "multipart/related":
749 					// the first one is the message itself
750 					// after that comes attachments that can be rendered inline
751 					if(part.stuff.length) {
752 						auto msg = part.stuff[0];
753 						foreach(thing; part.stuff[1 .. $]) {
754 							// FIXME: should this be special?
755 							attachments ~= thing.toMimeAttachment();
756 						}
757 						part = msg;
758 						goto deeperInTheMimeTree;
759 					}
760 				break;
761 				case "multipart/mixed":
762 					if(part.stuff.length) {
763 						auto msg = part.stuff[0];
764 						foreach(thing; part.stuff[1 .. $]) {
765 							attachments ~= thing.toMimeAttachment();
766 						}
767 						part = msg;
768 						goto deeperInTheMimeTree;
769 					}
770 
771 					// FIXME: the more proper way is:
772 					// check the disposition
773 					// if none, concat it to make a text message body
774 					// if inline it is prolly an image to be concated in the other body
775 					// if attachment, it is an attachment
776 				break;
777 				case "multipart/signed":
778 					// FIXME: it would be cool to actually check the signature
779 					if (part.stuff.length) {
780 						auto msg = part.stuff[0];
781 						//{ import std.stdio; writeln("hdrs: ", part.stuff[0].headers); }
782 						gpgalg = part.gpgalg;
783 						gpgproto = part.gpgproto;
784 						gpgmime = part;
785 						foreach (thing; part.stuff[1 .. $]) {
786 							attachments ~= thing.toMimeAttachment();
787 						}
788 						part = msg;
789 						goto deeperInTheMimeTree;
790 					}
791 				break;
792 				default:
793 					// FIXME: correctly handle more
794 					if(part.stuff.length) {
795 						part = part.stuff[0];
796 						goto deeperInTheMimeTree;
797 					}
798 			}
799 		} else {
800 			switch(contentTransferEncoding) {
801 				case "quoted-printable":
802 					if(textMessageBody.length)
803 						textMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(textMessageBody), charset);
804 					if(htmlMessageBody.length)
805 						htmlMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(htmlMessageBody), charset);
806 				break;
807 				case "base64":
808 					if(textMessageBody.length) {
809 						// alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here
810 						char[] mmb;
811 						mmb.reserve(textMessageBody.length);
812 						foreach (char ch; textMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch;
813 						textMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset);
814 					}
815 					if(htmlMessageBody.length) {
816 						// alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here
817 						char[] mmb;
818 						mmb.reserve(htmlMessageBody.length);
819 						foreach (char ch; htmlMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch;
820 						htmlMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset);
821 					}
822 
823 				break;
824 				default:
825 					// nothing needed
826 			}
827 		}
828 
829 		if(htmlMessageBody.length > 0 && textMessageBody.length == 0) {
830 			import arsd.htmltotext;
831 			textMessageBody = htmlToText(htmlMessageBody);
832 			textAutoConverted = true;
833 		}
834 	}
835 
836 	///
837 	@property bool hasGPGSignature () const nothrow @trusted @nogc {
838 		MimePart mime = cast(MimePart)gpgmime; // sorry
839 		if (mime is null) return false;
840 		if (mime.type != "multipart/signed") return false;
841 		if (mime.stuff.length != 2) return false;
842 		if (mime.stuff[1].type != "application/pgp-signature") return false;
843 		if (mime.stuff[0].type.length <= 5 && mime.stuff[0].type[0..5] != "text/") return false;
844 		return true;
845 	}
846 
847 	///
848 	ubyte[] extractGPGData () const nothrow @trusted {
849 		if (!hasGPGSignature) return null;
850 		MimePart mime = cast(MimePart)gpgmime; // sorry
851 		char[] res;
852 		res.reserve(mime.stuff[0].encodedContent.length); // more, actually
853 		foreach (string s; mime.stuff[0].headers[1..$]) {
854 			while (s.length && s[$-1] <= ' ') s = s[0..$-1];
855 			if (s.length == 0) return null; // wtf?! empty headers?
856 			res ~= s;
857 			res ~= "\r\n";
858 		}
859 		res ~= "\r\n";
860 		// extract content (see rfc3156)
861 		size_t pos = 0;
862 		auto ctt = mime.stuff[0].encodedContent;
863 		// last CR/LF is a part of mime signature, actually, so remove it
864 		if (ctt.length && ctt[$-1] == '\n') {
865 			ctt = ctt[0..$-1];
866 			if (ctt.length && ctt[$-1] == '\r') ctt = ctt[0..$-1];
867 		}
868 		while (pos < ctt.length) {
869 			auto epos = pos;
870 			while (epos < ctt.length && ctt.ptr[epos] != '\n') ++epos;
871 			auto xpos = epos;
872 			while (xpos > pos && ctt.ptr[xpos-1] <= ' ') --xpos; // according to rfc
873 			res ~= ctt[pos..xpos].dup;
874 			res ~= "\r\n"; // according to rfc
875 			pos = epos+1;
876 		}
877 		return cast(ubyte[])res;
878 	}
879 
880 	///
881 	immutable(ubyte)[] extractGPGSignature () const nothrow @safe @nogc {
882 		if (!hasGPGSignature) return null;
883 		return gpgmime.stuff[1].content;
884 	}
885 
886 	string[string] headers; ///
887 
888 	string subject; ///
889 
890 	string htmlMessageBody; ///
891 	string textMessageBody; ///
892 
893 	string from; ///
894 	string to; ///
895 
896 	bool textAutoConverted; ///
897 
898 	MimeAttachment[] attachments; ///
899 
900 	// gpg signature fields
901 	string gpgalg; ///
902 	string gpgproto; ///
903 	MimePart gpgmime; ///
904 
905 	string fromEmailAddress() {
906 		auto i = from.indexOf("<");
907 		if(i == -1)
908 			return from;
909 		auto e = from.indexOf(">");
910 		return from[i + 1 .. e];
911 	}
912 
913 	string toEmailAddress() {
914 		auto i = to.indexOf("<");
915 		if(i == -1)
916 			return to;
917 		auto e = to.indexOf(">");
918 		return to[i + 1 .. e];
919 	}
920 }
921 
922 struct MboxMessages {
923 	immutable(ubyte)[][] linesRemaining;
924 
925 	this(immutable(ubyte)[] data) {
926 		linesRemaining = splitLinesWithoutDecoding(data);
927 		popFront();
928 	}
929 
930 	IncomingEmailMessage currentFront;
931 
932 	IncomingEmailMessage front() {
933 		return currentFront;
934 	}
935 
936 	bool empty() {
937 		return currentFront is null;
938 	}
939 
940 	void popFront() {
941 		if(linesRemaining.length)
942 			currentFront = new IncomingEmailMessage(linesRemaining);
943 		else
944 			currentFront = null;
945 	}
946 }
947 
948 ///
949 MboxMessages processMboxData(immutable(ubyte)[] data) {
950 	return MboxMessages(data);
951 }
952 
953 immutable(ubyte)[][] splitLinesWithoutDecoding(immutable(ubyte)[] data) {
954 	immutable(ubyte)[][] ret;
955 
956 	size_t starting = 0;
957 	bool justSaw13 = false;
958 	foreach(idx, b; data) {
959 		if(b == 13)
960 			justSaw13 = true;
961 
962 		if(b == 10) {
963 			auto use = idx;
964 			if(justSaw13)
965 				use--;
966 
967 			ret ~= data[starting .. use];
968 			starting = idx + 1;
969 		}
970 
971 		if(b != 13)
972 			justSaw13 = false;
973 	}
974 
975 	if(starting < data.length)
976 		ret ~= data[starting .. $];
977 
978 	return ret;
979 }
980 
981 string decodeEncodedWord(string data) {
982 	string originalData = data;
983 
984 	auto delimiter = data.indexOf("=?");
985 	if(delimiter == -1)
986 		return data;
987 
988 	string ret;
989 
990 	while(delimiter != -1) {
991 		ret ~= data[0 .. delimiter];
992 		data = data[delimiter + 2 .. $];
993 
994 		string charset;
995 		string encoding;
996 		string encodedText;
997 
998 		// FIXME: the insane things should probably throw an
999 		// exception that keeps a copy of orignal data for use later
1000 
1001 		auto questionMark = data.indexOf("?");
1002 		if(questionMark == -1) return originalData; // not sane
1003 
1004 		charset = data[0 .. questionMark];
1005 		data = data[questionMark + 1 .. $];
1006 
1007 		questionMark = data.indexOf("?");
1008 		if(questionMark == -1) return originalData; // not sane
1009 
1010 		encoding = data[0 .. questionMark];
1011 		data = data[questionMark + 1 .. $];
1012 
1013 		questionMark = data.indexOf("?=");
1014 		if(questionMark == -1) return originalData; // not sane
1015 
1016 		encodedText = data[0 .. questionMark];
1017 		data = data[questionMark + 2 .. $];
1018 
1019 		delimiter = data.indexOf("=?");
1020 		if (delimiter == 1 && data[0] == ' ') {
1021 			// a single space between encoded words must be ignored because it is
1022 			// used to separate multiple encoded words (RFC2047 says CRLF SPACE but a most clients
1023 			// just use a space)
1024 			data = data[1..$];
1025 			delimiter = 0;
1026 		}
1027 
1028 		immutable(ubyte)[] decodedText;
1029 		if(encoding == "Q" || encoding == "q")
1030 			decodedText = decodeQuotedPrintable(encodedText);
1031 		else if(encoding == "B" || encoding == "b")
1032 			decodedText = cast(typeof(decodedText)) Base64.decode(encodedText);
1033 		else
1034 			return originalData; // wtf
1035 
1036 		ret ~= convertToUtf8Lossy(decodedText, charset);
1037 	}
1038 
1039 	ret ~= data; // keep the rest since there could be trailing stuff
1040 
1041 	return ret;
1042 }
1043 
1044 immutable(ubyte)[] decodeQuotedPrintable(string text) {
1045 	immutable(ubyte)[] ret;
1046 
1047 	int state = 0;
1048 	ubyte hexByte;
1049 	foreach(b; cast(immutable(ubyte)[]) text) {
1050 		switch(state) {
1051 			case 0:
1052 				if(b == '=') {
1053 					state++;
1054 					hexByte = 0;
1055 				} else if (b == '_') { // RFC2047 4.2.2: a _ may be used to represent a space
1056 					ret ~= ' ';
1057 				} else
1058 					ret ~= b;
1059 			break;
1060 			case 1:
1061 				if(b == '\n') {
1062 					state = 0;
1063 					continue;
1064 				}
1065 				goto case;
1066 			case 2:
1067 				int value;
1068 				if(b >= '0' && b <= '9')
1069 					value = b - '0';
1070 				else if(b >= 'A' && b <= 'F')
1071 					value = b - 'A' + 10;
1072 				else if(b >= 'a' && b <= 'f')
1073 					value = b - 'a' + 10;
1074 				if(state == 1) {
1075 					hexByte |= value << 4;
1076 					state++;
1077 				} else {
1078 					hexByte |= value;
1079 					ret ~= hexByte;
1080 					state = 0;
1081 				}
1082 			break;
1083 			default: assert(0);
1084 		}
1085 	}
1086 
1087 	return ret;
1088 }
1089 
1090 /+
1091 void main() {
1092 	import std.file;
1093 	import std.stdio;
1094 
1095 	auto data = cast(immutable(ubyte)[]) std.file.read("/home/me/test_email_data");
1096 	foreach(message; processMboxData(data)) {
1097 		writeln(message.subject);
1098 		writeln(message.textMessageBody);
1099 		writeln("**************** END MESSSAGE **************");
1100 	}
1101 }
1102 +/