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