Skip to content

Instantly share code, notes, and snippets.

@alexcrichton
Created January 18, 2019 21:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save alexcrichton/c14d385a385f6961cda3d74bd67d5748 to your computer and use it in GitHub Desktop.
Save alexcrichton/c14d385a385f6961cda3d74bd67d5748 to your computer and use it in GitHub Desktop.
"use strict";
var AWS = require('aws-sdk');
console.log("AWS Lambda SES Forwarder // @arithmetric // Version 4.2.0");
// Configure the S3 bucket and key prefix for stored raw emails, and the
// mapping of email addresses to forward from and to.
//
// Expected keys/values:
//
// - fromEmail: Forwarded emails will come from this verified address
//
// - subjectPrefix: Forwarded emails subject will contain this prefix
//
// - emailBucket: S3 bucket name where SES stores emails.
//
// - emailKeyPrefix: S3 key name prefix where SES stores email. Include the
// trailing slash.
//
// - forwardMapping: Object where the key is the lowercase email address from
// which to forward and the value is an array of email addresses to which to
// send the message.
//
// To match all email addresses on a domain, use a key without the name part
// of an email address before the "at" symbol (i.e. `@example.com`).
//
// To match a mailbox name on all domains, use a key without the "at" symbol
// and domain part of an email address (i.e. `info`).
const config = {
fromEmail: "noreply@rust-lang.com",
emailBucket: "rustlang-emails",
emailKeyPrefix: "incoming/",
forwardMapping: {
"admin@rust-lang.com": [
"alex@alexcrichton.com",
],
"webmaster@rust-lang.com": [
"admin@rust-lang.com",
],
"infra@rust-lang.com": [
"pietro@pietroalbini.org",
"alex@alexcrichton.com",
],
}
};
/**
* Handler function to be invoked by AWS Lambda with an inbound SES email as
* the event.
*
* @param {object} event - Lambda event from inbound email received by AWS SES.
* @param {object} context - Lambda context object.
* @param {object} callback - Lambda callback object.
* @param {object} overrides - Overrides for the default data, including the
* configuration, SES object, and S3 object.
*/
exports.handler = function(event, context, callback, overrides) {
handle(event, context)
.then(() => {
console.log({level: "invo", message: "process finished successfully"});
callback();
})
.catch(err => {
console.log({
level: "error",
message: "exception throwin while processing",
error: err,
stack: err.stack,
});
callback(new Error("error: step threw an exception"));
});
};
async function handle(event, context) {
const ses = new AWS.SES();
const s3 = new AWS.S3({signatureVersion: 'v4'});
const { email, recipients } = parseEvent(event);
try {
const newRecipients = transformRecipients(recipients, email);
if (!newRecipients.length) {
console.log({message: "Finishing process. No new recipients found for " +
"original destinations: " + recipients.join(", "),
level: "info"});
return;
}
const emailData = await fetchMessage(s3, email);
if (emailData.indexOf("lolwut") !== -1) {
throw new Error('lolwut');
}
const newData = processMessage(emailData);
sendMessage(ses, newRecipients, email.commonHeaders.from[0], newData);
} catch (e) {
try {
notifyBounce(ses, email);
} catch (bounce_err) {
console.log({
message: "failed to bounce",
err: bounce_err,
stack: bounce_err.stack,
level: "error"
});
}
throw e;
}
}
/**
* Parses the SES event record provided for the `email` and `receipients` data.
*/
function parseEvent(event) {
// Validate characteristics of a SES event record.
if (!event ||
!event.hasOwnProperty('Records') ||
event.Records.length !== 1 ||
!event.Records[0].hasOwnProperty('eventSource') ||
event.Records[0].eventSource !== 'aws:ses' ||
event.Records[0].eventVersion !== '1.0')
{
console.log({message: "parseEvent() received invalid SES message:",
level: "error", event: JSON.stringify(event)});
throw new Error('Error: Received invalid SES message.');
}
const ret = {};
ret.email = event.Records[0].ses.mail;
ret.recipients = event.Records[0].ses.receipt.recipients;
return ret;
}
/**
* Transforms the original recipients to the desired forwarded destinations.
*/
function transformRecipients(recipients, email) {
var newRecipients = [];
var changed = true;
while (changed) {
newRecipients = [];
changed = false;
recipients.forEach(function(origEmail) {
var origEmailKey = origEmail.toLowerCase();
if (config.forwardMapping.hasOwnProperty(origEmailKey)) {
newRecipients = newRecipients.concat(
config.forwardMapping[origEmailKey]);
//data.originalRecipient = origEmail;
changed = true;
} else {
newRecipients.push(origEmail);
}
});
recipients = newRecipients;
}
// Don't send this email back to whomever it was sent from. Also don't send
// the email again to folks already receiving it.
console.log(email.commonHeaders);
console.log(email);
let blacklist = [];
if (email.commonHeaders.to)
blacklist = blacklist.concat(email.commonHeaders.to);
if (email.commonHeaders.from)
blacklist = blacklist.concat(email.commonHeaders.from);
if (email.commonHeaders.cc)
blacklist = blacklist.concat(email.commonHeaders.cc);
if (email.commonHeaders.bcc)
blacklist = blacklist.concat(email.commonHeaders.bcc);
return newRecipients.filter(function(recipient) {
for (var i = 0; i < blacklist.length; i++) {
if (blacklist[i].indexOf(recipient) !== -1) {
console.log(blacklist[i] + " contains " + recipient + " so it is being removed");
return false;
}
}
return true;
});
}
/**
* Fetches the message data from S3.
*
* @param {object} data - Data bundle with context, email, etc.
*
* @return {object} - Promise resolved with data.
*/
async function fetchMessage(s3, email) {
console.log({level: "info", message: "Fetching email at s3://" +
config.emailBucket + '/' + config.emailKeyPrefix +
email.messageId});
// Copying email object to ensure read permission
const copy = new Promise(function(resolve, reject) {
s3.copyObject({
Bucket: config.emailBucket,
CopySource: config.emailBucket + '/' + config.emailKeyPrefix +
email.messageId,
Key: config.emailKeyPrefix + email.messageId,
ACL: 'private',
ContentType: 'text/plain',
StorageClass: 'STANDARD'
}, function(err) {
if (err) {
console.log({level: "error", message: "copyObject() returned error:"});
reject(err);
} else {
resolve();
}
});
});
await copy;
// Load the raw email from S3
const getObject = new Promise(function(resolve, reject) {
s3.getObject({
Bucket: config.emailBucket,
Key: config.emailKeyPrefix + email.messageId
}, function(err, result) {
if (err) {
console.log({level: "error", message: "getObject() returned error:"});
reject(err);
} else {
resolve(result);
}
});
});
const result = await(getObject);
return result.Body.toString();
}
/**
* Processes the message data, making updates to recipients and other headers
* before forwarding message.
*/
function processMessage(emailData) {
var match = emailData.match(/^((?:.+\r?\n)*)(\r?\n(?:.*\s+)*)/m);
var header = match && match[1] ? match[1] : emailData;
var body = match && match[2] ? match[2] : '';
// Add "Reply-To:" with the "From" address if it doesn't already exists
if (!/^Reply-To: /mi.test(header)) {
match = header.match(/^From: (.*(?:\r?\n\s+.*)*\r?\n)/m);
var from = match && match[1] ? match[1] : '';
if (from) {
header = header + 'Reply-To: ' + from;
console.log({level: "info", message: "Added Reply-To address of: " + from});
} else {
console.log({level: "info", message: "Reply-To address not added because " +
"From address was not properly extracted."});
}
}
// SES does not allow sending messages from an unverified address,
// so replace the message's "From:" header with the original
// recipient (which is a verified domain)
header = header.replace(
/^From: (.*(?:\r?\n\s+.*)*)/mg,
function(match, from) {
return 'From: ' + from.replace(/<(.*)>/, '').trim() +
' <' + config.fromEmail + '>';
});
// // Remove the Return-Path header.
// header = header.replace(/^Return-Path: (.*)\r?\n/mg, '');
//
// // Remove Sender header.
// header = header.replace(/^Sender: (.*)\r?\n/mg, '');
// Remove Message-ID header.
header = header.replace(/^Message-ID: (.*)\r?\n/mig, '');
// Remove all DKIM-Signature headers to prevent triggering an
// "InvalidParameterValue: Duplicate header 'DKIM-Signature'" error.
// These signatures will likely be invalid anyways, since the From
// header was modified.
header = header.replace(/^DKIM-Signature: .*\r?\n(\s+.*\r?\n)*/mg, '');
return header + body;
}
/**
* Send email using the SES sendRawEmail command.
*
* @param {object} data - Data bundle with context, email, etc.
*
* @return {object} - Promise resolved with data.
*/
async function sendMessage(ses, recipients, source, emailData) {
var promises = [];
// SES limits 50 receivers per `sendRawEmail`.
for (var i = 0; i < recipients.length; i += 50) {
var params = {
Destinations: recipients.slice(i, i + 50),
Source: source,
RawMessage: {
Data: emailData
}
};
console.log({level: "info", message: "sendMessage: Sending email via SES. " +
"Recipients: " + recipients.join(", ")});
promises.push(new Promise(function(resolve, reject) {
ses.sendRawEmail(params, function(err, result) {
if (err) {
console.log({level: "error", message: "sendRawEmail() returned error."});
reject(err);
} else {
console.log({level: "info", message: "sendRawEmail() successful.",
result: result});
resolve();
}
});
}));
}
await Promise.all(promises);
}
async function notifyBounce(ses, email) {
const opts = {
BounceSender: config.fromEmail,
BouncedRecipientInfoList: email.commonHeaders.from.map(from => {
return {
Recipient: from,
BounceType: 'TemporaryFailure',
};
}),
OriginalMessageId: email.commonHeaders.messageId,
};
console.log('bounce opts', opts);
const bounce = new Promise((resolve, reject) => {
ses.sendBounce(opts, function(err, data) {
if (err) {
console.log("failed to send bounce");
reject(err);
} else {
resolve(data);
}
});
});
await bounce;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment