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