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