Introduction

I wanted a simple way for members of a tabletop gaming club I run to communicate with each other in order to arrange matchups, RSVP to events, etc.

I’m somewhat averse to the Facebook Hegemony and its social media ilk, so I turned to the last sleepy titan of the pre .com age, whose might, though waning, still comes (mostly) without privacy strings attached: Email!

There were two modes of email operation I wanted to support:

  • Simple email forwarding: gm@2d6.club to my personal email address
  • Discussion groups: Email distribution lists which when emailed to would not only deliver to all members, excluding the sender, but also modify the Reply-To header to ensure that all responses came back to the group, creating a sort of email based forum-like discussion thread

Now that I knew what I wanted, I needed to implement it. The obvious route was to setup a Postfix server, running in AWS, and configure it to do my bidding. However, that meant running (and paying for) a server 24x7x365 to handle only a very modest volume of mail. The solution? Go serverless. I implemented an email system based on AWS Simple Email Service, AWS S3 and AWS Lambda.

Architecture

AWS to the rescue

Implementation

The core of the setup is a Lambda function derrived from https://github.com/arithmetric/aws-lambda-ses-forwarder.

I forked the original to add the desired functionality.

--- original.js	2021-03-23 20:35:19.222285528 +0000
+++ index.js	2021-03-23 20:36:16.351859853 +0000
@@ -36,24 +36,18 @@
 //
 //   To match all email addresses matching no other mapping, use "@" as a key.
 var defaultConfig = {
-  fromEmail: "noreply@example.com",
+  fromEmail: "noreply@2d6.club",
   subjectPrefix: "",
-  emailBucket: "s3-bucket-name",
-  emailKeyPrefix: "emailsPrefix/",
+  emailBucket: "mail.2d6.club",
+  emailKeyPrefix: "mail/",
   allowPlusSign: true,
+  distributionLists: ["members@2d6.club"],
   forwardMapping: {
-    "info@example.com": [
-      "example.john@example.com",
-      "example.jen@example.com"
+    "gm@2d6.club": [
+      "joedwards32@gmail.com"
     ],
-    "abuse@example.com": [
-      "example.jim@example.com"
-    ],
-    "@example.com": [
-      "example.john@example.com"
-    ],
-    "info": [
-      "info@example.com"
+    "members@2d6.club": [
+      "joedwards32@gmail.com"
     ]
   }
 };
@@ -93,6 +87,10 @@
  * @return {object} - Promise resolved with data.
  */
 exports.transformRecipients = function(data) {
+  var match = data.emailData.match(/^((?:.+\r?\n)*)(\r?\n(?:.*\s+)*)/m);
+  var header = match && match[1] ? match[1] : data.emailData;
+  match = header.match(/^from:[\t ]?(.*(?:\r?\n\s+.*)*\r?\n)/mi);
+  var from = match && match[1] ? match[1] : '';
   var newRecipients = [];
   data.originalRecipients = data.recipients;
   data.recipients.forEach(function(origEmail) {
@@ -141,7 +139,16 @@
     return data.callback();
   }
 
-  data.recipients = newRecipients;
+  if (data.config.distributionLists.includes(data.originalRecipient)) {
+    var matchEmail = from.match(/\b([^\s]+@[^\s]+)\b/g)
+    data.recipients = newRecipients.filter( e => e.indexOf(matchEmail) === -1 );
+    data.log({
+      message: "Following recipient was excluded from newRecipieints: " + matchEmail,
+      level: "info"
+    });
+  } else {
+    data.recipients = newRecipients;
+  }
   return Promise.resolve(data);
 };
 
@@ -219,7 +226,15 @@
   if (!/^reply-to:[\t ]?/mi.test(header)) {
     match = header.match(/^from:[\t ]?(.*(?:\r?\n\s+.*)*\r?\n)/mi);
     var from = match && match[1] ? match[1] : '';
-    if (from) {
+    // Modify reply-to to be the originalRecipient if the recipient is flagged
+    // flaged as a distribution list. Else, preserve original sender.
+    if (data.config.distributionLists.includes(data.originalRecipient)) {
+      header = header + 'Reply-To: ' + data.originalRecipient + '\r\n';
+      data.log({
+        level: "info",
+        message: "Added Reply-To address of: " + data.originalRecipient + '\r\n'
+      });
+    } else if (from) {
       header = header + 'Reply-To: ' + from;
       data.log({
         level: "info",
@@ -229,7 +244,7 @@
       data.log({
         level: "info",
         message: "Reply-To address not added because From address was not " +
-          "properly extracted."
+          "properly extracted AND Original Recipient is not a distribution list."
       });
     }
   }
@@ -241,7 +256,10 @@
     /^from:[\t ]?(.*(?:\r?\n\s+.*)*)/mgi,
     function(match, from) {
       var fromText;
-      if (data.config.fromEmail) {
+      if (data.config.distributionLists.includes(data.originalRecipient)) {
+        fromText = 'From: ' + from.replace(/<(.*)>/, '').trim() +
+        ' <' + data.originalRecipient + '>';
+      } else if (data.config.fromEmail) {
         fromText = 'From: ' + from.replace(/<(.*)>/, '').trim() +
         ' <' + data.config.fromEmail + '>';
       } else {
@@ -340,8 +358,8 @@
   var steps = overrides && overrides.steps ? overrides.steps :
     [
       exports.parseEvent,
-      exports.transformRecipients,
       exports.fetchMessage,
+      exports.transformRecipients,
       exports.processMessage,
       exports.sendMessage
     ];
@@ -383,3 +401,4 @@
     return chain.then(promise);
   }, Promise.resolve(initValue));
 };
+

Then it was a simple matter of configuring AWS SES to deliver to a private S3 bucket with appropriate IAM role permissions and then trigger the Lambda function.