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