1 /**
2  * Parses and handles Internet mail/news messages.
3  *
4  * License:
5  *   This Source Code Form is subject to the terms of
6  *   the Mozilla Public License, v. 2.0. If a copy of
7  *   the MPL was not distributed with this file, You
8  *   can obtain one at http://mozilla.org/MPL/2.0/.
9  *
10  * Authors:
11  *   Vladimir Panteleev <vladimir@thecybershadow.net>
12  */
13 
14 module ae.net.ietf.message;
15 
16 import std.algorithm;
17 import std.array;
18 import std.base64;
19 import std.conv;
20 import std.datetime;
21 import std.exception;
22 import std.regex;
23 import std.string;
24 import std.uri;
25 import std.utf;
26 
27 // TODO: Replace with logging?
28 debug(RFC850) import std.stdio : stderr;
29 
30 import ae.net.ietf.headers;
31 import ae.utils.array;
32 import ae.utils.iconv;
33 import ae.utils.mime;
34 import ae.utils.regex;
35 import ae.utils.text;
36 import ae.utils.time;
37 
38 import ae.net.ietf.wrap;
39 
40 alias ae.utils.text.ascii.ascii ascii; // https://d.puremagic.com/issues/show_bug.cgi?id=12156
41 alias std..string.indexOf indexOf;
42 
43 struct Xref
44 {
45 	string group;
46 	int num;
47 }
48 
49 class Rfc850Message
50 {
51 	/// The raw message (as passed in a constructor).
52 	ascii message;
53 
54 	/// The message ID, as specified at creation or in the Message-ID field.
55 	/// Includes the usual angular brackets.
56 	string id;
57 
58 	/// Cross-references - for newsgroups posts, list of groups where it was
59 	/// posted, and article number in said group.
60 	Xref[] xref;
61 
62 	/// The thread subject, with the leading "Re: " and list ID stripped.
63 	string subject;
64 
65 	/// The original message subject, as it appears in the message.
66 	string rawSubject;
67 
68 	/// The author's name, in UTF-8, stripped of quotes (no email address).
69 	string author;
70 
71 	/// The author's email address, stripped of angular brackets.
72 	string authorEmail;
73 
74 	/// Message date/time.
75 	SysTime time;
76 
77 	/// A list of Message-IDs that this post is in reply to.
78 	/// The most recent message (and direct parent) comes last.
79 	string[] references;
80 
81 	/// Whether this post is a reply.
82 	bool reply;
83 
84 	/// This message's headers.
85 	Headers headers;
86 
87 	/// The raw body of this message (or message part),
88 	/// i.e. the part of `this.message` after the headers.
89 	ascii rawContent;
90 
91 	/// The text contents of this message (UTF-8).
92 	/// "null" in case of an error.
93 	string content;
94 
95 	/// The contents of this message (depends on mimeType).
96 	ubyte[] data;
97 
98 	/// Explanation for null content.
99 	string error;
100 
101 	/// Reflow options (RFC 2646).
102 	bool flowed, delsp;
103 
104 	/// For a multipart message, contains the child parts.
105 	/// May nest more than one level.
106 	Rfc850Message[] parts;
107 
108 	/// Properties of a multipart message's part.
109 	string name, fileName, description, mimeType;
110 
111 	/// Parses a message string and creates a Rfc850Message.
112 	this(ascii message)
113 	{
114 		this.message = message;
115 		debug(RFC850) scope(failure) stderr.writeln("Failure while parsing message: ", id);
116 
117 		// Split headers from message, parse headers
118 
119 		// TODO: this breaks binary encodings, FIXME
120 		auto text = message.fastReplace("\r\n", "\n");
121 		auto headerEnd = text.indexOf("\n\n");
122 		if (headerEnd < 0) headerEnd = text.length;
123 		auto header = text[0..headerEnd];
124 		header = header.fastReplace("\n\t", " ").fastReplace("\n ", " ");
125 
126 		// TODO: Use a proper spec-conforming header parser
127 		foreach (s; header.fastSplit('\n'))
128 		{
129 			if (s == "") break;
130 
131 			auto p = s.indexOf(": ");
132 			if (p<0) continue;
133 			//assert(p>0, "Bad header line: " ~ s);
134 			headers[s[0..p]] = s[p+2..$];
135 		}
136 
137 		// Decode international characters in headers
138 
139 		string defaultEncoding = guessDefaultEncoding(headers.get("User-Agent", null));
140 
141 		foreach (string key, ref string value; headers)
142 			if (hasHighAsciiChars(value))
143 				value = decodeEncodedText(value, defaultEncoding);
144 
145 		// Decode transfer encoding
146 
147 		rawContent = text[min(headerEnd+2, $)..$];
148 
149 		if ("Content-Transfer-Encoding" in headers)
150 			try
151 				rawContent = decodeTransferEncoding(rawContent, headers["Content-Transfer-Encoding"]);
152 			catch (Exception e)
153 			{
154 				rawContent = null;
155 				error = "Error decoding " ~ headers["Content-Transfer-Encoding"] ~ " message: " ~ e.msg;
156 			}
157 
158 		// Decode message
159 
160 		data = cast(ubyte[])rawContent;
161 
162 		TokenHeader contentType, contentDisposition;
163 		if ("Content-Type" in headers)
164 			contentType = decodeTokenHeader(headers["Content-Type"]);
165 		if ("Content-Disposition" in headers)
166 			contentDisposition = decodeTokenHeader(headers["Content-Disposition"]);
167 		mimeType = toLower(contentType.value);
168 		flowed = contentType.properties.get("format", "fixed").icmp("flowed")==0;
169 		delsp = contentType.properties.get("delsp", "no").icmp("yes") == 0;
170 
171 		if (rawContent)
172 		{
173 			if (!mimeType || mimeType == "text/plain")
174 			{
175 				if ("charset" in contentType.properties)
176 					content = decodeEncodedText(rawContent, contentType.properties["charset"]);
177 				else
178 					content = decodeEncodedText(rawContent, defaultEncoding);
179 			}
180 			else
181 			if (mimeType.startsWith("multipart/") && "boundary" in contentType.properties)
182 			{
183 				string boundary = contentType.properties["boundary"];
184 				auto end = rawContent.indexOf("--" ~ boundary ~ "--");
185 				if (end < 0)
186 					end = rawContent.length;
187 				rawContent = rawContent[0..end];
188 
189 				auto rawParts = rawContent.split("--" ~ boundary ~ "\n");
190 				foreach (rawPart; rawParts[1..$])
191 				{
192 					auto part = new Rfc850Message(rawPart);
193 					if (part.content && !content)
194 						content = part.content;
195 					parts ~= part;
196 				}
197 
198 				if (!content)
199 				{
200 					if (rawParts.length && rawParts[0].asciiStrip().length)
201 						content = rawParts[0]; // default content to multipart stub
202 					else
203 						error = "Couldn't find text part in this " ~ mimeType ~ " message";
204 				}
205 			}
206 			else
207 				error = "Don't know how parse " ~ mimeType ~ " message";
208 		}
209 
210 		// Strip PGP signature away to a separate "attachment"
211 
212 		enum PGP_START = "-----BEGIN PGP SIGNED MESSAGE-----\n";
213 		enum PGP_DELIM = "\n-----BEGIN PGP SIGNATURE-----\n";
214 		enum PGP_END   = "\n-----END PGP SIGNATURE-----";
215 		if (content.startsWith(PGP_START) &&
216 		    content.contains(PGP_DELIM) &&
217 		    content.asciiStrip().endsWith(PGP_END))
218 		{
219 			// Don't attempt to create meaningful signature files... just get the clutter out of the way
220 			content = content.asciiStrip();
221 			auto p = content.indexOf(PGP_DELIM);
222 			auto part = new Rfc850Message(content[p+PGP_DELIM.length..$-PGP_END.length]);
223 			content = content[PGP_START.length..p];
224 			p = content.indexOf("\n\n");
225 			if (p >= 0)
226 				content = content[p+2..$];
227 			part.fileName = "pgp.sig";
228 			parts ~= part;
229 		}
230 
231 		// Decode UU-encoded attachments
232 
233 		if (content.contains("\nbegin "))
234 		{
235 			auto r = regex(`^begin [0-7]+ \S+$`);
236 			auto lines = content.split("\n");
237 			size_t start;
238 			bool started;
239 			string fn;
240 
241 			for (size_t i=0; i<lines.length; i++)
242 				if (!started && !match(lines[i], r).empty)
243 				{
244 					start = i;
245 					fn = lines[i].split(" ")[2];
246 					started = true;
247 				}
248 				else
249 				if (started && lines[i] == "end" && lines[i-1]=="`")
250 				{
251 					started = false;
252 					try
253 					{
254 						auto data = uudecode(lines[start+1..i]);
255 
256 						auto part = new Rfc850Message();
257 						part.fileName = fn;
258 						part.mimeType = guessMime(fn);
259 						part.data = data;
260 						parts ~= part;
261 
262 						lines = lines[0..start] ~ lines[i+1..$];
263 						i = start-1;
264 					}
265 					catch (Exception e)
266 						debug(RFC850) stderr.writeln(e);
267 				}
268 
269 			content = lines.join("\n");
270 		}
271 
272 		// Parse message-part properties
273 
274 		name = contentType.properties.get("name", string.init);
275 		fileName = contentDisposition.properties.get("filename", string.init);
276 		description = headers.get("Content-Description", string.init);
277 		if (name == fileName)
278 			name = null;
279 
280 		// Decode references
281 
282 		if ("References" in headers)
283 		{
284 			reply = true;
285 			auto refs = asciiStrip(headers["References"]);
286 			while (refs.startsWith("<"))
287 			{
288 				auto p = refs.indexOf(">");
289 				if (p < 0)
290 					break;
291 				references ~= refs[0..p+1];
292 				refs = asciiStrip(refs[p+1..$]);
293 			}
294 		}
295 		else
296 		if ("In-Reply-To" in headers)
297 			references = [headers["In-Reply-To"]];
298 
299 		// Decode subject
300 
301 		subject = rawSubject = "Subject" in headers ? decodeRfc1522(headers["Subject"]) : null;
302 		if (subject.startsWith("Re: "))
303 		{
304 			subject = subject[4..$];
305 			reply = true;
306 		}
307 
308 		// Decode author
309 
310 		static string[2] decodeAuthor(string header)
311 		{
312 			string author, authorEmail;
313 			author = authorEmail = header;
314 			if ((author.indexOf('@') < 0 && author.indexOf(" at ") >= 0)
315 			 || (author.indexOf("<") < 0 && author.indexOf(">") < 0 && author.indexOf(" (") > 0 && author.endsWith(")")))
316 			{
317 				// Mailing list archive format
318 				assert(author == authorEmail);
319 				if (author.indexOf(" (") > 0 && author.endsWith(")"))
320 				{
321 					authorEmail = author[0 .. author.lastIndexOf(" (")].replace(" at ", "@");
322 					author      = author[author.lastIndexOf(" (")+2 .. $-1].decodeRfc1522();
323 				}
324 				else
325 				{
326 					authorEmail = author.replace(" at ", "@");
327 					author = author[0 .. author.lastIndexOf(" at ")];
328 				}
329 			}
330 			if (author.indexOf('<')>=0 && author.endsWith('>'))
331 			{
332 				auto p = author.indexOf('<');
333 				authorEmail = author[p+1..$-1];
334 				author = decodeRfc1522(asciiStrip(author[0..p]));
335 			}
336 
337 			if (author.length>2 && author[0]=='"' && author[$-1]=='"')
338 				author = decodeRfc1522(asciiStrip(author[1..$-1]));
339 			if ((author == authorEmail || author == "") && authorEmail.indexOf("@") > 0)
340 				author = authorEmail[0..authorEmail.indexOf("@")];
341 			return [author, authorEmail];
342 		}
343 
344 		list(author, authorEmail) = decodeAuthor("From" in headers ? decodeRfc1522(headers["From"]) : null);
345 
346 		if (headers.get("List-Post", null) == "<mailto:" ~ authorEmail ~ ">" && "Reply-To" in headers)
347 			list(null, authorEmail) = decodeAuthor(decodeRfc1522(headers["Reply-To"].findSplit(", ")[0]));
348 
349 		// Decode cross-references
350 
351 		if ("Xref" in headers)
352 		{
353 			auto xrefStrings = split(headers["Xref"], " ")[1..$];
354 			foreach (str; xrefStrings)
355 			{
356 				auto segs = str.split(":");
357 				xref ~= Xref(segs[0], to!int(segs[1]));
358 			}
359 		}
360 		else
361 		if (headers.get("Sender", null).canFind("-bounces@") && !headers["Sender"].representation.any!(c => c.among('<', '>', '"')))
362 			xref = [Xref(headers["Sender"].findSplit("-bounces@")[0])];
363 		else
364 		if ("List-Unsubscribe" in headers)
365 			xref = headers["List-Unsubscribe"].split(", ").filter!(s => s.canFind("/options/")).map!(s => Xref(s.split("/")[$-1].stripRight('>'))).array();
366 		else
367 		if ("List-ID" in headers)
368 		{
369 			auto listID = headers["List-ID"];
370 			auto parts = listID.findSplit(" <");
371 			if (parts[1].length)
372 				listID = parts[2].findSplit(".")[0];
373 			else
374 				listID = parts[0].replace(`"`, ``);
375 			xref = [Xref(listID)];
376 		}
377 
378 		// Decode message ID
379 
380 		if ("Message-ID" in headers && !id)
381 			id = headers["Message-ID"];
382 
383 		// Decode post time
384 
385 		time = Clock.currTime; // default value
386 
387 		if (auto pdate = "X-Original-Date" in headers)
388 			time = parseDate(*pdate, time);
389 		else
390 		if (auto pdate = "NNTP-Posting-Date" in headers)
391 			time = parseDate(*pdate, time);
392 		else
393 		if (auto pdate = "Date" in headers)
394 			time = parseDate(*pdate, time);
395 	}
396 
397 	private this() {} // for attachments and templates
398 
399 	/// Create a template Rfc850Message for a new posting to the specified groups.
400 	static Rfc850Message newPostTemplate(string groups)
401 	{
402 		auto post = new Rfc850Message();
403 		foreach (group; groups.split(","))
404 			post.xref ~= Xref(group);
405 		return post;
406 	}
407 
408 	private static SysTime parseDate(string str, SysTime defaultTime)
409 	{
410 		str = str.strip();
411 		str = str.replace(re!`([+\-]\d\d\d\d) \(.*\)$`, "$1");
412 		try
413 			return parseTime!(TimeFormats.RFC850)(str);
414 		catch (Exception e)
415 		try
416 			return parseTime!(`D, j M Y H:i:s O`)(str);
417 		catch (Exception e)
418 		try
419 			return parseTime!(`D, j M Y H:i:s e`)(str);
420 		catch (Exception e)
421 		try
422 			return parseTime!(`D, j M Y H:i O`)(str);
423 		catch (Exception e)
424 		try
425 			return parseTime!(`D, j M Y H:i e`)(str);
426 		catch (Exception e)
427 		{
428 			// fall-back to default (class creation time)
429 			// TODO: better behavior?
430 			return defaultTime;
431 		}
432 	}
433 
434 	@property WrapFormat wrapFormat()
435 	{
436 		return flowed ? delsp ? WrapFormat.flowedDelSp : WrapFormat.flowed : WrapFormat.heuristics;
437 	}
438 
439 	/// Create a template Rfc850Message for a reply to this message.
440 	Rfc850Message replyTemplate()
441 	{
442 		auto post = new Rfc850Message();
443 		post.reply = true;
444 		post.xref = this.xref;
445 		post.references = this.references ~ this.id;
446 		post.subject = this.rawSubject;
447 		if (!post.subject.startsWith("Re:"))
448 			post.subject = "Re: " ~ post.subject;
449 
450 		auto paragraphs = unwrapText(this.content, this.wrapFormat);
451 		foreach (i, ref paragraph; paragraphs)
452 			if (paragraph.quotePrefix.length)
453 				paragraph.quotePrefix = ">" ~ paragraph.quotePrefix;
454 			else
455 			{
456 				if (paragraph.text == "-- " || paragraph.text == "_______________________________________________")
457 				{
458 					paragraphs = paragraphs[0..i];
459 					break;
460 				}
461 				paragraph.quotePrefix = paragraph.text.length ? "> " : ">";
462 			}
463 		while (paragraphs.length && paragraphs[$-1].text.length==0)
464 			paragraphs = paragraphs[0..$-1];
465 
466 		auto replyTime = time;
467 		replyTime.timezone = UTC();
468 		post.content =
469 			"On " ~ replyTime.formatTime!`l, j F Y \a\t H:i:s e`() ~ ", " ~ this.author ~ " wrote:\n" ~
470 			wrapText(paragraphs) ~
471 			"\n\n";
472 		post.flowed = true;
473 		post.delsp = false;
474 
475 		return post;
476 	}
477 
478 	/// Set the message text.
479 	/// Rewraps as necessary.
480 	void setText(string text)
481 	{
482 		this.content = wrapText(unwrapText(text, WrapFormat.input));
483 		this.flowed = true;
484 		this.delsp = false;
485 	}
486 
487 	/// Write this Message instance's fields to their appropriate headers.
488 	void compileHeaders()
489 	{
490 		assert(id);
491 
492 		headers["Message-ID"] = id;
493 		headers["From"] = format(`%s <%s>`, author, authorEmail);
494 		headers["Subject"] = subject;
495 		headers["Newsgroups"] = xref.map!(x => x.group)().join(",");
496 		headers["Content-Type"] = format("text/plain; charset=utf-8; format=%s; delsp=%s", flowed ? "flowed" : "fixed", delsp ? "yes" : "no");
497 		headers["Content-Transfer-Encoding"] = "8bit";
498 		if (references.length)
499 		{
500 			headers["References"] = references.join(" ");
501 			headers["In-Reply-To"] = references[$-1];
502 		}
503 		if (time == SysTime.init)
504 			time = Clock.currTime();
505 		headers["Date"] = time.formatTime!(TimeFormats.RFC2822);
506 		headers["User-Agent"] = "ae.net.ietf.message";
507 	}
508 
509 	/// Construct the headers and message fields.
510 	void compile()
511 	{
512 		compileHeaders();
513 
514 		string[] lines;
515 		foreach (name, value; headers)
516 		{
517 			if (value.hasHighAsciiChars())
518 				value = value.encodeRfc1522();
519 			auto line = name ~ ": " ~ value;
520 			auto lineStart = name.length + 2;
521 
522 			foreach (c; line)
523 				enforce(c >= 32, "Control characters in header: %(%s%)".format([line]));
524 
525 			while (line.length >= 80)
526 			{
527 				auto p = line[0..80].lastIndexOf(' ');
528 				if (p < lineStart)
529 				{
530 					p = 80 + line[80..$].indexOf(' ');
531 					if (p < 80)
532 						break;
533 				}
534 				lines ~= line[0..p];
535 				line = line[p..$];
536 				lineStart = 1;
537 			}
538 			lines ~= line;
539 		}
540 
541 		message =
542 			lines.join("\r\n") ~
543 			"\r\n\r\n" ~
544 			splitAsciiLines(content).join("\r\n");
545 	}
546 
547 	/// Get the Message-ID that this message is in reply to.
548 	@property string parentID()
549 	{
550 		return references.length ? references[$-1] : null;
551 	}
552 
553 	/// Return the oldest known ancestor of this post, possibly
554 	/// this post's ID if it is the first one in the thread.
555 	/// May not be the thread ID - some UAs/services
556 	/// cut off or strip the "References" header.
557 	@property string firstAncestorID()
558 	{
559 		return references.length ? references[0] : id;
560 	}
561 }
562 
563 unittest
564 {
565 	auto post = new Rfc850Message("From: msonke at example.org (=?ISO-8859-1?Q?S=F6nke_Martin?=)\n\nText");
566 	assert(post.author == "Sönke Martin");
567 	assert(post.authorEmail == "msonke@example.org");
568 
569 	post = new Rfc850Message("Date: Tue, 06 Sep 2011 14:52 -0700\n\nText");
570 	assert(post.time.year == 2011);
571 
572 	post = new Rfc850Message("List-ID: phobos\nSubject: [phobos] Subject\n\nText");
573 	assert(post.xref == [Xref("phobos")]);
574 
575 	post = new Rfc850Message("Sender: dmd-beta-bounces@puremagic.com\n\nText");
576 	assert(post.xref == [Xref("dmd-beta")]);
577 }
578 
579 private:
580 
581 /// Decode headers with international characters in them.
582 string decodeRfc1522(string str)
583 {
584 	auto words = str.split(" ");
585 	bool[] encoded = new bool[words.length];
586 
587 	foreach (wordIndex, ref word; words)
588 		if (word.length > 6 && word.startsWith("=?") && word.endsWith("?="))
589 		{
590 			auto parts = split(word[2..$-2], "?");
591 			if (parts.length != 3)
592 				continue;
593 			auto charset = parts[0];
594 			auto encoding = parts[1];
595 			auto text = parts[2];
596 
597 			switch (toUpper(encoding))
598 			{
599 			case "Q":
600 				text = decodeQuotedPrintable(text, true);
601 				break;
602 			case "B":
603 				text = cast(ascii)Base64.decode(text);
604 				break;
605 			default:
606 				continue /*foreach*/;
607 			}
608 
609 			word = decodeEncodedText(text, charset);
610 			encoded[wordIndex] = true;
611 		}
612 
613 	string result;
614 	foreach (wordIndex, word; words)
615 	{
616 		if (wordIndex > 0 && !(encoded[wordIndex-1] && encoded[wordIndex]))
617 			result ~= ' ';
618 		result ~= word;
619 	}
620 
621 	try
622 	{
623 		import std.utf;
624 		validate(result);
625 	}
626 	catch (Exception e)
627 		result = toUtf8(cast(ascii)result, "ISO-8859-1", true);
628 
629 	return result;
630 }
631 
632 /// Encodes an UTF-8 string to be used in headers.
633 string encodeRfc1522(string str)
634 {
635 	if (!str.hasHighAsciiChars())
636 		return str;
637 
638 	string[] words;
639 	bool wasIntl = false;
640 	foreach (word; str.split(" "))
641 	{
642 		bool isIntl = word.hasHighAsciiChars();
643 		if (wasIntl && isIntl)
644 			words[$-1] ~= " " ~ word;
645 		else
646 			words ~= word;
647 		wasIntl = isIntl;
648 	}
649 
650 	enum CHUNK_LENGTH_THRESHOLD = 20;
651 
652 	foreach (ref word; words)
653 	{
654 		if (!word.hasHighAsciiChars())
655 			continue;
656 		string[] output;
657 		string s = word;
658 		while (s.length)
659 		{
660 			size_t ptr = 0;
661 			while (ptr < s.length && ptr < CHUNK_LENGTH_THRESHOLD)
662 				ptr += stride(s, ptr);
663 			output ~= encodeRfc1522Chunk(s[0..ptr]);
664 			s = s[ptr..$];
665 		}
666 		word = output.join(" ");
667 	}
668 	return words.join(" ");
669 }
670 
671 string encodeRfc1522Chunk(string str) pure
672 {
673 	auto result = "=?UTF-8?B?" ~ Base64.encode(cast(ubyte[])str) ~ "?=";
674 	return result;
675 }
676 
677 unittest
678 {
679 	auto text = "В лесу родилась ёлочка";
680 	assert(decodeRfc1522(encodeRfc1522(text)) == text);
681 
682 	// Make sure email address isn't mangled
683 	assert(encodeRfc1522("Sönke Martin <msonke@example.org>").endsWith(" <msonke@example.org>"));
684 }
685 
686 string decodeQuotedPrintable(string s, bool inHeaders)
687 {
688 	auto r = appender!string();
689 	for (int i=0; i<s.length; )
690 		if (s[i]=='=')
691 		{
692 			if (i+1 >= s.length || s[i+1] == '\n')
693 				i+=2; // escape newline
694 			else
695 				r.put(cast(char)to!ubyte(s[i+1..i+3], 16)), i+=3;
696 		}
697 		else
698 		if (s[i]=='_' && inHeaders)
699 			r.put(' '), i++;
700 		else
701 			r.put(s[i++]);
702 	return r.data;
703 }
704 
705 string guessDefaultEncoding(string userAgent)
706 {
707 	switch (userAgent)
708 	{
709 		case "DFeed":
710 			// Early DFeed versions did not specify the encoding
711 			return "utf8";
712 		default:
713 			return "windows1252";
714 	}
715 }
716 
717 // http://d.puremagic.com/issues/show_bug.cgi?id=7016
718 static import ae.sys.cmd;
719 
720 string decodeEncodedText(ascii s, string textEncoding)
721 {
722 	try
723 		return toUtf8(s, textEncoding, false);
724 	catch (Exception e)
725 	{
726 		debug(RFC850) stderr.writefln("iconv fallback for %s (%s)", textEncoding, e.msg);
727 		try
728 		{
729 			import ae.sys.cmd;
730 			return iconv(s, textEncoding);
731 		}
732 		catch (Exception e)
733 		{
734 			debug(RFC850) stderr.writefln("ISO-8859-1 fallback (%s)", e.msg);
735 			return toUtf8(s, "ISO-8859-1", false);
736 		}
737 	}
738 }
739 
740 string decodeTransferEncoding(string data, string encoding)
741 {
742 	switch (toLower(encoding))
743 	{
744 	case "7bit":
745 		return data;
746 	case "quoted-printable":
747 		return decodeQuotedPrintable(data, false);
748 	case "base64":
749 		//return cast(string)Base64.decode(data.replace("\n", ""));
750 	{
751 		auto s = data.fastReplace("\n", "");
752 		scope(failure) debug(RFC850) stderr.writeln(s);
753 		return cast(string)Base64.decode(s);
754 	}
755 	default:
756 		return data;
757 	}
758 }
759 
760 ubyte[] uudecode(string[] lines)
761 {
762 	// TODO: optimize
763 	//auto data = appender!(ubyte[]);  // OPTLINK says no
764 	ubyte[] data;
765 	foreach (line; lines)
766 	{
767 		if (!line.length || line.startsWith("`"))
768 			continue;
769 		ubyte len = to!ubyte(line[0] - 32);
770 		line = line[1..$];
771 		while (line.length % 4)
772 			line ~= 32;
773 		ubyte[] lineData;
774 		while (line.length)
775 		{
776 			uint v = 0;
777 			foreach (c; line[0..4])
778 				if (c == '`') // same as space
779 					v <<= 6;
780 				else
781 				{
782 					enforce(c >= 32 && c < 96, [c]);
783 					v = (v<<6) | (c - 32);
784 				}
785 
786 			auto a = cast(ubyte[])((&v)[0..1]);
787 			lineData ~= a[2];
788 			lineData ~= a[1];
789 			lineData ~= a[0];
790 
791 			line = line[4..$];
792 		}
793 		while (len > lineData.length)
794 			lineData ~= 0;
795 		data ~= lineData[0..len];
796 	}
797 	return data;
798 }