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 module arsd.email; 5 6 import std.net.curl; 7 pragma(lib, "curl"); 8 9 import std.base64; 10 import std.string; 11 12 import arsd.characterencodings; 13 14 // import std.uuid; 15 // smtpMessageBoundary = randomUUID().toString(); 16 17 // SEE ALSO: std.net.curl.SMTP 18 19 /// 20 struct RelayInfo { 21 string server; /// 22 string username; /// 23 string password; /// 24 } 25 26 /// 27 struct MimeAttachment { 28 string type; /// 29 string filename; /// 30 const(ubyte)[] content; /// 31 string id; /// 32 } 33 34 /// 35 enum ToType { 36 to, 37 cc, 38 bcc 39 } 40 41 42 /++ 43 For OUTGOING email 44 45 46 To use: 47 48 --- 49 auto message = new EmailMessage(); 50 message.to ~= "someuser@example.com"; 51 message.from = "youremail@example.com"; 52 message.subject = "My Subject"; 53 message.setTextBody("hi there"); 54 //message.toString(); // get string to send externally 55 message.send(); // send via some relay 56 // may also set replyTo, etc 57 --- 58 +/ 59 class EmailMessage { 60 /// 61 void setHeader(string name, string value) { 62 headers ~= name ~ ": " ~ value; 63 } 64 65 string[] to; /// 66 string[] cc; /// 67 string[] bcc; /// 68 string from; /// 69 string replyTo; /// 70 string inReplyTo; /// 71 string textBody; 72 string htmlBody; 73 string subject; /// 74 75 string[] headers; 76 77 private bool isMime = false; 78 private bool isHtml = false; 79 80 /// 81 void addRecipient(string name, string email, ToType how = ToType.to) { 82 addRecipient(`"`~name~`" <`~email~`>`, how); 83 } 84 85 /// 86 void addRecipient(string who, ToType how = ToType.to) { 87 final switch(how) { 88 case ToType.to: 89 to ~= who; 90 break; 91 case ToType.cc: 92 cc ~= who; 93 break; 94 case ToType.bcc: 95 bcc ~= who; 96 break; 97 } 98 } 99 100 /// 101 void setTextBody(string text) { 102 textBody = text.strip; 103 } 104 /// automatically sets a text fallback if you haven't already 105 void setHtmlBody()(string html) { 106 isMime = true; 107 isHtml = true; 108 htmlBody = html; 109 110 import arsd.htmltotext; 111 if(textBody is null) 112 textBody = htmlToText(html); 113 } 114 115 const(MimeAttachment)[] attachments; 116 117 /++ 118 The filename is what is shown to the user, not the file on your sending computer. It should NOT have a path in it. 119 120 --- 121 message.addAttachment("text/plain", "something.txt", std.file.read("/path/to/local/something.txt")); 122 --- 123 +/ 124 void addAttachment(string mimeType, string filename, const void[] content, string id = null) { 125 isMime = true; 126 attachments ~= MimeAttachment(mimeType, filename, cast(const(ubyte)[]) content, id); 127 } 128 129 /// in the html, use img src="cid:ID_GIVEN_HERE" 130 void addInlineImage(string id, string mimeType, string filename, const void[] content) { 131 assert(isHtml); 132 isMime = true; 133 inlineImages ~= MimeAttachment(mimeType, filename, cast(const(ubyte)[]) content, id); 134 } 135 136 const(MimeAttachment)[] inlineImages; 137 138 139 /* we should build out the mime thingy 140 related 141 mixed 142 alternate 143 */ 144 145 /// Returns the MIME formatted email string, including encoded attachments 146 override string toString() { 147 assert(!isHtml || (isHtml && isMime)); 148 149 auto headers = this.headers; 150 151 if(to.length) 152 headers ~= "To: " ~ join(to, ", "); 153 if(cc.length) 154 headers ~= "Cc: " ~ join(cc, ", "); 155 156 if(from.length) 157 headers ~= "From: " ~ from; 158 159 if(subject !is null) 160 headers ~= "Subject: " ~ subject; 161 if(replyTo !is null) 162 headers ~= "Reply-To: " ~ replyTo; 163 if(inReplyTo !is null) 164 headers ~= "In-Reply-To: " ~ inReplyTo; 165 166 if(isMime) 167 headers ~= "MIME-Version: 1.0"; 168 169 /+ 170 if(inlineImages.length) { 171 headers ~= "Content-Type: multipart/related; boundary=" ~ boundary; 172 // so we put the alternative inside asthe first attachment with as seconary boundary 173 // then we do the images 174 } else 175 if(attachments.length) 176 headers ~= "Content-Type: multipart/mixed; boundary=" ~ boundary; 177 else if(isHtml) 178 headers ~= "Content-Type: multipart/alternative; boundary=" ~ boundary; 179 else 180 headers ~= "Content-Type: text/plain; charset=UTF-8"; 181 +/ 182 183 184 string msgContent; 185 186 if(isMime) { 187 MimeContainer top; 188 189 { 190 MimeContainer mimeMessage; 191 if(isHtml) { 192 auto alternative = new MimeContainer("multipart/alternative"); 193 alternative.stuff ~= new MimeContainer("text/plain; charset=UTF-8", textBody); 194 alternative.stuff ~= new MimeContainer("text/html; charset=UTF-8", htmlBody); 195 mimeMessage = alternative; 196 } else { 197 mimeMessage = new MimeContainer("text/plain; charset=UTF-8", textBody); 198 } 199 top = mimeMessage; 200 } 201 202 { 203 MimeContainer mimeRelated; 204 if(inlineImages.length) { 205 mimeRelated = new MimeContainer("multipart/related"); 206 207 mimeRelated.stuff ~= top; 208 top = mimeRelated; 209 210 foreach(attachment; inlineImages) { 211 auto mimeAttachment = new MimeContainer(attachment.type ~ "; name=\""~attachment.filename~"\""); 212 mimeAttachment.headers ~= "Content-Transfer-Encoding: base64"; 213 mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">"; 214 mimeAttachment.content = Base64.encode(cast(const(ubyte)[]) attachment.content); 215 216 mimeRelated.stuff ~= mimeAttachment; 217 } 218 } 219 } 220 221 { 222 MimeContainer mimeMixed; 223 if(attachments.length) { 224 mimeMixed = new MimeContainer("multipart/mixed"); 225 226 mimeMixed.stuff ~= top; 227 top = mimeMixed; 228 229 foreach(attachment; attachments) { 230 auto mimeAttachment = new MimeContainer(attachment.type); 231 mimeAttachment.headers ~= "Content-Disposition: attachment; filename=\""~attachment.filename~"\""; 232 mimeAttachment.headers ~= "Content-Transfer-Encoding: base64"; 233 if(attachment.id.length) 234 mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">"; 235 236 mimeAttachment.content = Base64.encode(cast(const(ubyte)[]) attachment.content); 237 238 mimeMixed.stuff ~= mimeAttachment; 239 } 240 } 241 } 242 243 headers ~= top.contentType; 244 msgContent = top.toMimeString(true); 245 } else { 246 headers ~= "Content-Type: text/plain; charset=UTF-8"; 247 msgContent = textBody; 248 } 249 250 251 string msg; 252 msg.reserve(htmlBody.length + textBody.length + 1024); 253 254 foreach(header; headers) 255 msg ~= header ~ "\r\n"; 256 if(msg.length) // has headers 257 msg ~= "\r\n"; 258 259 msg ~= msgContent; 260 261 return msg; 262 } 263 264 /// Sends via a given SMTP relay 265 void send(RelayInfo mailServer = RelayInfo("smtp://localhost")) { 266 auto smtp = SMTP(mailServer.server); 267 268 smtp.verifyHost = false; 269 smtp.verifyPeer = false; 270 //smtp.verbose = true; 271 272 { 273 // std.net.curl doesn't work well with STARTTLS if you don't 274 // put smtps://... and if you do, it errors if you can't start 275 // with a TLS connection from the beginning. 276 277 // This change allows ssl if it can. 278 import std.net.curl; 279 import etc.c.curl; 280 smtp.handle.set(CurlOption.use_ssl, CurlUseSSL.tryssl); 281 } 282 283 if(mailServer.username.length) 284 smtp.setAuthentication(mailServer.username, mailServer.password); 285 286 const(char)[][] allRecipients; 287 void processPerson(string person) { 288 auto idx = person.indexOf("<"); 289 if(idx == -1) 290 allRecipients ~= person; 291 else { 292 person = person[idx + 1 .. $]; 293 idx = person.indexOf(">"); 294 if(idx != -1) 295 person = person[0 .. idx]; 296 297 allRecipients ~= person; 298 } 299 } 300 foreach(person; to) processPerson(person); 301 foreach(person; cc) processPerson(person); 302 foreach(person; bcc) processPerson(person); 303 304 smtp.mailTo(allRecipients); 305 306 auto mailFrom = from; 307 auto idx = mailFrom.indexOf("<"); 308 if(idx != -1) 309 mailFrom = mailFrom[idx + 1 .. $]; 310 idx = mailFrom.indexOf(">"); 311 if(idx != -1) 312 mailFrom = mailFrom[0 .. idx]; 313 314 smtp.mailFrom = mailFrom; 315 smtp.message = this.toString(); 316 smtp.perform(); 317 } 318 } 319 320 /// 321 void email(string to, string subject, string message, string from, RelayInfo mailServer = RelayInfo("smtp://localhost")) { 322 auto msg = new EmailMessage(); 323 msg.from = from; 324 msg.to = [to]; 325 msg.subject = subject; 326 msg.textBody = message; 327 msg.send(mailServer); 328 } 329 330 // private: 331 332 import std.conv; 333 334 /// for reading 335 class MimePart { 336 string[] headers; 337 immutable(ubyte)[] content; 338 immutable(ubyte)[] encodedContent; // usually valid only for GPG, and will be cleared by creator; canonical form 339 string textContent; 340 MimePart[] stuff; 341 342 string name; 343 string charset; 344 string type; 345 string transferEncoding; 346 string disposition; 347 string id; 348 string filename; 349 // gpg signatures 350 string gpgalg; 351 string gpgproto; 352 353 MimeAttachment toMimeAttachment() { 354 if(type == "multipart/mixed" && stuff.length == 1) 355 return stuff[0].toMimeAttachment; 356 357 MimeAttachment att; 358 att.type = type; 359 if(att.type == "application/octet-stream" && filename.length == 0 && name.length > 0 ) { 360 att.filename = name; 361 } else { 362 att.filename = filename; 363 } 364 att.id = id; 365 att.content = content; 366 return att; 367 } 368 369 this(immutable(ubyte)[][] lines, string contentType = null) { 370 string boundary; 371 372 void parseContentType(string content) { 373 //{ import std.stdio; writeln("c=[", content, "]"); } 374 foreach(k, v; breakUpHeaderParts(content)) { 375 //{ import std.stdio; writeln(" k=[", k, "]; v=[", v, "]"); } 376 switch(k) { 377 case "root": 378 type = v; 379 break; 380 case "name": 381 name = v; 382 break; 383 case "charset": 384 charset = v; 385 break; 386 case "boundary": 387 boundary = v; 388 break; 389 default: 390 case "micalg": 391 gpgalg = v; 392 break; 393 case "protocol": 394 gpgproto = v; 395 break; 396 } 397 } 398 } 399 400 if(contentType is null) { 401 // read headers immediately... 402 auto copyOfLines = lines; 403 immutable(ubyte)[] currentHeader; 404 405 void commitHeader() { 406 if(currentHeader.length == 0) 407 return; 408 string h = decodeEncodedWord(cast(string) currentHeader); 409 headers ~= h; 410 currentHeader = null; 411 412 auto idx = h.indexOf(":"); 413 if(idx != -1) { 414 auto name = h[0 .. idx].strip.toLower; 415 auto content = h[idx + 1 .. $].strip; 416 417 string[4] filenames_found; 418 419 switch(name) { 420 case "content-type": 421 parseContentType(content); 422 break; 423 case "content-transfer-encoding": 424 transferEncoding = content.toLower; 425 break; 426 case "content-disposition": 427 foreach(k, v; breakUpHeaderParts(content)) { 428 switch(k) { 429 case "root": 430 disposition = v; 431 break; 432 case "filename": 433 filename = v; 434 break; 435 // FIXME: https://datatracker.ietf.org/doc/html/rfc2184#section-3 is what it is SUPPOSED to do 436 case "filename*0": 437 filenames_found[0] = v; 438 break; 439 case "filename*1": 440 filenames_found[1] = v; 441 break; 442 case "filename*2": 443 filenames_found[2] = v; 444 break; 445 case "filename*3": 446 filenames_found[3] = v; 447 break; 448 default: 449 } 450 } 451 break; 452 case "content-id": 453 id = content; 454 break; 455 default: 456 } 457 458 if (filenames_found[0] != "") { 459 foreach (string v; filenames_found) { 460 this.filename ~= v; 461 } 462 } 463 } 464 } 465 466 foreach(line; copyOfLines) { 467 lines = lines[1 .. $]; 468 if(line.length == 0) 469 break; 470 471 if(line[0] == ' ' || line[0] == '\t') 472 currentHeader ~= (cast(string) line).stripLeft(); 473 else { 474 if(currentHeader.length) { 475 commitHeader(); 476 } 477 currentHeader = line; 478 } 479 } 480 481 commitHeader(); 482 } else { 483 parseContentType(contentType); 484 } 485 486 // if it is multipart, find the start boundary. we'll break it up and fill in stuff 487 // otherwise, all the data that follows is just content 488 489 if(boundary.length) { 490 immutable(ubyte)[][] partLines; 491 bool inPart; 492 foreach(line; lines) { 493 if(line.startsWith("--" ~ boundary)) { 494 if(inPart) 495 stuff ~= new MimePart(partLines); 496 inPart = true; 497 partLines = null; 498 499 if(line == "--" ~ boundary ~ "--") 500 break; // all done 501 } 502 503 if(inPart) { 504 partLines ~= line; 505 } else { 506 content ~= line ~ '\n'; 507 } 508 } 509 } else { 510 foreach(line; lines) { 511 content ~= line; 512 513 if(transferEncoding != "base64") 514 content ~= '\n'; 515 } 516 } 517 518 // store encoded content for GPG (should be cleared by caller if necessary) 519 encodedContent = content; 520 521 // decode the content.. 522 switch(transferEncoding) { 523 case "base64": 524 content = Base64.decode(cast(string) content); 525 break; 526 case "quoted-printable": 527 content = decodeQuotedPrintable(cast(string) content); 528 break; 529 default: 530 // no change needed (I hope) 531 } 532 533 if(type.indexOf("text/") == 0) { 534 if(charset.length == 0) 535 charset = "latin1"; 536 textContent = convertToUtf8Lossy(content, charset); 537 } 538 } 539 } 540 541 string[string] breakUpHeaderParts(string headerContent) { 542 string[string] ret; 543 544 string currentName = "root"; 545 string currentContent; 546 bool inQuote = false; 547 bool gettingName = false; 548 bool ignoringSpaces = false; 549 foreach(char c; headerContent) { 550 if(ignoringSpaces) { 551 if(c == ' ') 552 continue; 553 else 554 ignoringSpaces = false; 555 } 556 557 if(gettingName) { 558 if(c == '=') { 559 gettingName = false; 560 continue; 561 } 562 currentName ~= c; 563 } 564 565 if(c == '"') { 566 inQuote = !inQuote; 567 continue; 568 } 569 570 if(!inQuote && c == ';') { 571 ret[currentName] = currentContent; 572 ignoringSpaces = true; 573 currentName = null; 574 currentContent = null; 575 576 gettingName = true; 577 continue; 578 } 579 580 if(!gettingName) 581 currentContent ~= c; 582 } 583 584 if(currentName.length) 585 ret[currentName] = currentContent; 586 587 return ret; 588 } 589 590 // for writing 591 class MimeContainer { 592 private static int sequence; 593 594 immutable string _contentType; 595 immutable string boundary; 596 597 string[] headers; // NOT including content-type 598 string content; 599 MimeContainer[] stuff; 600 601 this(string contentType, string content = null) { 602 this._contentType = contentType; 603 this.content = content; 604 sequence++; 605 if(_contentType.indexOf("multipart/") == 0) 606 boundary = "0016e64be86203dd36047610926a" ~ to!string(sequence); 607 } 608 609 @property string contentType() { 610 string ct = "Content-Type: "~_contentType; 611 if(boundary.length) 612 ct ~= "; boundary=" ~ boundary; 613 return ct; 614 } 615 616 617 string toMimeString(bool isRoot = false) { 618 string ret; 619 620 if(!isRoot) { 621 ret ~= contentType; 622 foreach(header; headers) { 623 ret ~= "\r\n"; 624 ret ~= header; 625 } 626 ret ~= "\r\n\r\n"; 627 } 628 629 ret ~= content; 630 631 foreach(idx, thing; stuff) { 632 assert(boundary.length); 633 ret ~= "\r\n--" ~ boundary ~ "\r\n"; 634 ret ~= thing.toMimeString(false); 635 } 636 637 if(boundary.length) 638 ret ~= "\r\n--" ~ boundary ~ "--"; 639 640 return ret; 641 } 642 } 643 644 import std.algorithm : startsWith; 645 /// 646 class IncomingEmailMessage { 647 /// 648 this(string[] lines) { 649 auto lns = cast(immutable(ubyte)[][])lines; 650 this(lns, false); 651 } 652 653 /// 654 this(ref immutable(ubyte)[][] mboxLines, bool asmbox=true) { 655 656 enum ParseState { 657 lookingForFrom, 658 readingHeaders, 659 readingBody 660 } 661 662 auto state = (asmbox ? ParseState.lookingForFrom : ParseState.readingHeaders); 663 string contentType; 664 665 bool isMultipart; 666 bool isHtml; 667 immutable(ubyte)[][] mimeLines; 668 669 string charset = "latin-1"; 670 671 string contentTransferEncoding; 672 673 string headerName; 674 string headerContent; 675 void commitHeader() { 676 if(headerName is null) 677 return; 678 679 headerName = headerName.toLower(); 680 headerContent = headerContent.strip(); 681 682 headerContent = decodeEncodedWord(headerContent); 683 684 if(headerName == "content-type") { 685 contentType = headerContent; 686 if(contentType.indexOf("multipart/") != -1) 687 isMultipart = true; 688 else if(contentType.indexOf("text/html") != -1) 689 isHtml = true; 690 691 auto charsetIdx = contentType.indexOf("charset="); 692 if(charsetIdx != -1) { 693 string cs = contentType[charsetIdx + "charset=".length .. $]; 694 if(cs.length && cs[0] == '\"') 695 cs = cs[1 .. $]; 696 697 auto quoteIdx = cs.indexOf("\""); 698 if(quoteIdx != -1) 699 cs = cs[0 .. quoteIdx]; 700 auto semicolonIdx = cs.indexOf(";"); 701 if(semicolonIdx != -1) 702 cs = cs[0 .. semicolonIdx]; 703 704 cs = cs.strip(); 705 if(cs.length) 706 charset = cs.toLower(); 707 } 708 } else if(headerName == "from") { 709 this.from = headerContent; 710 } else if(headerName == "to") { 711 this.to = headerContent; 712 } else if(headerName == "subject") { 713 this.subject = headerContent; 714 } else if(headerName == "content-transfer-encoding") { 715 contentTransferEncoding = headerContent; 716 } 717 718 headers[headerName] = headerContent; 719 headerName = null; 720 headerContent = null; 721 } 722 723 lineLoop: while(mboxLines.length) { 724 // this can needlessly convert headers too, but that won't harm anything since they are 7 bit anyway 725 auto line = convertToUtf8Lossy(mboxLines[0], charset); 726 auto origline = line; 727 line = line.stripRight; 728 729 final switch(state) { 730 case ParseState.lookingForFrom: 731 if(line.startsWith("From ")) 732 state = ParseState.readingHeaders; 733 break; 734 case ParseState.readingHeaders: 735 if(line.length == 0) { 736 commitHeader(); 737 state = ParseState.readingBody; 738 } else { 739 if(line[0] == ' ' || line[0] == '\t') { 740 headerContent ~= " " ~ line.stripLeft(); 741 } else { 742 commitHeader(); 743 744 auto idx = line.indexOf(":"); 745 if(idx == -1) 746 headerName = line; 747 else { 748 headerName = line[0 .. idx]; 749 headerContent = line[idx + 1 .. $].stripLeft(); 750 } 751 } 752 } 753 break; 754 case ParseState.readingBody: 755 if (asmbox) { 756 if(line.startsWith("From ")) { 757 break lineLoop; // we're at the beginning of the next messsage 758 } 759 if(line.startsWith(">>From") || line.startsWith(">From")) { 760 line = line[1 .. $]; 761 } 762 } 763 764 if(isMultipart) { 765 mimeLines ~= mboxLines[0]; 766 } else if(isHtml) { 767 // html with no alternative and no attachments 768 htmlMessageBody ~= line ~ "\n"; 769 } else { 770 // plain text! 771 // we want trailing spaces for "format=flowed", for example, so... 772 line = origline; 773 size_t epos = line.length; 774 while (epos > 0) { 775 char ch = line.ptr[epos-1]; 776 if (ch >= ' ' || ch == '\t') break; 777 --epos; 778 } 779 line = line.ptr[0..epos]; 780 textMessageBody ~= line ~ "\n"; 781 } 782 break; 783 } 784 785 mboxLines = mboxLines[1 .. $]; 786 } 787 788 if(mimeLines.length) { 789 auto part = new MimePart(mimeLines, contentType); 790 deeperInTheMimeTree: 791 switch(part.type) { 792 case "text/html": 793 htmlMessageBody = part.textContent; 794 break; 795 case "text/plain": 796 textMessageBody = part.textContent; 797 break; 798 case "multipart/alternative": 799 foreach(p; part.stuff) { 800 if(p.type == "text/html") 801 htmlMessageBody = p.textContent; 802 else if(p.type == "text/plain") 803 textMessageBody = p.textContent; 804 } 805 break; 806 case "multipart/related": 807 // the first one is the message itself 808 // after that comes attachments that can be rendered inline 809 if(part.stuff.length) { 810 auto msg = part.stuff[0]; 811 foreach(thing; part.stuff[1 .. $]) { 812 // FIXME: should this be special? 813 attachments ~= thing.toMimeAttachment(); 814 } 815 part = msg; 816 goto deeperInTheMimeTree; 817 } 818 break; 819 case "multipart/mixed": 820 if(part.stuff.length) { 821 auto msg = part.stuff[0]; 822 foreach(thing; part.stuff[1 .. $]) { 823 attachments ~= thing.toMimeAttachment(); 824 } 825 part = msg; 826 goto deeperInTheMimeTree; 827 } 828 829 // FIXME: the more proper way is: 830 // check the disposition 831 // if none, concat it to make a text message body 832 // if inline it is prolly an image to be concated in the other body 833 // if attachment, it is an attachment 834 break; 835 case "multipart/signed": 836 // FIXME: it would be cool to actually check the signature 837 if (part.stuff.length) { 838 auto msg = part.stuff[0]; 839 //{ import std.stdio; writeln("hdrs: ", part.stuff[0].headers); } 840 gpgalg = part.gpgalg; 841 gpgproto = part.gpgproto; 842 gpgmime = part; 843 foreach (thing; part.stuff[1 .. $]) { 844 attachments ~= thing.toMimeAttachment(); 845 } 846 part = msg; 847 goto deeperInTheMimeTree; 848 } 849 break; 850 default: 851 // FIXME: correctly handle more 852 if(part.stuff.length) { 853 part = part.stuff[0]; 854 goto deeperInTheMimeTree; 855 } 856 } 857 } else { 858 switch(contentTransferEncoding) { 859 case "quoted-printable": 860 if(textMessageBody.length) 861 textMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(textMessageBody), charset); 862 if(htmlMessageBody.length) 863 htmlMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(htmlMessageBody), charset); 864 break; 865 case "base64": 866 if(textMessageBody.length) { 867 // alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here 868 char[] mmb; 869 mmb.reserve(textMessageBody.length); 870 foreach (char ch; textMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch; 871 textMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset); 872 } 873 if(htmlMessageBody.length) { 874 // alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here 875 char[] mmb; 876 mmb.reserve(htmlMessageBody.length); 877 foreach (char ch; htmlMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch; 878 htmlMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset); 879 } 880 881 break; 882 default: 883 // nothing needed 884 } 885 } 886 887 if(htmlMessageBody.length > 0 && textMessageBody.length == 0) { 888 import arsd.htmltotext; 889 textMessageBody = htmlToText(htmlMessageBody); 890 textAutoConverted = true; 891 } 892 } 893 894 /// 895 @property bool hasGPGSignature () const nothrow @trusted @nogc { 896 MimePart mime = cast(MimePart)gpgmime; // sorry 897 if (mime is null) return false; 898 if (mime.type != "multipart/signed") return false; 899 if (mime.stuff.length != 2) return false; 900 if (mime.stuff[1].type != "application/pgp-signature") return false; 901 if (mime.stuff[0].type.length <= 5 && mime.stuff[0].type[0..5] != "text/") return false; 902 return true; 903 } 904 905 /// 906 ubyte[] extractGPGData () const nothrow @trusted { 907 if (!hasGPGSignature) return null; 908 MimePart mime = cast(MimePart)gpgmime; // sorry 909 char[] res; 910 res.reserve(mime.stuff[0].encodedContent.length); // more, actually 911 foreach (string s; mime.stuff[0].headers[1..$]) { 912 while (s.length && s[$-1] <= ' ') s = s[0..$-1]; 913 if (s.length == 0) return null; // wtf?! empty headers? 914 res ~= s; 915 res ~= "\r\n"; 916 } 917 res ~= "\r\n"; 918 // extract content (see rfc3156) 919 size_t pos = 0; 920 auto ctt = mime.stuff[0].encodedContent; 921 // last CR/LF is a part of mime signature, actually, so remove it 922 if (ctt.length && ctt[$-1] == '\n') { 923 ctt = ctt[0..$-1]; 924 if (ctt.length && ctt[$-1] == '\r') ctt = ctt[0..$-1]; 925 } 926 while (pos < ctt.length) { 927 auto epos = pos; 928 while (epos < ctt.length && ctt.ptr[epos] != '\n') ++epos; 929 auto xpos = epos; 930 while (xpos > pos && ctt.ptr[xpos-1] <= ' ') --xpos; // according to rfc 931 res ~= ctt[pos..xpos].dup; 932 res ~= "\r\n"; // according to rfc 933 pos = epos+1; 934 } 935 return cast(ubyte[])res; 936 } 937 938 /// 939 immutable(ubyte)[] extractGPGSignature () const nothrow @safe @nogc { 940 if (!hasGPGSignature) return null; 941 return gpgmime.stuff[1].content; 942 } 943 944 string[string] headers; /// 945 946 string subject; /// 947 948 string htmlMessageBody; /// 949 string textMessageBody; /// 950 951 string from; /// 952 string to; /// 953 954 bool textAutoConverted; /// 955 956 MimeAttachment[] attachments; /// 957 958 // gpg signature fields 959 string gpgalg; /// 960 string gpgproto; /// 961 MimePart gpgmime; /// 962 963 /// 964 string fromEmailAddress() { 965 auto i = from.indexOf("<"); 966 if(i == -1) 967 return from; 968 auto e = from.indexOf(">"); 969 return from[i + 1 .. e]; 970 } 971 972 /// 973 string toEmailAddress() { 974 auto i = to.indexOf("<"); 975 if(i == -1) 976 return to; 977 auto e = to.indexOf(">"); 978 return to[i + 1 .. e]; 979 } 980 } 981 982 /// 983 struct MboxMessages { 984 immutable(ubyte)[][] linesRemaining; 985 986 /// 987 this(immutable(ubyte)[] data) { 988 linesRemaining = splitLinesWithoutDecoding(data); 989 popFront(); 990 } 991 992 IncomingEmailMessage currentFront; 993 994 /// 995 IncomingEmailMessage front() { 996 return currentFront; 997 } 998 999 /// 1000 bool empty() { 1001 return currentFront is null; 1002 } 1003 1004 /// 1005 void popFront() { 1006 if(linesRemaining.length) 1007 currentFront = new IncomingEmailMessage(linesRemaining); 1008 else 1009 currentFront = null; 1010 } 1011 } 1012 1013 /// 1014 MboxMessages processMboxData(immutable(ubyte)[] data) { 1015 return MboxMessages(data); 1016 } 1017 1018 immutable(ubyte)[][] splitLinesWithoutDecoding(immutable(ubyte)[] data) { 1019 immutable(ubyte)[][] ret; 1020 1021 size_t starting = 0; 1022 bool justSaw13 = false; 1023 foreach(idx, b; data) { 1024 if(b == 13) 1025 justSaw13 = true; 1026 1027 if(b == 10) { 1028 auto use = idx; 1029 if(justSaw13) 1030 use--; 1031 1032 ret ~= data[starting .. use]; 1033 starting = idx + 1; 1034 } 1035 1036 if(b != 13) 1037 justSaw13 = false; 1038 } 1039 1040 if(starting < data.length) 1041 ret ~= data[starting .. $]; 1042 1043 return ret; 1044 } 1045 1046 string decodeEncodedWord(string data) { 1047 string originalData = data; 1048 1049 auto delimiter = data.indexOf("=?"); 1050 if(delimiter == -1) 1051 return data; 1052 1053 string ret; 1054 1055 while(delimiter != -1) { 1056 ret ~= data[0 .. delimiter]; 1057 data = data[delimiter + 2 .. $]; 1058 1059 string charset; 1060 string encoding; 1061 string encodedText; 1062 1063 // FIXME: the insane things should probably throw an 1064 // exception that keeps a copy of orignal data for use later 1065 1066 auto questionMark = data.indexOf("?"); 1067 if(questionMark == -1) return originalData; // not sane 1068 1069 charset = data[0 .. questionMark]; 1070 data = data[questionMark + 1 .. $]; 1071 1072 questionMark = data.indexOf("?"); 1073 if(questionMark == -1) return originalData; // not sane 1074 1075 encoding = data[0 .. questionMark]; 1076 data = data[questionMark + 1 .. $]; 1077 1078 questionMark = data.indexOf("?="); 1079 if(questionMark == -1) return originalData; // not sane 1080 1081 encodedText = data[0 .. questionMark]; 1082 data = data[questionMark + 2 .. $]; 1083 1084 delimiter = data.indexOf("=?"); 1085 if (delimiter == 1 && data[0] == ' ') { 1086 // a single space between encoded words must be ignored because it is 1087 // used to separate multiple encoded words (RFC2047 says CRLF SPACE but a most clients 1088 // just use a space) 1089 data = data[1..$]; 1090 delimiter = 0; 1091 } 1092 1093 immutable(ubyte)[] decodedText; 1094 if(encoding == "Q" || encoding == "q") 1095 decodedText = decodeQuotedPrintable(encodedText); 1096 else if(encoding == "B" || encoding == "b") 1097 decodedText = cast(typeof(decodedText)) Base64.decode(encodedText); 1098 else 1099 return originalData; // wtf 1100 1101 ret ~= convertToUtf8Lossy(decodedText, charset); 1102 } 1103 1104 ret ~= data; // keep the rest since there could be trailing stuff 1105 1106 return ret; 1107 } 1108 1109 immutable(ubyte)[] decodeQuotedPrintable(string text) { 1110 immutable(ubyte)[] ret; 1111 1112 int state = 0; 1113 ubyte hexByte; 1114 foreach(b; cast(immutable(ubyte)[]) text) { 1115 switch(state) { 1116 case 0: 1117 if(b == '=') { 1118 state++; 1119 hexByte = 0; 1120 } else if (b == '_') { // RFC2047 4.2.2: a _ may be used to represent a space 1121 ret ~= ' '; 1122 } else 1123 ret ~= b; 1124 break; 1125 case 1: 1126 if(b == '\n') { 1127 state = 0; 1128 continue; 1129 } 1130 goto case; 1131 case 2: 1132 int value; 1133 if(b >= '0' && b <= '9') 1134 value = b - '0'; 1135 else if(b >= 'A' && b <= 'F') 1136 value = b - 'A' + 10; 1137 else if(b >= 'a' && b <= 'f') 1138 value = b - 'a' + 10; 1139 if(state == 1) { 1140 hexByte |= value << 4; 1141 state++; 1142 } else { 1143 hexByte |= value; 1144 ret ~= hexByte; 1145 state = 0; 1146 } 1147 break; 1148 default: assert(0); 1149 } 1150 } 1151 1152 return ret; 1153 } 1154 1155 /+ 1156 void main() { 1157 import std.file; 1158 import std.stdio; 1159 1160 auto data = cast(immutable(ubyte)[]) std.file.read("/home/me/test_email_data"); 1161 foreach(message; processMboxData(data)) { 1162 writeln(message.subject); 1163 writeln(message.textMessageBody); 1164 writeln("**************** END MESSSAGE **************"); 1165 } 1166 } 1167 +/