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