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 MimeAttachment att; 355 att.type = type; 356 att.filename = filename; 357 att.id = id; 358 att.content = content; 359 return att; 360 } 361 362 this(immutable(ubyte)[][] lines, string contentType = null) { 363 string boundary; 364 365 void parseContentType(string content) { 366 //{ import std.stdio; writeln("c=[", content, "]"); } 367 foreach(k, v; breakUpHeaderParts(content)) { 368 //{ import std.stdio; writeln(" k=[", k, "]; v=[", v, "]"); } 369 switch(k) { 370 case "root": 371 type = v; 372 break; 373 case "name": 374 name = v; 375 break; 376 case "charset": 377 charset = v; 378 break; 379 case "boundary": 380 boundary = v; 381 break; 382 default: 383 case "micalg": 384 gpgalg = v; 385 break; 386 case "protocol": 387 gpgproto = v; 388 break; 389 } 390 } 391 } 392 393 if(contentType is null) { 394 // read headers immediately... 395 auto copyOfLines = lines; 396 immutable(ubyte)[] currentHeader; 397 398 void commitHeader() { 399 if(currentHeader.length == 0) 400 return; 401 string h = decodeEncodedWord(cast(string) currentHeader); 402 headers ~= h; 403 currentHeader = null; 404 405 auto idx = h.indexOf(":"); 406 if(idx != -1) { 407 auto name = h[0 .. idx].strip.toLower; 408 auto content = h[idx + 1 .. $].strip; 409 410 switch(name) { 411 case "content-type": 412 parseContentType(content); 413 break; 414 case "content-transfer-encoding": 415 transferEncoding = content.toLower; 416 break; 417 case "content-disposition": 418 foreach(k, v; breakUpHeaderParts(content)) { 419 switch(k) { 420 case "root": 421 disposition = v; 422 break; 423 case "filename": 424 filename = v; 425 break; 426 default: 427 } 428 } 429 break; 430 case "content-id": 431 id = content; 432 break; 433 default: 434 } 435 } 436 } 437 438 foreach(line; copyOfLines) { 439 lines = lines[1 .. $]; 440 if(line.length == 0) 441 break; 442 443 if(line[0] == ' ' || line[0] == '\t') 444 currentHeader ~= (cast(string) line).stripLeft(); 445 else { 446 if(currentHeader.length) { 447 commitHeader(); 448 } 449 currentHeader = line; 450 } 451 } 452 453 commitHeader(); 454 } else { 455 parseContentType(contentType); 456 } 457 458 // if it is multipart, find the start boundary. we'll break it up and fill in stuff 459 // otherwise, all the data that follows is just content 460 461 if(boundary.length) { 462 immutable(ubyte)[][] partLines; 463 bool inPart; 464 foreach(line; lines) { 465 if(line.startsWith("--" ~ boundary)) { 466 if(inPart) 467 stuff ~= new MimePart(partLines); 468 inPart = true; 469 partLines = null; 470 471 if(line == "--" ~ boundary ~ "--") 472 break; // all done 473 } 474 475 if(inPart) { 476 partLines ~= line; 477 } else { 478 content ~= line ~ '\n'; 479 } 480 } 481 } else { 482 foreach(line; lines) { 483 content ~= line; 484 485 if(transferEncoding != "base64") 486 content ~= '\n'; 487 } 488 } 489 490 // store encoded content for GPG (should be cleared by caller if necessary) 491 encodedContent = content; 492 493 // decode the content.. 494 switch(transferEncoding) { 495 case "base64": 496 content = Base64.decode(cast(string) content); 497 break; 498 case "quoted-printable": 499 content = decodeQuotedPrintable(cast(string) content); 500 break; 501 default: 502 // no change needed (I hope) 503 } 504 505 if(type.indexOf("text/") == 0) { 506 if(charset.length == 0) 507 charset = "latin1"; 508 textContent = convertToUtf8Lossy(content, charset); 509 } 510 } 511 } 512 513 string[string] breakUpHeaderParts(string headerContent) { 514 string[string] ret; 515 516 string currentName = "root"; 517 string currentContent; 518 bool inQuote = false; 519 bool gettingName = false; 520 bool ignoringSpaces = false; 521 foreach(char c; headerContent) { 522 if(ignoringSpaces) { 523 if(c == ' ') 524 continue; 525 else 526 ignoringSpaces = false; 527 } 528 529 if(gettingName) { 530 if(c == '=') { 531 gettingName = false; 532 continue; 533 } 534 currentName ~= c; 535 } 536 537 if(c == '"') { 538 inQuote = !inQuote; 539 continue; 540 } 541 542 if(!inQuote && c == ';') { 543 ret[currentName] = currentContent; 544 ignoringSpaces = true; 545 currentName = null; 546 currentContent = null; 547 548 gettingName = true; 549 continue; 550 } 551 552 if(!gettingName) 553 currentContent ~= c; 554 } 555 556 if(currentName.length) 557 ret[currentName] = currentContent; 558 559 return ret; 560 } 561 562 // for writing 563 class MimeContainer { 564 private static int sequence; 565 566 immutable string _contentType; 567 immutable string boundary; 568 569 string[] headers; // NOT including content-type 570 string content; 571 MimeContainer[] stuff; 572 573 this(string contentType, string content = null) { 574 this._contentType = contentType; 575 this.content = content; 576 sequence++; 577 if(_contentType.indexOf("multipart/") == 0) 578 boundary = "0016e64be86203dd36047610926a" ~ to!string(sequence); 579 } 580 581 @property string contentType() { 582 string ct = "Content-Type: "~_contentType; 583 if(boundary.length) 584 ct ~= "; boundary=" ~ boundary; 585 return ct; 586 } 587 588 589 string toMimeString(bool isRoot = false) { 590 string ret; 591 592 if(!isRoot) { 593 ret ~= contentType; 594 foreach(header; headers) { 595 ret ~= "\r\n"; 596 ret ~= header; 597 } 598 ret ~= "\r\n\r\n"; 599 } 600 601 ret ~= content; 602 603 foreach(idx, thing; stuff) { 604 assert(boundary.length); 605 ret ~= "\r\n--" ~ boundary ~ "\r\n"; 606 ret ~= thing.toMimeString(false); 607 } 608 609 if(boundary.length) 610 ret ~= "\r\n--" ~ boundary ~ "--"; 611 612 return ret; 613 } 614 } 615 616 import std.algorithm : startsWith; 617 /// 618 class IncomingEmailMessage { 619 /// 620 this(string[] lines) { 621 auto lns = cast(immutable(ubyte)[][])lines; 622 this(lns, false); 623 } 624 625 /// 626 this(ref immutable(ubyte)[][] mboxLines, bool asmbox=true) { 627 628 enum ParseState { 629 lookingForFrom, 630 readingHeaders, 631 readingBody 632 } 633 634 auto state = (asmbox ? ParseState.lookingForFrom : ParseState.readingHeaders); 635 string contentType; 636 637 bool isMultipart; 638 bool isHtml; 639 immutable(ubyte)[][] mimeLines; 640 641 string charset = "latin-1"; 642 643 string contentTransferEncoding; 644 645 string headerName; 646 string headerContent; 647 void commitHeader() { 648 if(headerName is null) 649 return; 650 651 headerName = headerName.toLower(); 652 headerContent = headerContent.strip(); 653 654 headerContent = decodeEncodedWord(headerContent); 655 656 if(headerName == "content-type") { 657 contentType = headerContent; 658 if(contentType.indexOf("multipart/") != -1) 659 isMultipart = true; 660 else if(contentType.indexOf("text/html") != -1) 661 isHtml = true; 662 663 auto charsetIdx = contentType.indexOf("charset="); 664 if(charsetIdx != -1) { 665 string cs = contentType[charsetIdx + "charset=".length .. $]; 666 if(cs.length && cs[0] == '\"') 667 cs = cs[1 .. $]; 668 669 auto quoteIdx = cs.indexOf("\""); 670 if(quoteIdx != -1) 671 cs = cs[0 .. quoteIdx]; 672 auto semicolonIdx = cs.indexOf(";"); 673 if(semicolonIdx != -1) 674 cs = cs[0 .. semicolonIdx]; 675 676 cs = cs.strip(); 677 if(cs.length) 678 charset = cs.toLower(); 679 } 680 } else if(headerName == "from") { 681 this.from = headerContent; 682 } else if(headerName == "to") { 683 this.to = headerContent; 684 } else if(headerName == "subject") { 685 this.subject = headerContent; 686 } else if(headerName == "content-transfer-encoding") { 687 contentTransferEncoding = headerContent; 688 } 689 690 headers[headerName] = headerContent; 691 headerName = null; 692 headerContent = null; 693 } 694 695 lineLoop: while(mboxLines.length) { 696 // this can needlessly convert headers too, but that won't harm anything since they are 7 bit anyway 697 auto line = convertToUtf8Lossy(mboxLines[0], charset); 698 auto origline = line; 699 line = line.stripRight; 700 701 final switch(state) { 702 case ParseState.lookingForFrom: 703 if(line.startsWith("From ")) 704 state = ParseState.readingHeaders; 705 break; 706 case ParseState.readingHeaders: 707 if(line.length == 0) { 708 commitHeader(); 709 state = ParseState.readingBody; 710 } else { 711 if(line[0] == ' ' || line[0] == '\t') { 712 headerContent ~= " " ~ line.stripLeft(); 713 } else { 714 commitHeader(); 715 716 auto idx = line.indexOf(":"); 717 if(idx == -1) 718 headerName = line; 719 else { 720 headerName = line[0 .. idx]; 721 headerContent = line[idx + 1 .. $].stripLeft(); 722 } 723 } 724 } 725 break; 726 case ParseState.readingBody: 727 if (asmbox) { 728 if(line.startsWith("From ")) { 729 break lineLoop; // we're at the beginning of the next messsage 730 } 731 if(line.startsWith(">>From") || line.startsWith(">From")) { 732 line = line[1 .. $]; 733 } 734 } 735 736 if(isMultipart) { 737 mimeLines ~= mboxLines[0]; 738 } else if(isHtml) { 739 // html with no alternative and no attachments 740 htmlMessageBody ~= line ~ "\n"; 741 } else { 742 // plain text! 743 // we want trailing spaces for "format=flowed", for example, so... 744 line = origline; 745 size_t epos = line.length; 746 while (epos > 0) { 747 char ch = line.ptr[epos-1]; 748 if (ch >= ' ' || ch == '\t') break; 749 --epos; 750 } 751 line = line.ptr[0..epos]; 752 textMessageBody ~= line ~ "\n"; 753 } 754 break; 755 } 756 757 mboxLines = mboxLines[1 .. $]; 758 } 759 760 if(mimeLines.length) { 761 auto part = new MimePart(mimeLines, contentType); 762 deeperInTheMimeTree: 763 switch(part.type) { 764 case "text/html": 765 htmlMessageBody = part.textContent; 766 break; 767 case "text/plain": 768 textMessageBody = part.textContent; 769 break; 770 case "multipart/alternative": 771 foreach(p; part.stuff) { 772 if(p.type == "text/html") 773 htmlMessageBody = p.textContent; 774 else if(p.type == "text/plain") 775 textMessageBody = p.textContent; 776 } 777 break; 778 case "multipart/related": 779 // the first one is the message itself 780 // after that comes attachments that can be rendered inline 781 if(part.stuff.length) { 782 auto msg = part.stuff[0]; 783 foreach(thing; part.stuff[1 .. $]) { 784 // FIXME: should this be special? 785 attachments ~= thing.toMimeAttachment(); 786 } 787 part = msg; 788 goto deeperInTheMimeTree; 789 } 790 break; 791 case "multipart/mixed": 792 if(part.stuff.length) { 793 auto msg = part.stuff[0]; 794 foreach(thing; part.stuff[1 .. $]) { 795 attachments ~= thing.toMimeAttachment(); 796 } 797 part = msg; 798 goto deeperInTheMimeTree; 799 } 800 801 // FIXME: the more proper way is: 802 // check the disposition 803 // if none, concat it to make a text message body 804 // if inline it is prolly an image to be concated in the other body 805 // if attachment, it is an attachment 806 break; 807 case "multipart/signed": 808 // FIXME: it would be cool to actually check the signature 809 if (part.stuff.length) { 810 auto msg = part.stuff[0]; 811 //{ import std.stdio; writeln("hdrs: ", part.stuff[0].headers); } 812 gpgalg = part.gpgalg; 813 gpgproto = part.gpgproto; 814 gpgmime = part; 815 foreach (thing; part.stuff[1 .. $]) { 816 attachments ~= thing.toMimeAttachment(); 817 } 818 part = msg; 819 goto deeperInTheMimeTree; 820 } 821 break; 822 default: 823 // FIXME: correctly handle more 824 if(part.stuff.length) { 825 part = part.stuff[0]; 826 goto deeperInTheMimeTree; 827 } 828 } 829 } else { 830 switch(contentTransferEncoding) { 831 case "quoted-printable": 832 if(textMessageBody.length) 833 textMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(textMessageBody), charset); 834 if(htmlMessageBody.length) 835 htmlMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(htmlMessageBody), charset); 836 break; 837 case "base64": 838 if(textMessageBody.length) { 839 // alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here 840 char[] mmb; 841 mmb.reserve(textMessageBody.length); 842 foreach (char ch; textMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch; 843 textMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset); 844 } 845 if(htmlMessageBody.length) { 846 // alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here 847 char[] mmb; 848 mmb.reserve(htmlMessageBody.length); 849 foreach (char ch; htmlMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch; 850 htmlMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset); 851 } 852 853 break; 854 default: 855 // nothing needed 856 } 857 } 858 859 if(htmlMessageBody.length > 0 && textMessageBody.length == 0) { 860 import arsd.htmltotext; 861 textMessageBody = htmlToText(htmlMessageBody); 862 textAutoConverted = true; 863 } 864 } 865 866 /// 867 @property bool hasGPGSignature () const nothrow @trusted @nogc { 868 MimePart mime = cast(MimePart)gpgmime; // sorry 869 if (mime is null) return false; 870 if (mime.type != "multipart/signed") return false; 871 if (mime.stuff.length != 2) return false; 872 if (mime.stuff[1].type != "application/pgp-signature") return false; 873 if (mime.stuff[0].type.length <= 5 && mime.stuff[0].type[0..5] != "text/") return false; 874 return true; 875 } 876 877 /// 878 ubyte[] extractGPGData () const nothrow @trusted { 879 if (!hasGPGSignature) return null; 880 MimePart mime = cast(MimePart)gpgmime; // sorry 881 char[] res; 882 res.reserve(mime.stuff[0].encodedContent.length); // more, actually 883 foreach (string s; mime.stuff[0].headers[1..$]) { 884 while (s.length && s[$-1] <= ' ') s = s[0..$-1]; 885 if (s.length == 0) return null; // wtf?! empty headers? 886 res ~= s; 887 res ~= "\r\n"; 888 } 889 res ~= "\r\n"; 890 // extract content (see rfc3156) 891 size_t pos = 0; 892 auto ctt = mime.stuff[0].encodedContent; 893 // last CR/LF is a part of mime signature, actually, so remove it 894 if (ctt.length && ctt[$-1] == '\n') { 895 ctt = ctt[0..$-1]; 896 if (ctt.length && ctt[$-1] == '\r') ctt = ctt[0..$-1]; 897 } 898 while (pos < ctt.length) { 899 auto epos = pos; 900 while (epos < ctt.length && ctt.ptr[epos] != '\n') ++epos; 901 auto xpos = epos; 902 while (xpos > pos && ctt.ptr[xpos-1] <= ' ') --xpos; // according to rfc 903 res ~= ctt[pos..xpos].dup; 904 res ~= "\r\n"; // according to rfc 905 pos = epos+1; 906 } 907 return cast(ubyte[])res; 908 } 909 910 /// 911 immutable(ubyte)[] extractGPGSignature () const nothrow @safe @nogc { 912 if (!hasGPGSignature) return null; 913 return gpgmime.stuff[1].content; 914 } 915 916 string[string] headers; /// 917 918 string subject; /// 919 920 string htmlMessageBody; /// 921 string textMessageBody; /// 922 923 string from; /// 924 string to; /// 925 926 bool textAutoConverted; /// 927 928 MimeAttachment[] attachments; /// 929 930 // gpg signature fields 931 string gpgalg; /// 932 string gpgproto; /// 933 MimePart gpgmime; /// 934 935 /// 936 string fromEmailAddress() { 937 auto i = from.indexOf("<"); 938 if(i == -1) 939 return from; 940 auto e = from.indexOf(">"); 941 return from[i + 1 .. e]; 942 } 943 944 /// 945 string toEmailAddress() { 946 auto i = to.indexOf("<"); 947 if(i == -1) 948 return to; 949 auto e = to.indexOf(">"); 950 return to[i + 1 .. e]; 951 } 952 } 953 954 /// 955 struct MboxMessages { 956 immutable(ubyte)[][] linesRemaining; 957 958 /// 959 this(immutable(ubyte)[] data) { 960 linesRemaining = splitLinesWithoutDecoding(data); 961 popFront(); 962 } 963 964 IncomingEmailMessage currentFront; 965 966 /// 967 IncomingEmailMessage front() { 968 return currentFront; 969 } 970 971 /// 972 bool empty() { 973 return currentFront is null; 974 } 975 976 /// 977 void popFront() { 978 if(linesRemaining.length) 979 currentFront = new IncomingEmailMessage(linesRemaining); 980 else 981 currentFront = null; 982 } 983 } 984 985 /// 986 MboxMessages processMboxData(immutable(ubyte)[] data) { 987 return MboxMessages(data); 988 } 989 990 immutable(ubyte)[][] splitLinesWithoutDecoding(immutable(ubyte)[] data) { 991 immutable(ubyte)[][] ret; 992 993 size_t starting = 0; 994 bool justSaw13 = false; 995 foreach(idx, b; data) { 996 if(b == 13) 997 justSaw13 = true; 998 999 if(b == 10) { 1000 auto use = idx; 1001 if(justSaw13) 1002 use--; 1003 1004 ret ~= data[starting .. use]; 1005 starting = idx + 1; 1006 } 1007 1008 if(b != 13) 1009 justSaw13 = false; 1010 } 1011 1012 if(starting < data.length) 1013 ret ~= data[starting .. $]; 1014 1015 return ret; 1016 } 1017 1018 string decodeEncodedWord(string data) { 1019 string originalData = data; 1020 1021 auto delimiter = data.indexOf("=?"); 1022 if(delimiter == -1) 1023 return data; 1024 1025 string ret; 1026 1027 while(delimiter != -1) { 1028 ret ~= data[0 .. delimiter]; 1029 data = data[delimiter + 2 .. $]; 1030 1031 string charset; 1032 string encoding; 1033 string encodedText; 1034 1035 // FIXME: the insane things should probably throw an 1036 // exception that keeps a copy of orignal data for use later 1037 1038 auto questionMark = data.indexOf("?"); 1039 if(questionMark == -1) return originalData; // not sane 1040 1041 charset = data[0 .. questionMark]; 1042 data = data[questionMark + 1 .. $]; 1043 1044 questionMark = data.indexOf("?"); 1045 if(questionMark == -1) return originalData; // not sane 1046 1047 encoding = data[0 .. questionMark]; 1048 data = data[questionMark + 1 .. $]; 1049 1050 questionMark = data.indexOf("?="); 1051 if(questionMark == -1) return originalData; // not sane 1052 1053 encodedText = data[0 .. questionMark]; 1054 data = data[questionMark + 2 .. $]; 1055 1056 delimiter = data.indexOf("=?"); 1057 if (delimiter == 1 && data[0] == ' ') { 1058 // a single space between encoded words must be ignored because it is 1059 // used to separate multiple encoded words (RFC2047 says CRLF SPACE but a most clients 1060 // just use a space) 1061 data = data[1..$]; 1062 delimiter = 0; 1063 } 1064 1065 immutable(ubyte)[] decodedText; 1066 if(encoding == "Q" || encoding == "q") 1067 decodedText = decodeQuotedPrintable(encodedText); 1068 else if(encoding == "B" || encoding == "b") 1069 decodedText = cast(typeof(decodedText)) Base64.decode(encodedText); 1070 else 1071 return originalData; // wtf 1072 1073 ret ~= convertToUtf8Lossy(decodedText, charset); 1074 } 1075 1076 ret ~= data; // keep the rest since there could be trailing stuff 1077 1078 return ret; 1079 } 1080 1081 immutable(ubyte)[] decodeQuotedPrintable(string text) { 1082 immutable(ubyte)[] ret; 1083 1084 int state = 0; 1085 ubyte hexByte; 1086 foreach(b; cast(immutable(ubyte)[]) text) { 1087 switch(state) { 1088 case 0: 1089 if(b == '=') { 1090 state++; 1091 hexByte = 0; 1092 } else if (b == '_') { // RFC2047 4.2.2: a _ may be used to represent a space 1093 ret ~= ' '; 1094 } else 1095 ret ~= b; 1096 break; 1097 case 1: 1098 if(b == '\n') { 1099 state = 0; 1100 continue; 1101 } 1102 goto case; 1103 case 2: 1104 int value; 1105 if(b >= '0' && b <= '9') 1106 value = b - '0'; 1107 else if(b >= 'A' && b <= 'F') 1108 value = b - 'A' + 10; 1109 else if(b >= 'a' && b <= 'f') 1110 value = b - 'a' + 10; 1111 if(state == 1) { 1112 hexByte |= value << 4; 1113 state++; 1114 } else { 1115 hexByte |= value; 1116 ret ~= hexByte; 1117 state = 0; 1118 } 1119 break; 1120 default: assert(0); 1121 } 1122 } 1123 1124 return ret; 1125 } 1126 1127 /+ 1128 void main() { 1129 import std.file; 1130 import std.stdio; 1131 1132 auto data = cast(immutable(ubyte)[]) std.file.read("/home/me/test_email_data"); 1133 foreach(message; processMboxData(data)) { 1134 writeln(message.subject); 1135 writeln(message.textMessageBody); 1136 writeln("**************** END MESSSAGE **************"); 1137 } 1138 } 1139 +/