Created
January 18, 2019 21:32
-
-
Save alexcrichton/4c99e24a7325441d42f33bab92516284 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
"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); | |
await 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