AWS SES, S3 and Lambda: A Serverless mailserver
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
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.