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