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