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