From 0a072c66df1e2765ecfa0365c642868bbfeaad3e Mon Sep 17 00:00:00 2001 From: Dan Goodliffe Date: Mon, 27 May 2019 17:14:35 +0100 Subject: Initial commit of MIME/Mail functionality --- icetray/icetray/Jamfile.jam | 22 ++- icetray/icetray/mail.ice | 33 +++++ icetray/icetray/mailServer.cpp | 53 +++++++ icetray/icetray/mailServer.h | 28 ++++ icetray/icetray/mime.ice | 32 ++++ icetray/icetray/mimeImpl.cpp | 163 +++++++++++++++++++++ icetray/icetray/mimeImpl.h | 53 +++++++ icetray/icetray/stream_support.h | 17 +++ icetray/unittests/Jamfile.jam | 10 ++ icetray/unittests/fixtures/mail/blank.png | Bin 0 -> 114 bytes .../unittests/fixtures/mail/multipart-alt-imgs.eml | 48 ++++++ icetray/unittests/fixtures/mail/multipart-alt.eml | 27 ++++ icetray/unittests/fixtures/mail/simple.eml | 11 ++ icetray/unittests/testIceTrayMail.cpp | 156 ++++++++++++++++++++ 14 files changed, 646 insertions(+), 7 deletions(-) create mode 100644 icetray/icetray/mail.ice create mode 100644 icetray/icetray/mailServer.cpp create mode 100644 icetray/icetray/mailServer.h create mode 100644 icetray/icetray/mime.ice create mode 100644 icetray/icetray/mimeImpl.cpp create mode 100644 icetray/icetray/mimeImpl.h create mode 100644 icetray/icetray/stream_support.h create mode 100644 icetray/unittests/fixtures/mail/blank.png create mode 100644 icetray/unittests/fixtures/mail/multipart-alt-imgs.eml create mode 100644 icetray/unittests/fixtures/mail/multipart-alt.eml create mode 100644 icetray/unittests/fixtures/mail/simple.eml create mode 100644 icetray/unittests/testIceTrayMail.cpp diff --git a/icetray/icetray/Jamfile.jam b/icetray/icetray/Jamfile.jam index bacda4e..09cf1b2 100644 --- a/icetray/icetray/Jamfile.jam +++ b/icetray/icetray/Jamfile.jam @@ -1,15 +1,22 @@ import package ; lib boost_program_options ; +lib esmtp ; -obj logWriter : logWriter.ice : - no - . - tidy:none - ; +rule iceobj ( name : source ) +{ + obj $(name) : $(source) : + no + . + tidy:none + ; +} +iceobj logWriter : logWriter.ice ; +iceobj mime : mime.ice ; +iceobj mail : mail.ice ; lib icetray : - [ glob *.cpp *.ice ] - logWriter + [ glob *.cpp *.ice : mime.ice mail.ice ] + logWriter mime mail : ..//adhocutil ..//dbppcore @@ -20,6 +27,7 @@ lib icetray : ..//slicer-db ../..//glibmm boost_program_options + esmtp logWriter pure . diff --git a/icetray/icetray/mail.ice b/icetray/icetray/mail.ice new file mode 100644 index 0000000..7f8bda4 --- /dev/null +++ b/icetray/icetray/mail.ice @@ -0,0 +1,33 @@ +#ifndef ICETRAY_MAIL +#define ICETRAY_MAIL + +#include + +[["ice-prefix"]] +module IceTray { + module Mail { + local struct Address { + string name; + string address; + }; + + local class Email { + Address to; + Address from; + string subject; + Mime::BasicPart content; + }; + + ["cpp:ice_print"] + local exception SendEmailFailed { + string message; + }; + + local interface MailServer { + idempotent void sendEmail(Email msg) throws SendEmailFailed; + }; + }; +}; + +#endif + diff --git a/icetray/icetray/mailServer.cpp b/icetray/icetray/mailServer.cpp new file mode 100644 index 0000000..67287c8 --- /dev/null +++ b/icetray/icetray/mailServer.cpp @@ -0,0 +1,53 @@ +#include "mailServer.h" +#include +#include +#include +#include + +namespace IceTray { + namespace Mail { + LibesmtpMailServer::LibesmtpMailServer(std::string s) : + server(std::move(s)) + { + } + + void + LibesmtpMailServer::sendEmail(const EmailPtr & msg) + { + smtp_session_t session = smtp_create_session(); + smtp_message_t message = smtp_add_message(session); + smtp_set_server(session, server.c_str()); + smtp_set_header(message, "To", msg->to.name.c_str(), msg->to.address.c_str()); + smtp_set_header(message, "From", msg->from.name.c_str(), msg->from.address.c_str()); + smtp_set_header(message, "Subject", msg->subject.c_str()); + smtp_add_recipient(message, msg->to.address.c_str()); + AdHoc::MemStream ms; + writeEmailContent(msg, ms); + smtp_set_message_fp(message, ms); + if (!smtp_start_session(session)) { + char buf[BUFSIZ]; + auto b = smtp_strerror(smtp_errno(), buf, sizeof(buf)); + assert(b); + SendEmailFailed e(__FILE__, __LINE__, b); + smtp_destroy_session(session); + throw e; + } + smtp_destroy_session(session); + } + + void + BasicMailServer::writeEmailContent(EmailPtr msg, FILE * ms) + { + fputs("MIME-Version: 1.0\r\n", ms); + msg->content->write({ ms }, 0); + } + + + void + SendEmailFailed::ice_print(std::ostream & buf) const + { + buf << "Failed to send email: " << message; + } + } +} + diff --git a/icetray/icetray/mailServer.h b/icetray/icetray/mailServer.h new file mode 100644 index 0000000..8f19877 --- /dev/null +++ b/icetray/icetray/mailServer.h @@ -0,0 +1,28 @@ + +#ifndef ICETRAY_MAILSERVER_H +#define ICETRAY_MAILSERVER_H + +#include +#include + +namespace IceTray { + namespace Mail { + class DLL_PUBLIC BasicMailServer { + public: + static void writeEmailContent(EmailPtr msg, FILE * ms); + }; + + class DLL_PUBLIC LibesmtpMailServer : public MailServer, BasicMailServer { + public: + LibesmtpMailServer(std::string server); + + void sendEmail(const EmailPtr & msg) override; + + private: + const std::string server; + }; + } +} + +#endif + diff --git a/icetray/icetray/mime.ice b/icetray/icetray/mime.ice new file mode 100644 index 0000000..112b165 --- /dev/null +++ b/icetray/icetray/mime.ice @@ -0,0 +1,32 @@ +#ifndef ICETRAY_MIME +#define ICETRAY_MIME + +[["ice-prefix"]] +[["cpp:include:stream_support.h"]] +module IceTray { + module Mime { + local dictionary Headers; + + local interface Writable { + ["cpp:const"] void write(["cpp:type:StreamPtr"] string buffer, int depth); + }; + + local class BasicPart implements Writable { + Headers headers; + }; + + local sequence Parts; + + local class BasicSinglePart extends BasicPart { + string mimetype; + }; + + local class BasicMultiPart extends BasicPart { + string subtype; + Parts parts; + }; + }; +}; + +#endif + diff --git a/icetray/icetray/mimeImpl.cpp b/icetray/icetray/mimeImpl.cpp new file mode 100644 index 0000000..74dfad5 --- /dev/null +++ b/icetray/icetray/mimeImpl.cpp @@ -0,0 +1,163 @@ +#include "mimeImpl.h" + +namespace IceTray::Mime { + static const char * const DIVIDER = "//divider//"; + static const char mime_base64[] = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/="; + + void + PartHelper::writeHeaders(const Headers & headers, const StreamPtr & ms) + { + for (const auto & h : headers) { + if (h.second.empty()) { + continue; + } + fprintf(ms, "%s: %s\r\n", h.first.c_str(), h.second.c_str()); + } + } + + TextPart::TextPart(const Headers & h, const std::string & m, const std::string & p) : + BasicSinglePart(h, m), + payload(p) + { + } + + void + TextPart::write(const StreamPtr & ms, Ice::Int) const + { + writeHeaders(headers, ms); + fputs("Content-Transfer-Encoding: quoted-printable\r\n", ms); + fprintf(ms, "Content-Type: %s; charset=\"utf-8\"\r\n\r\n", + mimetype.c_str()); + quotedPrintable(payload, ms); + fputs("\r\n", ms); + } + + void + TextPart::quotedPrintable(const std::string_view & input, FILE * ms, const size_t maxWidth) + { + size_t line = 0; + auto wrap = [&]() { + fputs("=\r\n", ms); + line = 0; + }; + auto wrapIfNeeded = [&](size_t need) { + if (line + need > maxWidth) { + wrap(); + } + }; + for (const auto & ch : input) { + const auto & nextCh = *(&ch + 1); + if (ch == '\r') { + } + else if (ch == '\n') { + fputs("\r\n", ms); + line = 0; + } + else if (ch == ' ' || ch == '\t') { + if (nextCh == '\r' || nextCh == '\n') { + wrapIfNeeded(3); + fprintf(ms, "=%02X", (uint8_t)ch); + line += 3; + } + else { + wrapIfNeeded(1); + fputc(ch, ms); + line += 1; + } + } + else if ((ch >= 33 && ch < 61) || (ch > 61 && ch <= 126)) { + wrapIfNeeded(1); + fputc(ch, ms); + line += 1; + } + else { + wrapIfNeeded(3); + fprintf(ms, "=%02X", (uint8_t)ch); + line += 3; + } + } + } + + BinaryViewPart::BinaryViewPart(const Headers & h, const std::string & m, const std::basic_string_view & v) : + BasicSinglePart(h, m), + payload(v) + { + } + + void + BinaryViewPart::write(const StreamPtr & ms, Ice::Int) const + { + writeHeaders(headers, ms); + fputs("Content-Transfer-Encoding: base64\r\n", ms); + fprintf(ms, "Content-Type: %s\r\n\r\n", + mimetype.c_str()); + base64(payload, ms); + fputs("\r\n", ms); + } + + void + BinaryViewPart::base64(const std::basic_string_view & input, FILE * ms, + const size_t maxWidth) + { + auto mime_encode_base64_block = [](auto & dest, const auto & src) { + if (src.length() >= 1) { + dest[0] = mime_base64[(src[0] & 0xFC) >> 2]; + if (src.length() >= 2) { + dest[1] = mime_base64[((src[0] & 0x03) << 4) | ((src[1] & 0xF0) >> 4)]; + if (src.length() >= 3) { + dest[2] = mime_base64[((src[1] & 0x0F) << 2) | ((src[2] & 0xC0) >> 6)]; + dest[3] = mime_base64[((src[2] & 0x3F))]; + } + else { + dest[2] = mime_base64[((src[1] & 0x0F) << 2)]; + } + } + else { + dest[1] = mime_base64[(src[0] & 0x03) << 4]; + } + } + }; + + size_t l = 0; + for (size_t i = 0; i < input.length(); i += 3) { + if (maxWidth > 0 && l + 4 > maxWidth) { + fputs("\r\n", ms); + l = 0; + } + + std::array bytes { '=', '=', '=', '=' }; + mime_encode_base64_block(bytes, input.substr(i, 3)); + fwrite(bytes.data(), bytes.size(),1, ms); + l += 4; + } + fputs("\r\n", ms); + } + + BinaryCopyPart::BinaryCopyPart(const Headers & h, const std::string & m, std::vector v) : + BinaryViewPart(h, m, { v.data(), v.size() }), + payload(std::move(v)) + { + } + + + MultiPart::MultiPart(const Headers & h, const std::string & st, const Parts & p) : + BasicMultiPart(h, st, p) + { + } + + void + MultiPart::write(const StreamPtr & ms, Ice::Int depth) const + { + writeHeaders(headers, ms); + fprintf(ms, "Content-Type: multipart/%s; boundary=\"%s%d\"\r\n\r\n", + subtype.c_str(), DIVIDER, depth); + for (const auto & p : parts) { + fprintf(ms, "--%s%d\r\n", DIVIDER, depth); + p->write(ms, depth + 1); + } + fprintf(ms, "--%s%d--\r\n", DIVIDER, depth); + } + +} + diff --git a/icetray/icetray/mimeImpl.h b/icetray/icetray/mimeImpl.h new file mode 100644 index 0000000..235d747 --- /dev/null +++ b/icetray/icetray/mimeImpl.h @@ -0,0 +1,53 @@ +#ifndef ICETRAY_MIME_IMPL_H +#define ICETRAY_MIME_IMPL_H + +#include +#include + +namespace IceTray::Mime { + class DLL_PUBLIC PartHelper { + protected: + static void writeHeaders(const Headers & headers, const StreamPtr & ms); + }; + + class DLL_PUBLIC TextPart : public BasicSinglePart, PartHelper { + public: + TextPart(const Headers &, const std::string &, const std::string &); + + void write(const StreamPtr & ms, Ice::Int depth) const override; + + static void quotedPrintable(const std::string_view & input, FILE * ms, + const size_t maxWidth = 74); + + const std::string payload; + }; + + class DLL_PUBLIC BinaryViewPart : public BasicSinglePart, PartHelper { + public: + BinaryViewPart(const Headers &, const std::string &, const std::basic_string_view &); + + void write(const StreamPtr & ms, Ice::Int depth) const override; + + static void base64(const std::basic_string_view & input, FILE * ms, + const size_t maxWidth = 76); + + std::basic_string_view payload; + }; + + class DLL_PUBLIC BinaryCopyPart : public BinaryViewPart { + public: + BinaryCopyPart(const Headers &, const std::string &, std::vector); + + std::vector payload; + }; + + class DLL_PUBLIC MultiPart : public BasicMultiPart, PartHelper { + public: + MultiPart(const Headers &, const std::string &, const Parts &); + + void write(const StreamPtr & ms, Ice::Int depth) const override; + }; +} + +#endif + diff --git a/icetray/icetray/stream_support.h b/icetray/icetray/stream_support.h new file mode 100644 index 0000000..4e01e7d --- /dev/null +++ b/icetray/icetray/stream_support.h @@ -0,0 +1,17 @@ +#ifndef ICETRAY_STREAM_SUPPORT_H +#define ICETRAY_STREAM_SUPPORT_H + +#include +#include + +namespace IceTray +{ + class StreamPtr { + public: + operator FILE * () const { return f; } + FILE * const f; + }; +} + +#endif + diff --git a/icetray/unittests/Jamfile.jam b/icetray/unittests/Jamfile.jam index 7486d04..655acb3 100644 --- a/icetray/unittests/Jamfile.jam +++ b/icetray/unittests/Jamfile.jam @@ -100,6 +100,16 @@ run testService ; +run + testIceTrayMail.cpp + : -- : + fixtures/mail/multipart-alt-imgs.eml + fixtures/mail/multipart-alt.eml + fixtures/mail/simple.eml + : + testCommon + ; + lib testService : testService.cpp diff --git a/icetray/unittests/fixtures/mail/blank.png b/icetray/unittests/fixtures/mail/blank.png new file mode 100644 index 0000000..bcaf83a Binary files /dev/null and b/icetray/unittests/fixtures/mail/blank.png differ diff --git a/icetray/unittests/fixtures/mail/multipart-alt-imgs.eml b/icetray/unittests/fixtures/mail/multipart-alt-imgs.eml new file mode 100644 index 0000000..f883d39 --- /dev/null +++ b/icetray/unittests/fixtures/mail/multipart-alt-imgs.eml @@ -0,0 +1,48 @@ +From: from +To: to +Date: Fri, 24 May 2019 22:19:54 +0000 (UTC) +Return-path: +MIME-Version: 1.0 +X-Source: multipart_top +Content-Type: multipart/alternative; boundary="//divider//0" + +--//divider//0 +X-Source: multipart_plain +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset="utf-8" + +Simple text =C2=A3 + +--//divider//0 +X-Source: multipart_html +Content-Type: multipart/related; boundary="//divider//1" + +--//divider//1 +X-Source: multipart_html_main +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset="utf-8" + + + +=C2=A3 + + + +--//divider//1 +X-Source: multipart_html_img1 +Content-Transfer-Encoding: base64 +Content-Type: image/png + +iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAYAAACprHcmAAAABmJLR0QA/wD/AP+gvaeTAAAAJ0lE +QVQYlWP8////fwbiwGomIhUyMDAwMIwqpo9iFgYGhtVEqj0BAAvPBjJ63HJVAAAAAElFTkSuQmCC + +--//divider//1 +X-Source: multipart_html_img2 +Content-Transfer-Encoding: base64 +Content-Type: image/png + +iVBORw0KGgoAAAANSUhEUgAAAAsAAAALCAYAAACprHcmAAAABmJLR0QA/wD/AP+gvaeTAAAAJ0lE +QVQYlWP8////fwbiwGomIhUyMDAwMIwqpo9iFgYGhtVEqj0BAAvPBjJ63HJVAAAAAElFTkSuQmCC + +--//divider//1-- +--//divider//0-- diff --git a/icetray/unittests/fixtures/mail/multipart-alt.eml b/icetray/unittests/fixtures/mail/multipart-alt.eml new file mode 100644 index 0000000..e199b58 --- /dev/null +++ b/icetray/unittests/fixtures/mail/multipart-alt.eml @@ -0,0 +1,27 @@ +From: from +To: to +Date: Fri, 24 May 2019 22:19:54 +0000 (UTC) +Return-path: +MIME-Version: 1.0 +X-Source: multipart_top +Content-Type: multipart/alternative; boundary="//divider//0" + +--//divider//0 +X-Source: multipart_plain +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset="utf-8" + +Simple text =C2=A3 + +--//divider//0 +X-Source: multipart_html +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset="utf-8" + + + +=C2=A3 + + + +--//divider//0-- diff --git a/icetray/unittests/fixtures/mail/simple.eml b/icetray/unittests/fixtures/mail/simple.eml new file mode 100644 index 0000000..2b411c9 --- /dev/null +++ b/icetray/unittests/fixtures/mail/simple.eml @@ -0,0 +1,11 @@ +From: from +To: to +Date: Fri, 24 May 2019 22:19:54 +0000 (UTC) +Return-path: +MIME-Version: 1.0 +X-Source: single_part +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset="utf-8" + +Simple text =C2=A3 + diff --git a/icetray/unittests/testIceTrayMail.cpp b/icetray/unittests/testIceTrayMail.cpp new file mode 100644 index 0000000..ff2220a --- /dev/null +++ b/icetray/unittests/testIceTrayMail.cpp @@ -0,0 +1,156 @@ +#define BOOST_TEST_MODULE TestIceTrayMail +#include +#include + +#include +#include +#include +#include +#include + +using namespace std::string_literals; +using namespace IceTray::Mime; +using namespace IceTray::Mail; +using QPTD = std::tuple; +using B64TD = std::tuple; + +BOOST_DATA_TEST_CASE(quotedPrintable, boost::unit_test::data::make({ + { "", "" }, + { "Simple string", "Simple string" }, + { " \t Leading whitespace", " \t Leading whitespace" }, + { "Trailing whitespace \t \n", "Trailing whitespace \t=20\r\n" }, + { "High byte values £ © ±", "High byte values =C2=A3 =\r\n=C2=A9 =C2=B1" }, + { "", "" } +}), input, expected) +{ + AdHoc::MemStream ms; + TextPart::quotedPrintable(input, ms, 25); + BOOST_CHECK_EQUAL(expected, ms.sv()); +} + +BOOST_DATA_TEST_CASE(base64, boost::unit_test::data::make({ + { 0, "\r\n" }, + { 1, "iQ==\r\n" }, + { 2, "iVA=\r\n" }, + { 3, "iVBO\r\n" }, + { 4, "iVBORw==\r\n" }, + { 5, "iVBORw0=\r\n" }, + { 90, + "iVBORw0KGgoAAAANSUhEUgAA\r\n" + "AAsAAAALCAYAAACprHcmAAAA\r\n" + "BmJLR0QA/wD/AP+gvaeTAAAA\r\n" + "J0lEQVQYlWP8////fwbiwGom\r\n" + "IhUyMDAwMIwqpo9iFgYGhtVE\r\n" }, + { 113, + "iVBORw0KGgoAAAANSUhEUgAA\r\n" + "AAsAAAALCAYAAACprHcmAAAA\r\n" + "BmJLR0QA/wD/AP+gvaeTAAAA\r\n" + "J0lEQVQYlWP8////fwbiwGom\r\n" + "IhUyMDAwMIwqpo9iFgYGhtVE\r\n" + "qj0BAAvPBjJ63HJVAAAAAElF\r\n" + "TkSuQmA=\r\n" }, +}), input, expected) +{ + AdHoc::MemStream ms; + AdHoc::FileUtils::MemMap png(rootDir / "fixtures" / "mail" / "blank.png"); + BinaryViewPart::base64(png.sv().substr(0, input), ms, 24); + BOOST_CHECK_EQUAL(expected, ms.sv()); +} + +struct TestBase { + TestBase() : + e(std::make_shared()), +#ifdef DUMP + dump(fopen("/tmp/dump.eml", "w")), +#endif + fixtures(rootDir / "fixtures" / "mail") + { + e->from = {"from", "from@test.com"}; + e->to = {"to", "to@test.com"}; + e->subject = "subject"; + + auto commonHeaders = [](FILE * s) { + fputs("From: from \r\n", s); + fputs("To: to \r\n", s); + fputs("Date: Fri, 24 May 2019 22:19:54 +0000 (UTC)\r\n", s); + fputs("Return-path: \r\n", s); + }; + commonHeaders(ms); +#ifdef DUMP + commonHeaders(dump); +#endif + } + ~TestBase() + { +#ifdef DUMP + fclose(dump); +#endif + } + EmailPtr e; + AdHoc::MemStream ms; +#ifdef DUMP + FILE * dump; +#endif + const std::filesystem::path fixtures; +}; + +const std::string text_content = "Simple text £\r\n"; +const std::string html_content = "\r\n" +"\r\n" +"£\r\n" +"\r\n" +"\r\n"; + +BOOST_FIXTURE_TEST_SUITE(base, TestBase); + +BOOST_AUTO_TEST_CASE(single_part) +{ + e->content = std::make_shared(Headers { + { "X-Source", "single_part" } + }, "text/plain", text_content); + BasicMailServer::writeEmailContent(e, ms); + BOOST_CHECK_EQUAL(ms, AdHoc::FileUtils::MemMap(fixtures / "simple.eml").sv()); +} + +BOOST_AUTO_TEST_CASE(multipart_alt) +{ + auto text = std::make_shared(Headers { + { "X-Source", "multipart_plain" } + }, "text/plain", text_content); + auto html = std::make_shared(Headers { + { "X-Source", "multipart_html" } + }, "text/html", html_content); + e->content = std::make_shared(Headers { + { "X-Source", "multipart_top" } + }, "alternative", Parts { text, html }); + BasicMailServer::writeEmailContent(e, ms); + BOOST_CHECK_EQUAL(ms, AdHoc::FileUtils::MemMap(fixtures / "multipart-alt.eml").sv()); +} + +BOOST_AUTO_TEST_CASE(multipart_alt_imgs) +{ + AdHoc::FileUtils::MemMap png(fixtures / "blank.png"); + auto text = std::make_shared(Headers { + { "X-Source", "multipart_plain" } + }, "text/plain", text_content); + auto img1 = std::make_shared(Headers { + { "X-Source", "multipart_html_img1" } + }, "image/png", png.sv()); + auto img2 = std::make_shared(Headers { + { "X-Source", "multipart_html_img2" } + }, "image/png", std::vector{ png.sv().begin(), png.sv().end() }); + auto html = std::make_shared(Headers { + { "X-Source", "multipart_html_main" } + }, "text/html", html_content); + auto htmlrel = std::make_shared(Headers { + { "X-Source", "multipart_html" } + }, "related", Parts { html, img1, img2 }); + e->content = std::make_shared(Headers { + { "X-Source", "multipart_top" } + }, "alternative", Parts { text, htmlrel }); + BasicMailServer::writeEmailContent(e, ms); + BOOST_CHECK_EQUAL(ms, AdHoc::FileUtils::MemMap(fixtures / "multipart-alt-imgs.eml").sv()); +} + +BOOST_AUTO_TEST_SUITE_END(); + -- cgit v1.2.3