arsd/email.d

1248 lines
30 KiB
D

/++
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.
+/
module arsd.email;
import std.net.curl;
pragma(lib, "curl");
import std.base64;
import std.string;
import std.range;
import std.utf;
import std.array;
import std.algorithm.iteration;
import arsd.characterencodings;
// import std.uuid;
// smtpMessageBoundary = randomUUID().toString();
// SEE ALSO: std.net.curl.SMTP
///
struct RelayInfo {
string server; ///
string username; ///
string password; ///
}
///
struct MimeAttachment {
string type; ///
string filename; ///
const(ubyte)[] content; ///
string id; ///
}
///
enum ToType {
to,
cc,
bcc
}
/++
For OUTGOING email
To use:
---
auto message = new EmailMessage();
message.to ~= "someuser@example.com";
message.from = "youremail@example.com";
message.subject = "My Subject";
message.setTextBody("hi there");
//message.toString(); // get string to send externally
message.send(); // send via some relay
// may also set replyTo, etc
---
+/
class EmailMessage {
///
void setHeader(string name, string value) {
headers ~= name ~ ": " ~ value;
}
string[] to; ///
string[] cc; ///
string[] bcc; ///
string from; ///
string replyTo; ///
string inReplyTo; ///
string textBody;
string htmlBody;
string subject; ///
string[] headers;
/++
If you use the send method with an SMTP server, you don't want to change this.
While RFC 2045 mandates CRLF as a lineseperator, there are some edge-cases where this won't work.
When passing the E-Mail string to a unix program which handles communication with the SMTP server, some (i.e. qmail)
expect the system lineseperator (LF) instead.
Notably, the google mail REST API will choke on CRLF lineseps and produce strange emails (as of 2024).
+/
string linesep = "\r\n";
private bool isMime = false;
private bool isHtml = false;
///
void addRecipient(string name, string email, ToType how = ToType.to) {
addRecipient(`"`~name~`" <`~email~`>`, how);
}
///
void addRecipient(string who, ToType how = ToType.to) {
final switch(how) {
case ToType.to:
to ~= who;
break;
case ToType.cc:
cc ~= who;
break;
case ToType.bcc:
bcc ~= who;
break;
}
}
///
void setTextBody(string text) {
textBody = text.strip;
}
/// automatically sets a text fallback if you haven't already
void setHtmlBody()(string html) {
isMime = true;
isHtml = true;
htmlBody = html;
import arsd.htmltotext;
if(textBody is null)
textBody = htmlToText(html);
}
const(MimeAttachment)[] attachments;
/++
The filename is what is shown to the user, not the file on your sending computer. It should NOT have a path in it.
---
message.addAttachment("text/plain", "something.txt", std.file.read("/path/to/local/something.txt"));
---
+/
void addAttachment(string mimeType, string filename, const void[] content, string id = null) {
isMime = true;
attachments ~= MimeAttachment(mimeType, filename, cast(const(ubyte)[]) content, id);
}
/// in the html, use img src="cid:ID_GIVEN_HERE"
void addInlineImage(string id, string mimeType, string filename, const void[] content) {
assert(isHtml);
isMime = true;
inlineImages ~= MimeAttachment(mimeType, filename, cast(const(ubyte)[]) content, id);
}
const(MimeAttachment)[] inlineImages;
/* we should build out the mime thingy
related
mixed
alternate
*/
/// Returns the MIME formatted email string, including encoded attachments
override string toString() {
assert(!isHtml || (isHtml && isMime));
auto headers = this.headers;
if(to.length)
headers ~= "To: " ~ join(to, ", ");
if(cc.length)
headers ~= "Cc: " ~ join(cc, ", ");
if(from.length)
headers ~= "From: " ~ from;
if(subject !is null)
headers ~= "Subject: " ~ subject;
if(replyTo !is null)
headers ~= "Reply-To: " ~ replyTo;
if(inReplyTo !is null)
headers ~= "In-Reply-To: " ~ inReplyTo;
if(isMime)
headers ~= "MIME-Version: 1.0";
/+
if(inlineImages.length) {
headers ~= "Content-Type: multipart/related; boundary=" ~ boundary;
// so we put the alternative inside asthe first attachment with as seconary boundary
// then we do the images
} else
if(attachments.length)
headers ~= "Content-Type: multipart/mixed; boundary=" ~ boundary;
else if(isHtml)
headers ~= "Content-Type: multipart/alternative; boundary=" ~ boundary;
else
headers ~= "Content-Type: text/plain; charset=UTF-8";
+/
string msgContent;
if(isMime) {
MimeContainer top;
{
MimeContainer mimeMessage;
enum NO_TRANSFER_ENCODING = "Content-Transfer-Encoding: 8bit";
if(isHtml) {
auto alternative = new MimeContainer("multipart/alternative");
alternative.stuff ~= new MimeContainer("text/plain; charset=UTF-8", textBody).with_header(NO_TRANSFER_ENCODING);
alternative.stuff ~= new MimeContainer("text/html; charset=UTF-8", htmlBody).with_header(NO_TRANSFER_ENCODING);
mimeMessage = alternative;
} else {
mimeMessage = new MimeContainer("text/plain; charset=UTF-8", textBody).with_header(NO_TRANSFER_ENCODING);
}
top = mimeMessage;
}
{
MimeContainer mimeRelated;
if(inlineImages.length) {
mimeRelated = new MimeContainer("multipart/related");
mimeRelated.stuff ~= top;
top = mimeRelated;
foreach(attachment; inlineImages) {
auto mimeAttachment = new MimeContainer(attachment.type ~ "; name=\""~attachment.filename~"\"");
mimeAttachment.headers ~= "Content-Transfer-Encoding: base64";
mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">";
mimeAttachment.content = encodeBase64Mime(cast(const(ubyte)[]) attachment.content, this.linesep);
mimeRelated.stuff ~= mimeAttachment;
}
}
}
{
MimeContainer mimeMixed;
if(attachments.length) {
mimeMixed = new MimeContainer("multipart/mixed");
mimeMixed.stuff ~= top;
top = mimeMixed;
foreach(attachment; attachments) {
auto mimeAttachment = new MimeContainer(attachment.type);
mimeAttachment.headers ~= "Content-Disposition: attachment; filename=\""~attachment.filename~"\"";
mimeAttachment.headers ~= "Content-Transfer-Encoding: base64";
if(attachment.id.length)
mimeAttachment.headers ~= "Content-ID: <" ~ attachment.id ~ ">";
mimeAttachment.content = encodeBase64Mime(cast(const(ubyte)[]) attachment.content, this.linesep);
mimeMixed.stuff ~= mimeAttachment;
}
}
}
headers ~= top.contentType;
msgContent = top.toMimeString(true, this.linesep);
} else {
headers ~= "Content-Type: text/plain; charset=UTF-8";
msgContent = textBody;
}
string msg;
msg.reserve(htmlBody.length + textBody.length + 1024);
foreach(header; headers)
msg ~= header ~ this.linesep;
if(msg.length) // has headers
msg ~= this.linesep;
msg ~= msgContent;
return msg;
}
/// Sends via a given SMTP relay
void send(RelayInfo mailServer = RelayInfo("smtp://localhost")) {
auto smtp = SMTP(mailServer.server);
smtp.verifyHost = false;
smtp.verifyPeer = false;
//smtp.verbose = true;
{
// std.net.curl doesn't work well with STARTTLS if you don't
// put smtps://... and if you do, it errors if you can't start
// with a TLS connection from the beginning.
// This change allows ssl if it can.
import std.net.curl;
import etc.c.curl;
smtp.handle.set(CurlOption.use_ssl, CurlUseSSL.tryssl);
}
if(mailServer.username.length)
smtp.setAuthentication(mailServer.username, mailServer.password);
const(char)[][] allRecipients;
void processPerson(string person) {
auto idx = person.indexOf("<");
if(idx == -1)
allRecipients ~= person;
else {
person = person[idx + 1 .. $];
idx = person.indexOf(">");
if(idx != -1)
person = person[0 .. idx];
allRecipients ~= person;
}
}
foreach(person; to) processPerson(person);
foreach(person; cc) processPerson(person);
foreach(person; bcc) processPerson(person);
smtp.mailTo(allRecipients);
auto mailFrom = from;
auto idx = mailFrom.indexOf("<");
if(idx != -1)
mailFrom = mailFrom[idx + 1 .. $];
idx = mailFrom.indexOf(">");
if(idx != -1)
mailFrom = mailFrom[0 .. idx];
smtp.mailFrom = mailFrom;
smtp.message = this.toString();
smtp.perform();
}
}
///
void email(string to, string subject, string message, string from, RelayInfo mailServer = RelayInfo("smtp://localhost")) {
auto msg = new EmailMessage();
msg.from = from;
msg.to = [to];
msg.subject = subject;
msg.textBody = message;
msg.send(mailServer);
}
// private:
import std.conv;
/// for reading
class MimePart {
string[] headers;
immutable(ubyte)[] content;
immutable(ubyte)[] encodedContent; // usually valid only for GPG, and will be cleared by creator; canonical form
string textContent;
MimePart[] stuff;
string name;
string charset;
string type;
string transferEncoding;
string disposition;
string id;
string filename;
// gpg signatures
string gpgalg;
string gpgproto;
MimeAttachment toMimeAttachment() {
if(type == "multipart/mixed" && stuff.length == 1)
return stuff[0].toMimeAttachment;
MimeAttachment att;
att.type = type;
if(att.type == "application/octet-stream" && filename.length == 0 && name.length > 0 ) {
att.filename = name;
} else {
att.filename = filename;
}
att.id = id;
att.content = content;
return att;
}
this(immutable(ubyte)[][] lines, string contentType = null) {
string boundary;
void parseContentType(string content) {
//{ import std.stdio; writeln("c=[", content, "]"); }
foreach(k, v; breakUpHeaderParts(content)) {
//{ import std.stdio; writeln(" k=[", k, "]; v=[", v, "]"); }
switch(k) {
case "root":
type = v;
break;
case "name":
name = v;
break;
case "charset":
charset = v;
break;
case "boundary":
boundary = v;
break;
default:
case "micalg":
gpgalg = v;
break;
case "protocol":
gpgproto = v;
break;
}
}
}
if(contentType is null) {
// read headers immediately...
auto copyOfLines = lines;
immutable(ubyte)[] currentHeader;
void commitHeader() {
if(currentHeader.length == 0)
return;
string h = decodeEncodedWord(cast(string) currentHeader);
headers ~= h;
currentHeader = null;
auto idx = h.indexOf(":");
if(idx != -1) {
auto name = h[0 .. idx].strip.toLower;
auto content = h[idx + 1 .. $].strip;
string[4] filenames_found;
switch(name) {
case "content-type":
parseContentType(content);
break;
case "content-transfer-encoding":
transferEncoding = content.toLower;
break;
case "content-disposition":
foreach(k, v; breakUpHeaderParts(content)) {
switch(k) {
case "root":
disposition = v;
break;
case "filename":
filename = v;
break;
// FIXME: https://datatracker.ietf.org/doc/html/rfc2184#section-3 is what it is SUPPOSED to do
case "filename*0":
filenames_found[0] = v;
break;
case "filename*1":
filenames_found[1] = v;
break;
case "filename*2":
filenames_found[2] = v;
break;
case "filename*3":
filenames_found[3] = v;
break;
default:
}
}
break;
case "content-id":
id = content;
break;
default:
}
if (filenames_found[0] != "") {
foreach (string v; filenames_found) {
this.filename ~= v;
}
}
}
}
foreach(line; copyOfLines) {
lines = lines[1 .. $];
if(line.length == 0)
break;
if(line[0] == ' ' || line[0] == '\t')
currentHeader ~= (cast(string) line).stripLeft();
else {
if(currentHeader.length) {
commitHeader();
}
currentHeader = line;
}
}
commitHeader();
} else {
parseContentType(contentType);
}
// if it is multipart, find the start boundary. we'll break it up and fill in stuff
// otherwise, all the data that follows is just content
if(boundary.length) {
immutable(ubyte)[][] partLines;
bool inPart;
foreach(line; lines) {
if(line.startsWith("--" ~ boundary)) {
if(inPart)
stuff ~= new MimePart(partLines);
inPart = true;
partLines = null;
if(line == "--" ~ boundary ~ "--")
break; // all done
}
if(inPart) {
partLines ~= line;
} else {
content ~= line ~ '\n';
}
}
} else {
foreach(line; lines) {
content ~= line;
if(transferEncoding != "base64")
content ~= '\n';
}
}
// store encoded content for GPG (should be cleared by caller if necessary)
encodedContent = content;
// decode the content..
switch(transferEncoding) {
case "base64":
content = Base64.decode(cast(string) content);
break;
case "quoted-printable":
content = decodeQuotedPrintable(cast(string) content);
break;
default:
// no change needed (I hope)
}
if(type.indexOf("text/") == 0) {
if(charset.length == 0)
charset = "latin1";
textContent = convertToUtf8Lossy(content, charset);
}
}
}
string[string] breakUpHeaderParts(string headerContent) {
string[string] ret;
string currentName = "root";
string currentContent;
bool inQuote = false;
bool gettingName = false;
bool ignoringSpaces = false;
foreach(char c; headerContent) {
if(ignoringSpaces) {
if(c == ' ')
continue;
else
ignoringSpaces = false;
}
if(gettingName) {
if(c == '=') {
gettingName = false;
continue;
}
currentName ~= c;
}
if(c == '"') {
inQuote = !inQuote;
continue;
}
if(!inQuote && c == ';') {
ret[currentName] = currentContent;
ignoringSpaces = true;
currentName = null;
currentContent = null;
gettingName = true;
continue;
}
if(!gettingName)
currentContent ~= c;
}
if(currentName.length)
ret[currentName] = currentContent;
return ret;
}
// for writing
class MimeContainer {
private static int sequence;
immutable string _contentType;
immutable string boundary;
string[] headers; // NOT including content-type
string content;
MimeContainer[] stuff;
this(string contentType, string content = null) {
this._contentType = contentType;
this.content = content;
sequence++;
if(_contentType.indexOf("multipart/") == 0)
boundary = "0016e64be86203dd36047610926a" ~ to!string(sequence);
}
@property string contentType() {
string ct = "Content-Type: "~_contentType;
if(boundary.length)
ct ~= "; boundary=" ~ boundary;
return ct;
}
string toMimeString(bool isRoot = false, string linesep="\r\n") {
string ret;
if(!isRoot) {
ret ~= contentType;
foreach(header; headers) {
ret ~= linesep;
ret ~= header;
}
ret ~= linesep ~ linesep;
}
ret ~= content;
foreach(idx, thing; stuff) {
assert(boundary.length);
ret ~= linesep ~ "--" ~ boundary ~ linesep;
ret ~= thing.toMimeString(false, linesep);
}
if(boundary.length)
ret ~= linesep ~ "--" ~ boundary ~ "--";
return ret;
}
}
import std.algorithm : startsWith;
///
class IncomingEmailMessage {
///
this(string[] lines) {
auto lns = cast(immutable(ubyte)[][])lines;
this(lns, false);
}
///
this(ref immutable(ubyte)[][] mboxLines, bool asmbox=true) @trusted {
enum ParseState {
lookingForFrom,
readingHeaders,
readingBody
}
auto state = (asmbox ? ParseState.lookingForFrom : ParseState.readingHeaders);
string contentType;
bool isMultipart;
bool isHtml;
immutable(ubyte)[][] mimeLines;
string charset = "latin-1";
string contentTransferEncoding;
string headerName;
string headerContent;
void commitHeader() {
if(headerName is null)
return;
headerName = headerName.toLower();
headerContent = headerContent.strip();
headerContent = decodeEncodedWord(headerContent);
if(headerName == "content-type") {
contentType = headerContent;
if(contentType.indexOf("multipart/") != -1)
isMultipart = true;
else if(contentType.indexOf("text/html") != -1)
isHtml = true;
auto charsetIdx = contentType.indexOf("charset=");
if(charsetIdx != -1) {
string cs = contentType[charsetIdx + "charset=".length .. $];
if(cs.length && cs[0] == '\"')
cs = cs[1 .. $];
auto quoteIdx = cs.indexOf("\"");
if(quoteIdx != -1)
cs = cs[0 .. quoteIdx];
auto semicolonIdx = cs.indexOf(";");
if(semicolonIdx != -1)
cs = cs[0 .. semicolonIdx];
cs = cs.strip();
if(cs.length)
charset = cs.toLower();
}
} else if(headerName == "from") {
this.from = headerContent;
} else if(headerName == "to") {
this.to = headerContent;
} else if(headerName == "subject") {
this.subject = headerContent;
} else if(headerName == "content-transfer-encoding") {
contentTransferEncoding = headerContent;
}
headers[headerName] = headerContent;
headerName = null;
headerContent = null;
}
lineLoop: while(mboxLines.length) {
// this can needlessly convert headers too, but that won't harm anything since they are 7 bit anyway
auto line = convertToUtf8Lossy(mboxLines[0], charset);
auto origline = line;
line = line.stripRight;
final switch(state) {
case ParseState.lookingForFrom:
if(line.startsWith("From "))
state = ParseState.readingHeaders;
break;
case ParseState.readingHeaders:
if(line.length == 0) {
commitHeader();
state = ParseState.readingBody;
} else {
if(line[0] == ' ' || line[0] == '\t') {
headerContent ~= " " ~ line.stripLeft();
} else {
commitHeader();
auto idx = line.indexOf(":");
if(idx == -1)
headerName = line;
else {
headerName = line[0 .. idx];
headerContent = line[idx + 1 .. $].stripLeft();
}
}
}
break;
case ParseState.readingBody:
if (asmbox) {
if(line.startsWith("From ")) {
break lineLoop; // we're at the beginning of the next messsage
}
if(line.startsWith(">>From") || line.startsWith(">From")) {
line = line[1 .. $];
}
}
if(isMultipart) {
mimeLines ~= mboxLines[0];
} else if(isHtml) {
// html with no alternative and no attachments
htmlMessageBody ~= line ~ "\n";
} else {
// plain text!
// we want trailing spaces for "format=flowed", for example, so...
line = origline;
size_t epos = line.length;
while (epos > 0) {
char ch = line.ptr[epos-1];
if (ch >= ' ' || ch == '\t') break;
--epos;
}
line = line.ptr[0..epos];
textMessageBody ~= line ~ "\n";
}
break;
}
mboxLines = mboxLines[1 .. $];
}
if(mimeLines.length) {
auto part = new MimePart(mimeLines, contentType);
deeperInTheMimeTree:
switch(part.type) {
case "text/html":
htmlMessageBody = part.textContent;
break;
case "text/plain":
textMessageBody = part.textContent;
break;
case "multipart/alternative":
foreach(p; part.stuff) {
if(p.type == "text/html")
htmlMessageBody = p.textContent;
else if(p.type == "text/plain")
textMessageBody = p.textContent;
}
break;
case "multipart/related":
// the first one is the message itself
// after that comes attachments that can be rendered inline
if(part.stuff.length) {
auto msg = part.stuff[0];
foreach(thing; part.stuff[1 .. $]) {
// FIXME: should this be special?
attachments ~= thing.toMimeAttachment();
}
part = msg;
goto deeperInTheMimeTree;
}
break;
case "multipart/mixed":
if(part.stuff.length) {
auto msg = part.stuff[0];
foreach(thing; part.stuff[1 .. $]) {
attachments ~= thing.toMimeAttachment();
}
part = msg;
goto deeperInTheMimeTree;
}
// FIXME: the more proper way is:
// check the disposition
// if none, concat it to make a text message body
// if inline it is prolly an image to be concated in the other body
// if attachment, it is an attachment
break;
case "multipart/signed":
// FIXME: it would be cool to actually check the signature
if (part.stuff.length) {
auto msg = part.stuff[0];
//{ import std.stdio; writeln("hdrs: ", part.stuff[0].headers); }
gpgalg = part.gpgalg;
gpgproto = part.gpgproto;
gpgmime = part;
foreach (thing; part.stuff[1 .. $]) {
attachments ~= thing.toMimeAttachment();
}
part = msg;
goto deeperInTheMimeTree;
}
break;
default:
// FIXME: correctly handle more
if(part.stuff.length) {
part = part.stuff[0];
goto deeperInTheMimeTree;
}
}
} else {
switch(contentTransferEncoding) {
case "quoted-printable":
if(textMessageBody.length)
textMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(textMessageBody), charset);
if(htmlMessageBody.length)
htmlMessageBody = convertToUtf8Lossy(decodeQuotedPrintable(htmlMessageBody), charset);
break;
case "base64":
if(textMessageBody.length) {
textMessageBody = textMessageBody.decodeBase64Mime.convertToUtf8Lossy(charset);
}
if(htmlMessageBody.length) {
htmlMessageBody = htmlMessageBody.decodeBase64Mime.convertToUtf8Lossy(charset);
}
break;
default:
// nothing needed
}
}
if(htmlMessageBody.length > 0 && textMessageBody.length == 0) {
import arsd.htmltotext;
textMessageBody = htmlToText(htmlMessageBody);
textAutoConverted = true;
}
}
///
@property bool hasGPGSignature () const nothrow @trusted @nogc {
MimePart mime = cast(MimePart)gpgmime; // sorry
if (mime is null) return false;
if (mime.type != "multipart/signed") return false;
if (mime.stuff.length != 2) return false;
if (mime.stuff[1].type != "application/pgp-signature") return false;
if (mime.stuff[0].type.length <= 5 && mime.stuff[0].type[0..5] != "text/") return false;
return true;
}
///
ubyte[] extractGPGData () const nothrow @trusted {
if (!hasGPGSignature) return null;
MimePart mime = cast(MimePart)gpgmime; // sorry
char[] res;
res.reserve(mime.stuff[0].encodedContent.length); // more, actually
foreach (string s; mime.stuff[0].headers[1..$]) {
while (s.length && s[$-1] <= ' ') s = s[0..$-1];
if (s.length == 0) return null; // wtf?! empty headers?
res ~= s;
res ~= "\r\n";
}
res ~= "\r\n";
// extract content (see rfc3156)
size_t pos = 0;
auto ctt = mime.stuff[0].encodedContent;
// last CR/LF is a part of mime signature, actually, so remove it
if (ctt.length && ctt[$-1] == '\n') {
ctt = ctt[0..$-1];
if (ctt.length && ctt[$-1] == '\r') ctt = ctt[0..$-1];
}
while (pos < ctt.length) {
auto epos = pos;
while (epos < ctt.length && ctt.ptr[epos] != '\n') ++epos;
auto xpos = epos;
while (xpos > pos && ctt.ptr[xpos-1] <= ' ') --xpos; // according to rfc
res ~= ctt[pos..xpos].dup;
res ~= "\r\n"; // according to rfc
pos = epos+1;
}
return cast(ubyte[])res;
}
///
immutable(ubyte)[] extractGPGSignature () const nothrow @safe @nogc {
if (!hasGPGSignature) return null;
return gpgmime.stuff[1].content;
}
string[string] headers; ///
string subject; ///
string htmlMessageBody; ///
string textMessageBody; ///
string from; ///
string to; ///
bool textAutoConverted; ///
MimeAttachment[] attachments; ///
// gpg signature fields
string gpgalg; ///
string gpgproto; ///
MimePart gpgmime; ///
///
string fromEmailAddress() {
auto i = from.indexOf("<");
if(i == -1)
return from;
auto e = from.indexOf(">");
return from[i + 1 .. e];
}
///
string toEmailAddress() {
auto i = to.indexOf("<");
if(i == -1)
return to;
auto e = to.indexOf(">");
return to[i + 1 .. e];
}
}
///
struct MboxMessages {
immutable(ubyte)[][] linesRemaining;
///
this(immutable(ubyte)[] data) {
linesRemaining = splitLinesWithoutDecoding(data);
popFront();
}
IncomingEmailMessage currentFront;
///
IncomingEmailMessage front() {
return currentFront;
}
///
bool empty() {
return currentFront is null;
}
///
void popFront() {
if(linesRemaining.length)
currentFront = new IncomingEmailMessage(linesRemaining);
else
currentFront = null;
}
}
///
MboxMessages processMboxData(immutable(ubyte)[] data) {
return MboxMessages(data);
}
immutable(ubyte)[][] splitLinesWithoutDecoding(immutable(ubyte)[] data) {
immutable(ubyte)[][] ret;
size_t starting = 0;
bool justSaw13 = false;
foreach(idx, b; data) {
if(b == 13)
justSaw13 = true;
if(b == 10) {
auto use = idx;
if(justSaw13)
use--;
ret ~= data[starting .. use];
starting = idx + 1;
}
if(b != 13)
justSaw13 = false;
}
if(starting < data.length)
ret ~= data[starting .. $];
return ret;
}
string decodeEncodedWord(string data) {
string originalData = data;
auto delimiter = data.indexOf("=?");
if(delimiter == -1)
return data;
string ret;
while(delimiter != -1) {
ret ~= data[0 .. delimiter];
data = data[delimiter + 2 .. $];
string charset;
string encoding;
string encodedText;
// FIXME: the insane things should probably throw an
// exception that keeps a copy of orignal data for use later
auto questionMark = data.indexOf("?");
if(questionMark == -1) return originalData; // not sane
charset = data[0 .. questionMark];
data = data[questionMark + 1 .. $];
questionMark = data.indexOf("?");
if(questionMark == -1) return originalData; // not sane
encoding = data[0 .. questionMark];
data = data[questionMark + 1 .. $];
questionMark = data.indexOf("?=");
if(questionMark == -1) return originalData; // not sane
encodedText = data[0 .. questionMark];
data = data[questionMark + 2 .. $];
delimiter = data.indexOf("=?");
if (delimiter == 1 && data[0] == ' ') {
// a single space between encoded words must be ignored because it is
// used to separate multiple encoded words (RFC2047 says CRLF SPACE but a most clients
// just use a space)
data = data[1..$];
delimiter = 0;
}
immutable(ubyte)[] decodedText;
if(encoding == "Q" || encoding == "q")
decodedText = decodeQuotedPrintable(encodedText);
else if(encoding == "B" || encoding == "b")
decodedText = cast(typeof(decodedText)) Base64.decode(encodedText);
else
return originalData; // wtf
ret ~= convertToUtf8Lossy(decodedText, charset);
}
ret ~= data; // keep the rest since there could be trailing stuff
return ret;
}
immutable(ubyte)[] decodeQuotedPrintable(string text) {
immutable(ubyte)[] ret;
int state = 0;
ubyte hexByte;
foreach(b; cast(immutable(ubyte)[]) text) {
switch(state) {
case 0:
if(b == '=') {
state++;
hexByte = 0;
} else if (b == '_') { // RFC2047 4.2.2: a _ may be used to represent a space
ret ~= ' ';
} else
ret ~= b;
break;
case 1:
if(b == '\n') {
state = 0;
continue;
}
goto case;
case 2:
int value;
if(b >= '0' && b <= '9')
value = b - '0';
else if(b >= 'A' && b <= 'F')
value = b - 'A' + 10;
else if(b >= 'a' && b <= 'f')
value = b - 'a' + 10;
if(state == 1) {
hexByte |= value << 4;
state++;
} else {
hexByte |= value;
ret ~= hexByte;
state = 0;
}
break;
default: assert(0);
}
}
return ret;
}
/// Add header UFCS helper
auto with_header(MimeContainer container, string header){
container.headers ~= header;
return container;
}
/// Base64 range encoder UFCS helper.
alias base64encode = Base64.encoder;
/// Base64 encoded data with line length of 76 as mandated by RFC 2045 Section 6.8
string encodeBase64Mime(const(ubyte[]) content, string LINESEP = "\r\n") {
enum LINE_LENGTH = 76;
/// Only 6 bit of every byte are used; log2(64) = 6
enum int SOURCE_CHUNK_LENGTH = LINE_LENGTH * 6/8;
return cast(immutable(char[]))content.chunks(SOURCE_CHUNK_LENGTH).base64encode.join(LINESEP);
}
/// Base64 range decoder UFCS helper.
alias base64decode = Base64.decoder;
/// Base64 decoder, ignoring linebreaks which are mandated by RFC2045
immutable(ubyte[]) decodeBase64Mime(string encodedPart) {
return cast(immutable(ubyte[])) encodedPart
.byChar // prevent Autodecoding, which will break Base64 decoder. Since its base64, it's guarenteed to be 7bit ascii
.filter!((c) => (c != '\r') & (c != '\n'))
.base64decode
.array;
}
unittest {
// Mime base64 roundtrip
import std.algorithm.comparison;
string source = chain(
repeat('n', 1200), //long line
"\r\n",
"äöü\r\n",
"ඞ\rn",
).byChar.array;
assert( source.representation.encodeBase64Mime.decodeBase64Mime.equal(source));
}
unittest {
import std.algorithm;
import std.string;
// Mime message roundtrip
auto mail = new EmailMessage();
mail.to = ["recipient@example.org"];
mail.from = "sender@example.org";
mail.subject = "Subject";
auto text = cast(string) chain(
repeat('n', 1200),
"\r\n",
"äöü\r\n",
"ඞ\r\nlast",
).byChar.array;
mail.setTextBody(text);
mail.addAttachment("text/plain", "attachment.txt", text.representation);
// In case binary and plaintext get handled differently one day
mail.addAttachment("application/octet-stream", "attachment.bin", text.representation);
auto result = new IncomingEmailMessage(mail.toString().split("\r\n"));
assert(result.subject.equal(mail.subject));
assert(mail.to.canFind(result.to));
assert(result.from.equal(mail.from));
// This roundtrip works modulo trailing newline on the parsed message and LF vs CRLF
assert(result.textMessageBody.replace("\n", "\r\n").stripRight().equal(mail.textBody));
assert(result.attachments.equal(mail.attachments));
}
/+
void main() {
import std.file;
import std.stdio;
auto data = cast(immutable(ubyte)[]) std.file.read("/home/me/test_email_data");
foreach(message; processMboxData(data)) {
writeln(message.subject);
writeln(message.textMessageBody);
writeln("**************** END MESSSAGE **************");
}
}
+/