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