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