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