1 /++
2 	Create MIME emails with things like HTML, attachments, and send with convenience wrappers around std.net.curl's SMTP function, or read email from an mbox file.
3 
4 	For preparing and sending outgoing email, see [EmailMessage]. For processing incoming email or opening .eml files, mbox files, etc., see [IncomingEmailMessage].
5 
6 	History:
7 		Originally released as open source on August 11, 2012. The last-modified date of its predecessor file was January 2011.
8 
9 		Many of the public string members were overhauled on May 13, 2024. Compatibility methods are provided so your code will hopefully still work, but this also results in some stricter adherence to email encoding rules, so you should retest if you update after then.
10 
11 	Future_Directions:
12 		I might merge `IncomingEmailMessage` and `EmailMessage` some day, it seems silly to have them completely separate like this.
13 +/
14 module arsd.email;
15 
16 import std.net.curl;
17 
18 import std.base64;
19 import std.string;
20 import std.range;
21 import std.utf;
22 import std.array;
23 import std.algorithm.iteration;
24 
25 import arsd.characterencodings;
26 
27 public import arsd.core : FilePath;
28 
29 //         import std.uuid;
30 // smtpMessageBoundary = randomUUID().toString();
31 
32 // SEE ALSO: std.net.curl.SMTP
33 
34 /++
35 	Credentials for a SMTP relay, as passed to [std.net.curl.SMTP].
36 +/
37 struct RelayInfo {
38 	/++
39 		Should be as a url, such as `smtp://example.com` or `smtps://example.com`. You normally want smtp:// - even if you want TLS encryption, smtp uses STARTTLS so it gets that. smtps will only work if the server supports tls from the start, which is not always the case.
40 	+/
41 	string server;
42 	string username; ///
43 	string password; ///
44 }
45 
46 /++
47 	Representation of an email attachment.
48 +/
49 struct MimeAttachment {
50 	string type; /// e.g. `text/plain`
51 	string filename; ///
52 	const(ubyte)[] content; ///
53 	string id; ///
54 }
55 
56 ///
57 enum ToType {
58 	to,
59 	cc,
60 	bcc
61 }
62 
63 /++
64 	Structured representation of email users, including the name and email address as separate components.
65 
66 	`EmailRecipient` represents a single user, and `RecipientList` represents multiple users. A "recipient" may also be a from or reply to address.
67 
68 
69 	`RecipientList` is a wrapper over `EmailRecipient[]` that provides overloads that take string arguments, for compatibility for users of previous versions of the `arsd.email` api. It should generally work as you expect if you just pretend it is a normal array though (and if it doesn't, you can get the internal array via the `recipients` member.)
70 
71 	History:
72 		Added May 13, 2024 (dub v12.0) to replace the old plain, public strings and arrays of strings.
73 +/
74 struct EmailRecipient {
75 	/++
76 		The email user's name. It should not have quotes or any other encoding.
77 
78 		For example, `Adam D. Ruppe`.
79 	+/
80 	string name;
81 	/++
82 		The email address. It should not have brackets or any other encoding.
83 
84 		For example, `destructionator@gmail.com`.
85 	+/
86 	string address;
87 
88 	/++
89 		Returns a string representing this email address, in a format suitable for inclusion in a message about to be saved or transmitted.
90 
91 		In many cases, this is easy to read for people too, but not in all cases.
92 	+/
93 	string toProtocolString(string linesep = "\r\n") {
94 		if(name.length)
95 			return "\"" ~ encodeEmailHeaderContentForTransmit(name, linesep) ~ "\" <" ~ address ~ ">";
96 		return address;
97 	}
98 
99 	/++
100 		Returns a string representing this email address, in a format suitable for being read by people. This is not necessarily reversible.
101 	+/
102 	string toReadableString() {
103 		if(name.length)
104 			return "\"" ~ name ~ "\" <" ~ address ~ ">";
105 		return address;
106 	}
107 
108 	/++
109 		Construct an `EmailRecipient` either from a name and address (preferred!) or from an encoded string as found in an email header.
110 
111 		Examples:
112 
113 		`EmailRecipient("Adam D. Ruppe", "destructionator@gmail.com")` or `EmailRecipient(`"Adam D. Ruppe" <destructionator@gmail.com>`);
114 	+/
115 	this(string name, string address) {
116 		this.name = name;
117 		this.address = address;
118 	}
119 
120 	/// ditto
121 	this(string str) {
122 		this = str;
123 	}
124 
125 	/++
126 		Provided for compatibility for users of old versions of `arsd.email` - does implicit conversion from `EmailRecipient` to a plain string (in protocol format), as was present in previous versions of the api.
127 	+/
128 	alias toProtocolString this;
129 
130 	/// ditto
131 	void opAssign(string str) {
132 		auto idx = str.indexOf("<");
133 		if(idx == -1) {
134 			name = null;
135 			address = str;
136 		} else {
137 			name = decodeEncodedWord(unquote(str[0 .. idx].strip));
138 			address = str[idx + 1 .. $ - 1];
139 		}
140 
141 	}
142 }
143 
144 /// ditto
145 struct RecipientList {
146 	EmailRecipient[] recipients;
147 
148 	void opAssign(string[] strings) {
149 		recipients = null;
150 		foreach(s; strings)
151 			recipients ~= EmailRecipient(s);
152 	}
153 	void opAssign(EmailRecipient[] recpts) {
154 		this.recipients = recpts;
155 	}
156 
157 	void opOpAssign(string op : "~")(EmailRecipient r) {
158 		recipients ~= r;
159 	}
160 	void opOpAssign(string op : "~")(string s) {
161 		recipients ~= EmailRecipient(s);
162 	}
163 	int opApply(int delegate(size_t idx, EmailRecipient rcp) dg) {
164 		foreach(idx, item; recipients)
165 			if(auto result = dg(idx, item))
166 				return result;
167 		return 0;
168 	}
169 	int opApply(int delegate(EmailRecipient rcp) dg) {
170 		foreach(item; recipients)
171 			if(auto result = dg(item))
172 				return result;
173 		return 0;
174 	}
175 
176 	size_t length() {
177 		return recipients.length;
178 	}
179 
180 	string toProtocolString(string linesep = "\r\n") {
181 		string ret;
182 		int lineLengthCounter = 6; // first line probably includes "Bcc: " or similar and if not, we're allowed to be a wee bit conservative anyway
183 		foreach(idx, item; recipients) {
184 			if(idx) {
185 				ret ~= ", ";
186 				lineLengthCounter += 2;
187 			}
188 
189 			auto itemString = item.toProtocolString(linesep);
190 
191 			if(lineLengthCounter > 0 && lineLengthCounter + itemString.length > 78) {
192 				ret ~= linesep;
193 				ret ~= " ";
194 				lineLengthCounter = 1;
195 			}
196 
197 			ret ~= itemString;
198 			lineLengthCounter += itemString.length;
199 		}
200 		return ret;
201 	}
202 
203 	EmailRecipient front() { return recipients[0]; }
204 	void popFront() { recipients = recipients[1 .. $]; }
205 	bool empty() { return recipients.length == 0; }
206 	RecipientList save() { return this; }
207 }
208 
209 private string unquote(string s) {
210 	if(s.length == 0)
211 		return s;
212 	if(s[0] != '"')
213 		return s;
214 	s = s[1 .. $-1]; // strip the quotes
215 	// FIXME: possible to have \" escapes in there too
216 	return s;
217 }
218 
219 private struct CaseInsensitiveString {
220 	string actual;
221 
222 	size_t toHash() const {
223 		string l = actual.toLower;
224 		return typeid(string).getHash(&l);
225 	}
226 	bool opEquals(ref const typeof(this) s) const {
227 		return icmp(s.actual, this.actual) == 0;
228 	}
229 	bool opEquals(string s) const {
230 		return icmp(s, this.actual) == 0;
231 	}
232 
233 	alias actual this;
234 }
235 
236 /++
237 	A type that acts similarly to a `string[string]` to hold email headers in a case-insensitive way.
238 +/
239 struct HeadersHash {
240 	string[CaseInsensitiveString] hash;
241 
242 	string opIndex(string key) const {
243 		return hash[CaseInsensitiveString(key)];
244 	}
245 	string opIndexAssign(string value, string key) {
246 		return hash[CaseInsensitiveString(key)] = value;
247 	}
248 	inout(string)* opBinaryRight(string op : "in")(string key) inout {
249 		return CaseInsensitiveString(key) in hash;
250 	}
251 	alias hash this;
252 }
253 
254 unittest {
255 	HeadersHash h;
256 	h["From"] = "test";
257 	h["from"] = "other";
258 	foreach(k, v; h) {
259 		assert(k == "From");
260 		assert(v == "other");
261 	}
262 
263 	assert("from" in h);
264 	assert("From" in h);
265 	assert(h["from"] == "other");
266 
267 	const(HeadersHash) ch = HeadersHash([CaseInsensitiveString("From") : "test"]);
268 	assert(ch["from"] == "test");
269 	assert("From" in ch);
270 }
271 
272 /++
273 	For OUTGOING email
274 
275 
276 	To use:
277 
278 	---
279 	auto message = new EmailMessage();
280 	message.to ~= "someuser@example.com";
281 	message.from = "youremail@example.com";
282 	message.subject = "My Subject";
283 	message.setTextBody("hi there");
284 	//message.toString(); // get string to send externally
285 	message.send(); // send via some relay
286 	// may also set replyTo, etc
287 	---
288 
289 	History:
290 		This class got an API overhaul on May 13, 2024. Some undocumented members were removed, and some public members got changed (albeit in a mostly compatible way).
291 +/
292 class EmailMessage {
293 	/++
294 		Adds a custom header to the message. The header name should not include a colon and must not duplicate a header set elsewhere in the class; for example, do not use this to set `To`, and instead use the [to] field.
295 
296 		Setting the same header multiple times will overwrite the old value. It will not set duplicate headers and does not retain the specific order of which you added headers.
297 
298 		History:
299 			Prior to May 13, 2024, this assumed the value was previously encoded. This worked most the time but also left open the possibility of incorrectly encoded values, including the possibility of injecting inappropriate headers.
300 
301 			Since May 13, 2024, it now encodes the header content internally. You should NOT pass pre-encoded values to this function anymore.
302 
303 			It also would previously allow you to set repeated headers like `Subject` or `To`. These now throw exceptions.
304 
305 			It previously also allowed duplicate headers. Adding the same thing twice will now silently overwrite the old value instead.
306 	+/
307 	void setHeader(string name, string value, string file = __FILE__, size_t line = __LINE__) {
308 		import arsd.core;
309 		if(name.length == 0)
310 			throw new InvalidArgumentsException("name", "name cannot be an empty string", LimitedVariant(name), "setHeader", file, line);
311 		if(name.indexOf(":") != -1)
312 			throw new InvalidArgumentsException("name", "do not put a colon in the header name", LimitedVariant(name), "setHeader", file, line);
313 		if(!headerSettableThroughAA(name))
314 			throw new InvalidArgumentsException("name", "use named methods/properties for this header instead of setHeader", LimitedVariant(name), "setHeader", file, line);
315 
316 		headers_[name] = value;
317 	}
318 
319 	protected bool headerSettableThroughAA(string name) {
320 		switch(name.toLower) {
321 			case "to", "cc", "bcc":
322 			case "from", "reply-to", "in-reply-to":
323 			case "subject":
324 			case "content-type", "content-transfer-encoding", "mime-version":
325 			case "received", "return-path": // set by the MTA
326 				return false;
327 			default:
328 				return true;
329 		}
330 	}
331 
332 	/++
333 		Recipients of the message. You can use operator `~=` to add people to this list, or you can also use [addRecipient] to achieve the same result.
334 
335 		---
336 		message.to ~= EmailRecipient("Adam D. Ruppe", "destructionator@gmail.com");
337 		message.cc ~= EmailRecipient("John Doe", "john.doe@example.com");
338 		// or, same result as the above two lines:
339 		message.addRecipient("Adam D. Ruppe", "destructionator@gmail.com");
340 		message.addRecipient("John Doe", "john.doe@example.com", ToType.cc);
341 
342 		// or, the old style code that still works, but is not recommended, since
343 		// it is harder to encode properly for anything except pure ascii names:
344 		message.to ~= `"Adam D. Ruppe" <destructionator@gmail.com>`
345 		---
346 
347 		History:
348 			On May 13, 2024, the types of these changed. Before, they were `public string[]`; plain string arrays. This put the burden of proper encoding on the user, increasing the probability of bugs. Now, they are [RecipientList]s - internally, an array of `EmailRecipient` objects, but with a wrapper to provide compatibility with the old string-based api.
349 	+/
350 	RecipientList to;
351 	/// ditto
352 	RecipientList cc;
353 	/// ditto
354 	RecipientList bcc;
355 
356 	/++
357 		Represents the `From:` and `Reply-To:` header values in the email.
358 
359 
360 		Note that the `from` member is the "From:" header, which is not necessarily the same as the "envelope from". The "envelope from" is set by the email server usually based on your login credentials. The email server may or may not require these to match.
361 
362 		History:
363 			On May 13, 2024, the types of these changed from plain `string` to [EmailRecipient], to try to get the encoding easier to use correctly. `EmailRecipient` offers overloads for string parameters for compatibility, so your code should not need changing, however if you use non-ascii characters in your names, you should retest to ensure it still works correctly.
364 	+/
365 	EmailRecipient from;
366 	/// ditto
367 	EmailRecipient replyTo;
368 	/// The `Subject:` header value in the email.
369 	string subject;
370 	/// The `In-Reply-to:` header value. This should be set to the same value as the `Message-ID` header from the message you're replying to.
371 	string inReplyTo;
372 
373 	private string textBody_;
374 	private string htmlBody_;
375 
376 	private HeadersHash headers_;
377 
378 	/++
379 		Gets and sets the current text body.
380 
381 		History:
382 			Prior to May 13, 2024, this was a simple `public string` member, but still had a [setTextBody] method too. It now is a public property that works through that method.
383 	+/
384 	string textBody() {
385 		return textBody_;
386 	}
387 	/// ditto
388 	void textBody(string text) {
389 		setTextBody(text);
390 	}
391 	/++
392 		Gets the current html body, if any.
393 
394 		There is no setter for this property, use [setHtmlBody] instead.
395 
396 		History:
397 			Prior to May 13, 2024, this was a simple `public string` member. This let you easily get the `EmailMessage` object into an inconsistent state.
398 	+/
399 	string htmlBody() {
400 		return htmlBody_;
401 	}
402 
403 	/++
404 		If you use the send method with an SMTP server, you don't want to change this.
405 		While RFC 2045 mandates CRLF as a lineseperator, there are some edge-cases where this won't work.
406 		When passing the E-Mail string to a unix program which handles communication with the SMTP server, some (i.e. qmail)
407 		expect the system lineseperator (LF) instead.
408 		Notably, the google mail REST API will choke on CRLF lineseps and produce strange emails (as of 2024).
409 
410 		Do not change this after calling other methods, since it might break presaved values.
411 	+/
412 	string linesep = "\r\n";
413 
414 	/++
415 		History:
416 			Added May 13, 2024
417 	+/
418 	this(string linesep = "\r\n") {
419 		this.linesep = linesep;
420 	}
421 
422 	private bool isMime = false;
423 	private bool isHtml = false;
424 
425 	///
426 	void addRecipient(string name, string email, ToType how = ToType.to) {
427 		addRecipient(`"`~name~`" <`~email~`>`, how);
428 	}
429 
430 	///
431 	void addRecipient(string who, ToType how = ToType.to) {
432 		final switch(how) {
433 			case ToType.to:
434 				to ~= who;
435 			break;
436 			case ToType.cc:
437 				cc ~= who;
438 			break;
439 			case ToType.bcc:
440 				bcc ~= who;
441 			break;
442 		}
443 	}
444 
445 	/++
446 		Sets the plain text body of the email. You can also separately call [setHtmlBody] to set a HTML body.
447 	+/
448 	void setTextBody(string text) {
449 		textBody_ = text.strip;
450 	}
451 	/++
452 		Sets the HTML body to the mail, which can support rich text, inline images (see [addInlineImage]), etc.
453 
454 		Automatically sets a text fallback if you haven't already, unless you pass `false` as the `addFallback` template value. Adding the fallback requires [arsd.htmltotext].
455 
456 		History:
457 			The `addFallback` parameter was added on May 13, 2024.
458 	+/
459 	void setHtmlBody(bool addFallback = true)(string html) {
460 		isMime = true;
461 		isHtml = true;
462 		htmlBody_ = html;
463 
464 		static if(addFallback) {
465 			import arsd.htmltotext;
466 			if(textBody_ is null)
467 				textBody_ = htmlToText(html);
468 		}
469 	}
470 
471 	const(MimeAttachment)[] attachments;
472 
473 	/++
474 		The attachmentFileName is what is shown to the user, not the file on your sending computer. It should NOT have a path in it.
475 		If you want a filename from your computer, try [addFileAsAttachment].
476 
477 		The `mimeType` can be excluded if the filename has a common extension supported by the library.
478 
479 		---
480 			message.addAttachment("text/plain", "something.txt", std.file.read("/path/to/local/something.txt"));
481 		---
482 
483 		History:
484 			The overload without `mimeType` was added October 28, 2024.
485 
486 			The parameter `attachmentFileName` was previously called `filename`. This was changed for clarity and consistency with other overloads on October 28, 2024.
487 	+/
488 	void addAttachment(string mimeType, string attachmentFileName, const void[] content, string id = null) {
489 		isMime = true;
490 		attachments ~= MimeAttachment(mimeType, attachmentFileName, cast(const(ubyte)[]) content, id);
491 	}
492 
493 
494 	/// ditto
495 	void addAttachment(string attachmentFileName, const void[] content, string id = null) {
496 		import arsd.core;
497 		addAttachment(FilePath(attachmentFileName).contentTypeFromFileExtension, attachmentFileName, content, id);
498 	}
499 
500 	/++
501 		Reads the local file and attaches it.
502 
503 		If `attachmentFileName` is null, it uses the filename of `localFileName`, without the directory.
504 
505 		If `mimeType` is null, it guesses one based on the local file name's file extension.
506 
507 		If these cannot be determined, it will throw an `InvalidArgumentsException`.
508 
509 		History:
510 			Added October 28, 2024
511 	+/
512 	void addFileAsAttachment(FilePath localFileName, string attachmentFileName = null, string mimeType = null, string id = null) {
513 		if(mimeType is null)
514 			mimeType = localFileName.contentTypeFromFileExtension;
515 		if(attachmentFileName is null)
516 			attachmentFileName = localFileName.filename;
517 
518 		import std.file;
519 
520 		addAttachment(mimeType, attachmentFileName, std.file.read(localFileName.toString()), id);
521 
522 		// see also: curl.h :1877    CURLOPT(CURLOPT_XOAUTH2_BEARER, CURLOPTTYPE_STRINGPOINT, 220),
523 		// also option to force STARTTLS
524 	}
525 
526 	/// in the html, use img src="cid:ID_GIVEN_HERE"
527 	void addInlineImage(string id, string mimeType, string filename, const void[] content) {
528 		assert(isHtml);
529 		isMime = true;
530 		inlineImages ~= MimeAttachment(mimeType, filename, cast(const(ubyte)[]) content, id);
531 	}
532 
533 	const(MimeAttachment)[] inlineImages;
534 
535 
536 	/* we should build out the mime thingy
537 		related
538 			mixed
539 			alternate
540 	*/
541 
542 	/// Returns the MIME formatted email string, including encoded attachments
543 	override string toString() {
544 		assert(!isHtml || (isHtml && isMime));
545 
546 		string[] headers;
547 		foreach(k, v; this.headers_) {
548 			if(headerSettableThroughAA(k))
549 				headers ~= k ~ ": " ~ encodeEmailHeaderContentForTransmit(v, this.linesep);
550 		}
551 
552 		if(to.length)
553 			headers ~= "To: " ~ to.toProtocolString(this.linesep);
554 		if(cc.length)
555 			headers ~= "Cc: " ~ cc.toProtocolString(this.linesep);
556 
557 		if(from.length)
558 			headers ~= "From: " ~ from.toProtocolString(this.linesep);
559 
560 			//assert(0, headers[$-1]);
561 
562 		if(subject !is null)
563 			headers ~= "Subject: " ~ encodeEmailHeaderContentForTransmit(subject, this.linesep);
564 		if(replyTo !is null)
565 			headers ~= "Reply-To: " ~ replyTo.toProtocolString(this.linesep);
566 		if(inReplyTo !is null)
567 			headers ~= "In-Reply-To: " ~ encodeEmailHeaderContentForTransmit(inReplyTo, this.linesep);
568 
569 		if(isMime)
570 			headers ~= "MIME-Version: 1.0";
571 
572 	/+
573 		if(inlineImages.length) {
574 			headers ~= "Content-Type: multipart/related; boundary=" ~ boundary;
575 			// so we put the alternative inside asthe first attachment with as seconary boundary
576 			// then we do the images
577 		} else
578 		if(attachments.length)
579 			headers ~= "Content-Type: multipart/mixed; boundary=" ~ boundary;
580 		else if(isHtml)
581 			headers ~= "Content-Type: multipart/alternative; boundary=" ~ boundary;
582 		else
583 			headers ~= "Content-Type: text/plain; charset=UTF-8";
584 	+/
585 
586 
587 		string msgContent;
588 
589 		if(isMime) {
590 			MimeContainer top;
591 
592 			{
593 				MimeContainer mimeMessage;
594 				enum NO_TRANSFER_ENCODING = "Content-Transfer-Encoding: 8bit";
595 				if(isHtml) {
596 					auto alternative = new MimeContainer("multipart/alternative");
597 					alternative.stuff ~= new MimeContainer("text/plain; charset=UTF-8", textBody_).with_header(NO_TRANSFER_ENCODING);
598 					alternative.stuff ~= new MimeContainer("text/html; charset=UTF-8", htmlBody_).with_header(NO_TRANSFER_ENCODING);
599 					mimeMessage = alternative;
600 				} else {
601 					mimeMessage = new MimeContainer("text/plain; charset=UTF-8", textBody_).with_header(NO_TRANSFER_ENCODING);
602 				}
603 				top = mimeMessage;
604 			}
605 
606 			{
607 				MimeContainer mimeRelated;
608 				if(inlineImages.length) {
609 					mimeRelated = new MimeContainer("multipart/related");
610 
611 					mimeRelated.stuff ~= top;
612 					top = mimeRelated;
613 
614 					foreach(attachment; inlineImages) {
615 						auto mimeAttachment = new MimeContainer(attachment.type ~ "; name=\""~attachment.filename~"\"");
616 						mimeAttachment.headers ~= "Content-Transfer-Encoding: base64";
617 						mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">";
618 						mimeAttachment.content = encodeBase64Mime(cast(const(ubyte)[]) attachment.content, this.linesep);
619 
620 						mimeRelated.stuff ~= mimeAttachment;
621 					}
622 				}
623 			}
624 
625 			{
626 				MimeContainer mimeMixed;
627 				if(attachments.length) {
628 					mimeMixed = new MimeContainer("multipart/mixed");
629 
630 					mimeMixed.stuff ~= top;
631 					top = mimeMixed;
632 
633 					foreach(attachment; attachments) {
634 						auto mimeAttachment = new MimeContainer(attachment.type);
635 						mimeAttachment.headers ~= "Content-Disposition: attachment; filename=\""~encodeEmailHeaderContentForTransmit(attachment.filename, this.linesep)~"\"";
636 						mimeAttachment.headers ~= "Content-Transfer-Encoding: base64";
637 						if(attachment.id.length)
638 							mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">";
639 
640 						mimeAttachment.content = encodeBase64Mime(cast(const(ubyte)[]) attachment.content, this.linesep);
641 
642 						mimeMixed.stuff ~= mimeAttachment;
643 					}
644 				}
645 			}
646 
647 			headers ~= top.contentType;
648 			msgContent = top.toMimeString(true, this.linesep);
649 		} else {
650 			headers ~= "Content-Type: text/plain; charset=UTF-8";
651 			msgContent = textBody_;
652 		}
653 
654 
655 		string msg;
656 		msg.reserve(htmlBody_.length + textBody_.length + 1024);
657 
658 		foreach(header; headers)
659 			msg ~= header ~ this.linesep;
660 		if(msg.length) // has headers
661 			msg ~= this.linesep;
662 
663 		msg ~= msgContent;
664 
665 		return msg;
666 	}
667 
668 	/// Sends via a given SMTP relay
669 	void send(RelayInfo mailServer = RelayInfo("smtp://localhost")) {
670 		auto smtp = SMTP(mailServer.server);
671 
672 		smtp.verifyHost = false;
673 		smtp.verifyPeer = false;
674 		//smtp.verbose = true;
675 
676 		{
677 			// std.net.curl doesn't work well with STARTTLS if you don't
678 			// put smtps://... and if you do, it errors if you can't start
679 			// with a TLS connection from the beginning.
680 
681 			// This change allows ssl if it can.
682 			import std.net.curl;
683 			import etc.c.curl;
684 			smtp.handle.set(CurlOption.use_ssl, CurlUseSSL.tryssl);
685 		}
686 
687 		if(mailServer.username.length)
688 			smtp.setAuthentication(mailServer.username, mailServer.password);
689 
690 		const(char)[][] allRecipients;
691 		void processPerson(string person) {
692 			auto idx = person.indexOf("<");
693 			if(idx == -1)
694 				allRecipients ~= person;
695 			else {
696 				person = person[idx + 1 .. $];
697 				idx = person.indexOf(">");
698 				if(idx != -1)
699 					person = person[0 .. idx];
700 
701 				allRecipients ~= person;
702 			}
703 		}
704 		foreach(person; to) processPerson(person);
705 		foreach(person; cc) processPerson(person);
706 		foreach(person; bcc) processPerson(person);
707 
708 		smtp.mailTo(allRecipients);
709 
710 		auto mailFrom = from;
711 		auto idx = mailFrom.indexOf("<");
712 		if(idx != -1)
713 			mailFrom = mailFrom[idx + 1 .. $];
714 		idx = mailFrom.indexOf(">");
715 		if(idx != -1)
716 			mailFrom = mailFrom[0 .. idx];
717 
718 		smtp.mailFrom = mailFrom;
719 		smtp.message = this.toString();
720 		smtp.perform();
721 	}
722 }
723 
724 ///
725 void email(string to, string subject, string message, string from, RelayInfo mailServer = RelayInfo("smtp://localhost")) {
726 	auto msg = new EmailMessage();
727 	msg.from = from;
728 	msg.to = [to];
729 	msg.subject = subject;
730 	msg.textBody_ = message;
731 	msg.send(mailServer);
732 }
733 
734 // private:
735 
736 import std.conv;
737 
738 /// for reading
739 class MimePart {
740 	string[] headers;
741 	immutable(ubyte)[] content;
742 	immutable(ubyte)[] encodedContent; // usually valid only for GPG, and will be cleared by creator; canonical form
743 	string textContent;
744 	MimePart[] stuff;
745 
746 	string name;
747 	string charset;
748 	string type;
749 	string transferEncoding;
750 	string disposition;
751 	string id;
752 	string filename;
753 	// gpg signatures
754 	string gpgalg;
755 	string gpgproto;
756 
757 	MimeAttachment toMimeAttachment() {
758 		if(type == "multipart/mixed" && stuff.length == 1)
759 			return stuff[0].toMimeAttachment;
760 
761 		// just attach the preferred thing. i think this is never triggered since this is most likely handled by the text/html body scanner
762 		if(type == "multipart/alternative" && stuff.length >= 1)
763 			return stuff[0].toMimeAttachment;
764 
765 		MimeAttachment att;
766 		att.type = type;
767 		att.id = id;
768 		if(filename.length == 0 && name.length > 0 ) {
769 			att.filename = name;
770 		} else {
771 			att.filename = filename;
772 		}
773 
774 		if(type == "multipart/related" && stuff.length >= 1) {
775 			// super hack for embedded html for a special user; it is basically a mhtml attachment so we report the filename of the html part for the whole thing
776 			// really, the code should understand the type itself but this is still better than nothing
777 			if(att.filename.length == 0)
778 				att.filename = stuff[0].filename;
779 			if(att.filename.length == 0)
780 				att.filename = stuff[0].name;
781 			att.filename = att.filename.replace(".html", ".mhtml");
782 			att.content = []; // FIXME: recreate the concat thing and put some headers on it to make a valid .mhtml file out of it
783 		} else {
784 			att.content = content;
785 		}
786 
787 		return att;
788 	}
789 
790 	this(immutable(ubyte)[][] lines, string contentType = null) {
791 		string boundary;
792 
793 		void parseContentType(string content) {
794 			//{ import std.stdio; writeln("c=[", content, "]"); }
795 			foreach(k, v; breakUpHeaderParts(content)) {
796 				//{ import std.stdio; writeln("  k=[", k, "]; v=[", v, "]"); }
797 				switch(k) {
798 					case "root":
799 						type = v;
800 					break;
801 					case "name":
802 						name = v;
803 					break;
804 					case "charset":
805 						charset = v;
806 					break;
807 					case "boundary":
808 						boundary = v;
809 					break;
810 					default:
811 					case "micalg":
812 						gpgalg = v;
813 					break;
814 					case "protocol":
815 						gpgproto = v;
816 					break;
817 				}
818 			}
819 		}
820 
821 		if(contentType is null) {
822 			// read headers immediately...
823 			auto copyOfLines = lines;
824 			immutable(ubyte)[] currentHeader;
825 
826 			void commitHeader() {
827 				if(currentHeader.length == 0)
828 					return;
829 				string h = decodeEncodedWord(cast(string) currentHeader);
830 				headers ~= h;
831 				currentHeader = null;
832 
833 				auto idx = h.indexOf(":");
834 				if(idx != -1) {
835 					auto name = h[0 .. idx].strip.toLower;
836 					auto content = h[idx + 1 .. $].strip;
837 
838 					string[4] filenames_found;
839 
840 					switch(name) {
841 						case "content-type":
842 							parseContentType(content);
843 						break;
844 						case "content-transfer-encoding":
845 							transferEncoding = content.toLower;
846 						break;
847 						case "content-disposition":
848 							foreach(k, v; breakUpHeaderParts(content)) {
849 								switch(k) {
850 									case "root":
851 										disposition = v;
852 									break;
853 									case "filename":
854 										filename = v;
855 									break;
856 									// FIXME: https://datatracker.ietf.org/doc/html/rfc2184#section-3 is what it is SUPPOSED to do
857 									case "filename*0":
858 										filenames_found[0] = v;
859 									break;
860 									case "filename*1":
861 										filenames_found[1] = v;
862 									break;
863 									case "filename*2":
864 										filenames_found[2] = v;
865 									break;
866 									case "filename*3":
867 										filenames_found[3] = v;
868 									break;
869 									default:
870 								}
871 							}
872 						break;
873 						case "content-id":
874 							id = content;
875 						break;
876 						default:
877 					}
878 
879 					if (filenames_found[0] != "") {
880 						foreach (string v; filenames_found) {
881 							this.filename ~= v;
882 						}
883 					}
884 				}
885 			}
886 
887 			foreach(line; copyOfLines) {
888 				lines = lines[1 .. $];
889 				if(line.length == 0)
890 					break;
891 
892 				if(line[0] == ' ' || line[0] == '\t')
893 					currentHeader ~= (cast(string) line).stripLeft();
894 				else {
895 					if(currentHeader.length) {
896 						commitHeader();
897 					}
898 					currentHeader = line;
899 				}
900 			}
901 
902 			commitHeader();
903 		} else {
904 			parseContentType(contentType);
905 		}
906 
907 		// if it is multipart, find the start boundary. we'll break it up and fill in stuff
908 		// otherwise, all the data that follows is just content
909 
910 		if(boundary.length) {
911 			immutable(ubyte)[][] partLines;
912 			bool inPart;
913 			foreach(line; lines) {
914 				if(line.startsWith("--" ~ boundary)) {
915 					if(inPart)
916 						stuff ~= new MimePart(partLines);
917 					inPart = true;
918 					partLines = null;
919 
920 					if(line == "--" ~ boundary ~ "--")
921 						break; // all done
922 				}
923 
924 				if(inPart) {
925 					partLines ~= line;
926 				} else {
927 					content ~= line ~ '\n';
928 				}
929 			}
930 		} else {
931 			foreach(line; lines) {
932 				content ~= line;
933 
934 				if(transferEncoding != "base64")
935 					content ~= '\n';
936 			}
937 		}
938 
939 		// store encoded content for GPG (should be cleared by caller if necessary)
940 		encodedContent = content;
941 
942 		// decode the content..
943 		switch(transferEncoding) {
944 			case "base64":
945 				content = Base64.decode(cast(string) content);
946 			break;
947 			case "quoted-printable":
948 				content = decodeQuotedPrintable(cast(string) content);
949 			break;
950 			default:
951 				// no change needed (I hope)
952 		}
953 
954 		if(type.indexOf("text/") == 0) {
955 			if(charset.length == 0)
956 				charset = "latin1";
957 			textContent = convertToUtf8Lossy(content, charset);
958 		}
959 	}
960 }
961 
962 string[string] breakUpHeaderParts(string headerContent) {
963 	string[string] ret;
964 
965 	string currentName = "root";
966 	string currentContent;
967 	bool inQuote = false;
968 	bool gettingName = false;
969 	bool ignoringSpaces = false;
970 	foreach(char c; headerContent) {
971 		if(ignoringSpaces) {
972 			if(c == ' ')
973 				continue;
974 			else
975 				ignoringSpaces = false;
976 		}
977 
978 		if(gettingName) {
979 			if(c == '=') {
980 				gettingName = false;
981 				continue;
982 			}
983 			currentName ~= c;
984 		}
985 
986 		if(c == '"') {
987 			inQuote = !inQuote;
988 			continue;
989 		}
990 
991 		if(!inQuote && c == ';') {
992 			ret[currentName] = currentContent;
993 			ignoringSpaces = true;
994 			currentName = null;
995 			currentContent = null;
996 
997 			gettingName = true;
998 			continue;
999 		}
1000 
1001 		if(!gettingName)
1002 			currentContent ~= c;
1003 	}
1004 
1005 	if(currentName.length)
1006 		ret[currentName] = currentContent;
1007 
1008 	return ret;
1009 }
1010 
1011 // for writing
1012 class MimeContainer {
1013 	private static int sequence;
1014 
1015 	immutable string _contentType;
1016 	immutable string boundary;
1017 
1018 	string[] headers; // NOT including content-type
1019 	string content;
1020 	MimeContainer[] stuff;
1021 
1022 	this(string contentType, string content = null) {
1023 		this._contentType = contentType;
1024 		this.content = content;
1025 		sequence++;
1026 		if(_contentType.indexOf("multipart/") == 0)
1027 			boundary = "0016e64be86203dd36047610926a" ~ to!string(sequence);
1028 	}
1029 
1030 	@property string contentType() {
1031 		string ct = "Content-Type: "~_contentType;
1032 		if(boundary.length)
1033 			ct ~= "; boundary=" ~ boundary;
1034 		return ct;
1035 	}
1036 
1037 
1038 	string toMimeString(bool isRoot = false, string linesep="\r\n") {
1039 		string ret;
1040 
1041 		if(!isRoot) {
1042 			ret ~= contentType;
1043 			foreach(header; headers) {
1044 				ret ~= linesep;
1045 				ret ~= encodeEmailHeaderForTransmit(header, linesep);
1046 			}
1047 			ret ~= linesep ~ linesep;
1048 		}
1049 
1050 		ret ~= content;
1051 
1052 		foreach(idx, thing; stuff) {
1053 			assert(boundary.length);
1054 			ret ~= linesep ~ "--" ~ boundary ~ linesep;
1055 			ret ~= thing.toMimeString(false, linesep);
1056 		}
1057 
1058 		if(boundary.length)
1059 			ret ~= linesep ~ "--" ~ boundary ~ "--";
1060 
1061 		return ret;
1062 	}
1063 }
1064 
1065 import std.algorithm : startsWith;
1066 /++
1067 	Represents a single email from an incoming or saved source consisting of the raw data. Such saved sources include mbox files (which are several concatenated together, see [MboxMessages] for a full reader of these files), .eml files, and Maildir entries.
1068 +/
1069 class IncomingEmailMessage : EmailMessage {
1070 	/++
1071 		Various constructors for parsing an email message.
1072 
1073 
1074 		The `ref immutable(ubyte)[][]` one is designed for reading a pre-loaded mbox file. It updates the ref variable to the point at the next message in the file as it processes. You probably should use [MboxMessages] in a `foreach` loop instead of calling this directly most the time.
1075 
1076 		The `string[]` one takes an ascii or utf-8 file of a single email pre-split into lines.
1077 
1078 		The `immutable(ubyte)[]` one is designed for reading an individual message in its own file in the easiest way. Try `new IncomingEmailMessage(cast(immutable(ubyte)[]) std.file.read("filename.eml"));` to use this. You can also use `IncomingEmailMessage.fromFile("filename.eml")` as well.
1079 
1080 		History:
1081 			The `immutable(ubyte)[]` overload for a single file was added on May 14, 2024.
1082 	+/
1083 	this(ref immutable(ubyte)[][] mboxLines, bool asmbox=true) @trusted {
1084 
1085 		enum ParseState {
1086 			lookingForFrom,
1087 			readingHeaders,
1088 			readingBody
1089 		}
1090 
1091 		auto state = (asmbox ? ParseState.lookingForFrom : ParseState.readingHeaders);
1092 		string contentType;
1093 
1094 		bool isMultipart;
1095 		bool isHtml;
1096 		immutable(ubyte)[][] mimeLines;
1097 
1098 		string charset = "latin-1";
1099 
1100 		string contentTransferEncoding;
1101 
1102 		string headerName;
1103 		string headerContent;
1104 		void commitHeader() {
1105 			if(headerName is null)
1106 				return;
1107 
1108 			auto originalHeaderName = headerName;
1109 			headerName = headerName.toLower();
1110 			headerContent = headerContent.strip();
1111 
1112 			headerContent = decodeEncodedWord(headerContent);
1113 
1114 			if(headerName == "content-type") {
1115 				contentType = headerContent;
1116 				if(contentType.indexOf("multipart/") != -1)
1117 					isMultipart = true;
1118 				else if(contentType.indexOf("text/html") != -1)
1119 					isHtml = true;
1120 
1121 				auto charsetIdx = contentType.indexOf("charset=");
1122 				if(charsetIdx != -1) {
1123 					string cs = contentType[charsetIdx + "charset=".length .. $];
1124 					if(cs.length && cs[0] == '\"')
1125 						cs = cs[1 .. $];
1126 
1127 					auto quoteIdx = cs.indexOf("\"");
1128 					if(quoteIdx != -1)
1129 						cs = cs[0 .. quoteIdx];
1130 					auto semicolonIdx = cs.indexOf(";");
1131 					if(semicolonIdx != -1)
1132 						cs = cs[0 .. semicolonIdx];
1133 
1134 					cs = cs.strip();
1135 					if(cs.length)
1136 						charset = cs.toLower();
1137 				}
1138 			} else if(headerName == "from") {
1139 				this.from = headerContent;
1140 			} else if(headerName == "to") {
1141 				this.to ~= headerContent;
1142 			} else if(headerName == "subject") {
1143 				this.subject = headerContent;
1144 			} else if(headerName == "content-transfer-encoding") {
1145 				contentTransferEncoding = headerContent;
1146 			}
1147 
1148 			headers_[originalHeaderName] = headerContent;
1149 			headerName = null;
1150 			headerContent = null;
1151 		}
1152 
1153 		lineLoop: while(mboxLines.length) {
1154 			// this can needlessly convert headers too, but that won't harm anything since they are 7 bit anyway
1155 			auto line = convertToUtf8Lossy(mboxLines[0], charset);
1156 			auto origline = line;
1157 			line = line.stripRight;
1158 
1159 			final switch(state) {
1160 				case ParseState.lookingForFrom:
1161 					if(line.startsWith("From "))
1162 						state = ParseState.readingHeaders;
1163 				break;
1164 				case ParseState.readingHeaders:
1165 					if(line.length == 0) {
1166 						commitHeader();
1167 						state = ParseState.readingBody;
1168 					} else {
1169 						if(line[0] == ' ' || line[0] == '\t') {
1170 							headerContent ~= " " ~ line.stripLeft();
1171 						} else {
1172 							commitHeader();
1173 
1174 							auto idx = line.indexOf(":");
1175 							if(idx == -1)
1176 								headerName = line;
1177 							else {
1178 								headerName = line[0 .. idx];
1179 								headerContent = line[idx + 1 .. $].stripLeft();
1180 							}
1181 						}
1182 					}
1183 				break;
1184 				case ParseState.readingBody:
1185 					if (asmbox) {
1186 						if(line.startsWith("From ")) {
1187 							break lineLoop; // we're at the beginning of the next messsage
1188 						}
1189 						if(line.startsWith(">>From") || line.startsWith(">From")) {
1190 							line = line[1 .. $];
1191 						}
1192 					}
1193 
1194 					if(isMultipart) {
1195 						mimeLines ~= mboxLines[0];
1196 					} else if(isHtml) {
1197 						// html with no alternative and no attachments
1198 						this.htmlBody_ ~= line ~ "\n";
1199 					} else {
1200 						// plain text!
1201 						// we want trailing spaces for "format=flowed", for example, so...
1202 						line = origline;
1203 						size_t epos = line.length;
1204 						while (epos > 0) {
1205 							char ch = line.ptr[epos-1];
1206 							if (ch >= ' ' || ch == '\t') break;
1207 							--epos;
1208 						}
1209 						line = line.ptr[0..epos];
1210 						this.textBody_ ~= line ~ "\n";
1211 					}
1212 				break;
1213 			}
1214 
1215 			mboxLines = mboxLines[1 .. $];
1216 		}
1217 
1218 		if(mimeLines.length) {
1219 			auto part = new MimePart(mimeLines, contentType);
1220 			deeperInTheMimeTree:
1221 			switch(part.type) {
1222 				case "text/html":
1223 					this.htmlBody_ = part.textContent;
1224 				break;
1225 				case "text/plain":
1226 					this.textBody_ = part.textContent;
1227 				break;
1228 				case "multipart/alternative":
1229 					foreach(p; part.stuff) {
1230 						if(p.type == "text/html")
1231 							this.htmlBody_ = p.textContent;
1232 						else if(p.type == "text/plain")
1233 							this.textBody_ = p.textContent;
1234 					}
1235 				break;
1236 				case "multipart/related":
1237 					// the first one is the message itself
1238 					// after that comes attachments that can be rendered inline
1239 					if(part.stuff.length) {
1240 						auto msg = part.stuff[0];
1241 						foreach(thing; part.stuff[1 .. $]) {
1242 							// FIXME: should this be special?
1243 							attachments ~= thing.toMimeAttachment();
1244 						}
1245 						part = msg;
1246 						goto deeperInTheMimeTree;
1247 					}
1248 				break;
1249 				case "multipart/mixed":
1250 					if(part.stuff.length) {
1251 						MimePart msg;
1252 						foreach(idx, thing; part.stuff) {
1253 							if(msg is null && thing.disposition != "attachment" && (thing.type.length == 0 || thing.type.indexOf("multipart/") != -1 || thing.type.indexOf("text/") != -1)) {
1254 								// the message should be the first suitable item for conversion
1255 								msg = thing;
1256 							} else {
1257 								attachments ~= thing.toMimeAttachment();
1258 							}
1259 						}
1260 						if(msg)
1261 							part = msg;
1262 						goto deeperInTheMimeTree;
1263 					}
1264 
1265 					// FIXME: the more proper way is:
1266 					// check the disposition
1267 					// if none, concat it to make a text message body
1268 					// if inline it is prolly an image to be concated in the other body
1269 					// if attachment, it is an attachment
1270 				break;
1271 				case "multipart/signed":
1272 					// FIXME: it would be cool to actually check the signature
1273 					if (part.stuff.length) {
1274 						auto msg = part.stuff[0];
1275 						//{ import std.stdio; writeln("hdrs: ", part.stuff[0].headers); }
1276 						gpgalg = part.gpgalg;
1277 						gpgproto = part.gpgproto;
1278 						gpgmime = part;
1279 						foreach (thing; part.stuff[1 .. $]) {
1280 							attachments ~= thing.toMimeAttachment();
1281 						}
1282 						part = msg;
1283 						goto deeperInTheMimeTree;
1284 					}
1285 				break;
1286 				default:
1287 					// FIXME: correctly handle more
1288 					if(part.stuff.length) {
1289 						part = part.stuff[0];
1290 						goto deeperInTheMimeTree;
1291 					}
1292 			}
1293 		} else {
1294 			switch(contentTransferEncoding) {
1295 				case "quoted-printable":
1296 					if(this.textBody_.length)
1297 						this.textBody_ = convertToUtf8Lossy(decodeQuotedPrintable(this.textBody_), charset);
1298 					if(this.htmlBody_.length)
1299 						this.htmlBody_ = convertToUtf8Lossy(decodeQuotedPrintable(this.htmlBody_), charset);
1300 				break;
1301 				case "base64":
1302 					if(this.textBody_.length) {
1303 						this.textBody_ = this.textBody_.decodeBase64Mime.convertToUtf8Lossy(charset);
1304 					}
1305 					if(this.htmlBody_.length) {
1306 						this.htmlBody_ = this.htmlBody_.decodeBase64Mime.convertToUtf8Lossy(charset);
1307 					}
1308 
1309 				break;
1310 				default:
1311 					// nothing needed
1312 			}
1313 		}
1314 
1315 		if(this.htmlBody_.length > 0 && this.textBody_.length == 0) {
1316 			import arsd.htmltotext;
1317 			this.textBody_ = htmlToText(this.htmlBody_);
1318 			textAutoConverted = true;
1319 		}
1320 	}
1321 
1322 	/// ditto
1323 	this(string[] lines) {
1324 		auto lns = cast(immutable(ubyte)[][])lines;
1325 		this(lns, false);
1326 	}
1327 
1328 	/// ditto
1329 	this(immutable(ubyte)[] fileContent) {
1330 		auto lns = splitLinesWithoutDecoding(fileContent);
1331 		this(lns, false);
1332 	}
1333 
1334 	/++
1335 		Convenience method that takes a filename instead of the content.
1336 
1337 		Its implementation is simply `return new IncomingEmailMessage(cast(immutable(ubyte)[]) std.file.read(filename));`
1338 		(though i reserve the right to use a different file loading library later, still the same idea)
1339 
1340 		History:
1341 			Added May 14, 2024
1342 	+/
1343 	static IncomingEmailMessage fromFile(string filename) {
1344 		import std.file;
1345 		return new IncomingEmailMessage(cast(immutable(ubyte)[]) std.file.read(filename));
1346 	}
1347 
1348 	///
1349 	@property bool hasGPGSignature () const nothrow @trusted @nogc {
1350 		MimePart mime = cast(MimePart)gpgmime; // sorry
1351 		if (mime is null) return false;
1352 		if (mime.type != "multipart/signed") return false;
1353 		if (mime.stuff.length != 2) return false;
1354 		if (mime.stuff[1].type != "application/pgp-signature") return false;
1355 		if (mime.stuff[0].type.length <= 5 && mime.stuff[0].type[0..5] != "text/") return false;
1356 		return true;
1357 	}
1358 
1359 	///
1360 	ubyte[] extractGPGData () const nothrow @trusted {
1361 		if (!hasGPGSignature) return null;
1362 		MimePart mime = cast(MimePart)gpgmime; // sorry
1363 		char[] res;
1364 		res.reserve(mime.stuff[0].encodedContent.length); // more, actually
1365 		foreach (string s; mime.stuff[0].headers[1..$]) {
1366 			while (s.length && s[$-1] <= ' ') s = s[0..$-1];
1367 			if (s.length == 0) return null; // wtf?! empty headers?
1368 			res ~= s;
1369 			res ~= "\r\n";
1370 		}
1371 		res ~= "\r\n";
1372 		// extract content (see rfc3156)
1373 		size_t pos = 0;
1374 		auto ctt = mime.stuff[0].encodedContent;
1375 		// last CR/LF is a part of mime signature, actually, so remove it
1376 		if (ctt.length && ctt[$-1] == '\n') {
1377 			ctt = ctt[0..$-1];
1378 			if (ctt.length && ctt[$-1] == '\r') ctt = ctt[0..$-1];
1379 		}
1380 		while (pos < ctt.length) {
1381 			auto epos = pos;
1382 			while (epos < ctt.length && ctt.ptr[epos] != '\n') ++epos;
1383 			auto xpos = epos;
1384 			while (xpos > pos && ctt.ptr[xpos-1] <= ' ') --xpos; // according to rfc
1385 			res ~= ctt[pos..xpos].dup;
1386 			res ~= "\r\n"; // according to rfc
1387 			pos = epos+1;
1388 		}
1389 		return cast(ubyte[])res;
1390 	}
1391 
1392 	///
1393 	immutable(ubyte)[] extractGPGSignature () const nothrow @safe @nogc {
1394 		if (!hasGPGSignature) return null;
1395 		return gpgmime.stuff[1].content;
1396 	}
1397 
1398 	/++
1399 		Allows access to the headers in the email as a key/value hash.
1400 
1401 		The hash allows access as if it was case-insensitive, but it also still keeps the original case when you loop through it.
1402 
1403 		Bugs:
1404 			Duplicate headers are lost in the current implementation; only the most recent copy of any given name is retained.
1405 	+/
1406 	const(HeadersHash) headers() {
1407 		return headers_;
1408 	}
1409 
1410 	/++
1411 		Returns the message body as either HTML or text. Gives the same results as through the parent interface, [EmailMessage.htmlBody] and [EmailMessage.textBody].
1412 
1413 		If the message was multipart/alternative, both of these will be populated with content from the message. They are supposed to be both the same, but not all senders respect this so you might want to check both anyway.
1414 
1415 		If the message was just plain text, `htmlMessageBody` will be `null` and `textMessageBody` will have the original message.
1416 
1417 		If the message was just HTML, `htmlMessageBody` contains the original message and `textMessageBody` will contain an automatically converted version (using [arsd.htmltotext]). [textAutoConverted] will be set to `true`.
1418 
1419 		History:
1420 			Were public strings until May 14, 2024, when it was changed to property getters instead.
1421 	+/
1422 	string htmlMessageBody() {
1423 		return this.htmlBody_;
1424 	}
1425 	/// ditto
1426 	string textMessageBody() {
1427 		return this.textBody_;
1428 	}
1429 	/// ditto
1430 	bool textAutoConverted;
1431 
1432 	// gpg signature fields
1433 	string gpgalg; ///
1434 	string gpgproto; ///
1435 	MimePart gpgmime; ///
1436 
1437 	///
1438 	string fromEmailAddress() {
1439 		return from.address;
1440 	}
1441 
1442 	///
1443 	string toEmailAddress() {
1444 		if(to.recipients.length)
1445 			return to.recipients[0].address;
1446 		return null;
1447 	}
1448 }
1449 
1450 /++
1451 	An mbox file is a concatenated list of individual email messages. This is a range of messages given the content of one of those files.
1452 +/
1453 struct MboxMessages {
1454 	immutable(ubyte)[][] linesRemaining;
1455 
1456 	///
1457 	this(immutable(ubyte)[] data) {
1458 		linesRemaining = splitLinesWithoutDecoding(data);
1459 		popFront();
1460 	}
1461 
1462 	IncomingEmailMessage currentFront;
1463 
1464 	///
1465 	IncomingEmailMessage front() {
1466 		return currentFront;
1467 	}
1468 
1469 	///
1470 	bool empty() {
1471 		return currentFront is null;
1472 	}
1473 
1474 	///
1475 	void popFront() {
1476 		if(linesRemaining.length)
1477 			currentFront = new IncomingEmailMessage(linesRemaining);
1478 		else
1479 			currentFront = null;
1480 	}
1481 }
1482 
1483 ///
1484 MboxMessages processMboxData(immutable(ubyte)[] data) {
1485 	return MboxMessages(data);
1486 }
1487 
1488 immutable(ubyte)[][] splitLinesWithoutDecoding(immutable(ubyte)[] data) {
1489 	immutable(ubyte)[][] ret;
1490 
1491 	size_t starting = 0;
1492 	bool justSaw13 = false;
1493 	foreach(idx, b; data) {
1494 		if(b == 13)
1495 			justSaw13 = true;
1496 
1497 		if(b == 10) {
1498 			auto use = idx;
1499 			if(justSaw13)
1500 				use--;
1501 
1502 			ret ~= data[starting .. use];
1503 			starting = idx + 1;
1504 		}
1505 
1506 		if(b != 13)
1507 			justSaw13 = false;
1508 	}
1509 
1510 	if(starting < data.length)
1511 		ret ~= data[starting .. $];
1512 
1513 	return ret;
1514 }
1515 
1516 string decodeEncodedWord(string data) {
1517 	string originalData = data;
1518 
1519 	auto delimiter = data.indexOf("=?");
1520 	if(delimiter == -1)
1521 		return data;
1522 
1523 	string ret;
1524 
1525 	while(delimiter != -1) {
1526 		ret ~= data[0 .. delimiter];
1527 		data = data[delimiter + 2 .. $];
1528 
1529 		string charset;
1530 		string encoding;
1531 		string encodedText;
1532 
1533 		// FIXME: the insane things should probably throw an
1534 		// exception that keeps a copy of orignal data for use later
1535 
1536 		auto questionMark = data.indexOf("?");
1537 		if(questionMark == -1) return originalData; // not sane
1538 
1539 		charset = data[0 .. questionMark];
1540 		data = data[questionMark + 1 .. $];
1541 
1542 		questionMark = data.indexOf("?");
1543 		if(questionMark == -1) return originalData; // not sane
1544 
1545 		encoding = data[0 .. questionMark];
1546 		data = data[questionMark + 1 .. $];
1547 
1548 		questionMark = data.indexOf("?=");
1549 		if(questionMark == -1) return originalData; // not sane
1550 
1551 		encodedText = data[0 .. questionMark];
1552 		data = data[questionMark + 2 .. $];
1553 
1554 		delimiter = data.indexOf("=?");
1555 		if (delimiter == 1 && data[0] == ' ') {
1556 			// a single space between encoded words must be ignored because it is
1557 			// used to separate multiple encoded words (RFC2047 says CRLF SPACE but a most clients
1558 			// just use a space)
1559 			data = data[1..$];
1560 			delimiter = 0;
1561 		}
1562 
1563 		immutable(ubyte)[] decodedText;
1564 		if(encoding == "Q" || encoding == "q")
1565 			decodedText = decodeQuotedPrintable(encodedText);
1566 		else if(encoding == "B" || encoding == "b") {
1567 			decodedText = cast(typeof(decodedText)) Base64.decode(encodedText);
1568 		} else
1569 			return originalData; // wtf
1570 
1571 		ret ~= convertToUtf8Lossy(decodedText, charset);
1572 	}
1573 
1574 	ret ~= data; // keep the rest since there could be trailing stuff
1575 
1576 	return ret;
1577 }
1578 
1579 immutable(ubyte)[] decodeQuotedPrintable(string text) {
1580 	immutable(ubyte)[] ret;
1581 
1582 	int state = 0;
1583 	ubyte hexByte;
1584 	foreach(b; cast(immutable(ubyte)[]) text) {
1585 		switch(state) {
1586 			case 0:
1587 				if(b == '=') {
1588 					state++;
1589 					hexByte = 0;
1590 				} else if (b == '_') { // RFC2047 4.2.2: a _ may be used to represent a space
1591 					ret ~= ' ';
1592 				} else
1593 					ret ~= b;
1594 			break;
1595 			case 1:
1596 				if(b == '\n') {
1597 					state = 0;
1598 					continue;
1599 				}
1600 				goto case;
1601 			case 2:
1602 				int value;
1603 				if(b >= '0' && b <= '9')
1604 					value = b - '0';
1605 				else if(b >= 'A' && b <= 'F')
1606 					value = b - 'A' + 10;
1607 				else if(b >= 'a' && b <= 'f')
1608 					value = b - 'a' + 10;
1609 				if(state == 1) {
1610 					hexByte |= value << 4;
1611 					state++;
1612 				} else {
1613 					hexByte |= value;
1614 					ret ~= hexByte;
1615 					state = 0;
1616 				}
1617 			break;
1618 			default: assert(0);
1619 		}
1620 	}
1621 
1622 	return ret;
1623 }
1624 
1625 /// Add header UFCS helper
1626 auto with_header(MimeContainer container, string header){
1627 	container.headers ~= header;
1628 	return container;
1629 }
1630 
1631 /// Base64 range encoder UFCS helper.
1632 alias base64encode = Base64.encoder;
1633 
1634 /// Base64 encoded data with line length of 76 as mandated by RFC 2045 Section 6.8
1635 string encodeBase64Mime(const(ubyte[]) content, string LINESEP = "\r\n") {
1636 	enum LINE_LENGTH = 76;
1637 	/// Only 6 bit of every byte are used; log2(64) = 6
1638 	enum int SOURCE_CHUNK_LENGTH = LINE_LENGTH * 6/8;
1639 
1640 	return cast(immutable(char[]))content.chunks(SOURCE_CHUNK_LENGTH).base64encode.join(LINESEP);
1641 }
1642 
1643 
1644 /// Base64 range decoder UFCS helper.
1645 alias base64decode = Base64.decoder;
1646 
1647 /// Base64 decoder, ignoring linebreaks which are mandated by RFC2045
1648 immutable(ubyte[]) decodeBase64Mime(string encodedPart) {
1649 	return cast(immutable(ubyte[])) encodedPart
1650 		.byChar // prevent Autodecoding, which will break Base64 decoder. Since its base64, it's guarenteed to be 7bit ascii
1651 		.filter!((c) => (c != '\r') & (c != '\n'))
1652 		.base64decode
1653 		.array;
1654 }
1655 
1656 unittest {
1657 	// Mime base64 roundtrip
1658 	import std.algorithm.comparison;
1659 	string source = chain(
1660 		repeat('n', 1200), //long line
1661 		"\r\n",
1662 		"äöü\r\n",
1663 		"ඞ\rn",
1664 		).byChar.array;
1665 	assert( source.representation.encodeBase64Mime.decodeBase64Mime.equal(source));
1666 }
1667 
1668 unittest {
1669 	import std.algorithm;
1670 	import std.string;
1671 	// Mime message roundtrip
1672 	auto mail = new EmailMessage();
1673 	mail.to = ["recipient@example.org"];
1674 	mail.from = "sender@example.org";
1675 	mail.subject = "Subject";
1676 
1677 	auto text = cast(string) chain(
1678 			repeat('n', 1200),
1679 			"\r\n",
1680 			"äöü\r\n",
1681 			"ඞ\r\nlast",
1682 			).byChar.array;
1683 	mail.setTextBody(text);
1684 	mail.addAttachment("text/plain", "attachment.txt", text.representation);
1685 	// In case binary and plaintext get handled differently one day
1686 	mail.addAttachment("application/octet-stream", "attachment.bin", text.representation);
1687 
1688 	auto result = new IncomingEmailMessage(mail.toString().split("\r\n"));
1689 
1690 	assert(result.subject.equal(mail.subject));
1691 	assert(mail.to.canFind(result.to));
1692 	assert(result.from == mail.from.toProtocolString);
1693 
1694 	// This roundtrip works modulo trailing newline on the parsed message and LF vs CRLF
1695 	assert(result.textMessageBody.replace("\n", "\r\n").stripRight().equal(mail.textBody_));
1696 	assert(result.attachments.equal(mail.attachments));
1697 }
1698 
1699 // FIXME: add unittest with a pdf in first multipart/mixed position
1700 
1701 private bool hasAllPrintableAscii(in char[] s) {
1702 	foreach(ch; s) {
1703 		if(ch < 32)
1704 			return false;
1705 		if(ch >= 127)
1706 			return false;
1707 	}
1708 	return true;
1709 }
1710 
1711 private string encodeEmailHeaderContentForTransmit(string value, string linesep, bool prechecked = false) {
1712 	if(!prechecked && value.length < 998 && hasAllPrintableAscii(value))
1713 		return value;
1714 
1715 	return "=?UTF-8?B?" ~
1716 		encodeBase64Mime(cast(const(ubyte)[]) value, "?=" ~ linesep ~ " =?UTF-8?B?") ~
1717 		"?=";
1718 }
1719 
1720 private string encodeEmailHeaderForTransmit(string completeHeader, string linesep) {
1721 	if(completeHeader.length < 998 && hasAllPrintableAscii(completeHeader))
1722 		return completeHeader;
1723 
1724 	// note that we are here if there's a newline embedded in the content as well
1725 	auto colon = completeHeader.indexOf(":");
1726 	if(colon == -1) // should never happen!
1727 		throw new Exception("invalid email header - no colon in " ~ completeHeader); // but exception instead of assert since this might happen as result of public data manip
1728 
1729 	auto name = completeHeader[0 .. colon + 1];
1730 	if(!hasAllPrintableAscii(name)) // should never happen!
1731 		throw new Exception("invalid email header - improper name: " ~ name); // ditto
1732 
1733 	auto value = completeHeader[colon + 1 .. $].strip;
1734 
1735 	return
1736 		name ~
1737 		" " ~ // i like that leading space after the colon but it was stripped out of value
1738 		encodeEmailHeaderContentForTransmit(value, linesep, true);
1739 }
1740 
1741 unittest {
1742 	auto linesep = "\r\n";
1743 	string test = "Subject: This is an ordinary subject line with no special characters and not exceeding the maximum line length limit.";
1744 	assert(test is encodeEmailHeaderForTransmit(test, linesep)); // returned by identity
1745 
1746 	test = "Subject: foo\nbar";
1747 	assert(test !is encodeEmailHeaderForTransmit(test, linesep)); // a newline forces encoding
1748 }
1749 
1750 unittest {
1751 	auto message = new EmailMessage();
1752 		//message.to ~= "someusadssssssssssssssssssssssssssssssssssssssssssssssssssssssser@example.com";
1753 	foreach(i; 0 .. 10)
1754 		message.to ~= "someuser@example.com";
1755 		//message.to ~= "someusadssssssssssssssssssssssssssssssssssssssssssssssssssssssser@example.com";
1756 	//foreach(i; 0 .. 10)
1757 		//message.to ~= "someuser@example.com";
1758 	message.from = "youremail@example.com";
1759 	message.subject = "My Subject";
1760 	message.setTextBody("hi there");
1761 	int lineLength;
1762 
1763 	//import arsd.core; writeln(message.toString());
1764 
1765 	foreach(char c; message.toString()) {
1766 		if(c == 13) {
1767 			assert(lineLength < 78);
1768 			lineLength = 0;
1769 		} else {
1770 			lineLength++;
1771 		}
1772 	}
1773 	assert(lineLength < 78);
1774 }
1775 
1776 /+
1777 void main() {
1778 	import std.file;
1779 	import std.stdio;
1780 
1781 	auto data = cast(immutable(ubyte)[]) std.file.read("/home/me/test_email_data");
1782 	foreach(message; processMboxData(data)) {
1783 		writeln(message.subject);
1784 		writeln(message.textMessageBody);
1785 		writeln("**************** END MESSSAGE **************");
1786 	}
1787 }
1788 +/