arsd/email.d

1168 lines
27 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 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;
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;
if(isHtml) {
auto alternative = new MimeContainer("multipart/alternative");
alternative.stuff ~= new MimeContainer("text/plain; charset=UTF-8", textBody);
alternative.stuff ~= new MimeContainer("text/html; charset=UTF-8", htmlBody);
mimeMessage = alternative;
} else {
mimeMessage = new MimeContainer("text/plain; charset=UTF-8", textBody);
}
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 = Base64.encode(cast(const(ubyte)[]) attachment.content);
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 = Base64.encode(cast(const(ubyte)[]) attachment.content);
mimeMixed.stuff ~= mimeAttachment;
}
}
}
headers ~= top.contentType;
msgContent = top.toMimeString(true);
} 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 ~ "\r\n";
if(msg.length) // has headers
msg ~= "\r\n";
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 ret;
if(!isRoot) {
ret ~= contentType;
foreach(header; headers) {
ret ~= "\r\n";
ret ~= header;
}
ret ~= "\r\n\r\n";
}
ret ~= content;
foreach(idx, thing; stuff) {
assert(boundary.length);
ret ~= "\r\n--" ~ boundary ~ "\r\n";
ret ~= thing.toMimeString(false);
}
if(boundary.length)
ret ~= "\r\n--" ~ 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) {
// alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here
char[] mmb;
mmb.reserve(textMessageBody.length);
foreach (char ch; textMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch;
textMessageBody = convertToUtf8Lossy(Base64.decode(mmb), charset);
}
if(htmlMessageBody.length) {
// alas, phobos' base64 decoder cannot accept ranges, so we have to allocate here
char[] mmb;
mmb.reserve(htmlMessageBody.length);
foreach (char ch; htmlMessageBody) if (ch > ' ' && ch < 127) mmb ~= ch;
htmlMessageBody = convertToUtf8Lossy(Base64.decode(mmb), 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;
}
/+
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 **************");
}
}
+/