Index.js difference

Created Diff never expires
31 removals
397 lines
21 additions
387 lines
"use strict";
"use strict";


var {
const { S3Client, CopyObjectCommand, GetObjectCommand } = require("@aws-sdk/client-s3");
CopyObjectCommand,
const { SESClient, SendRawEmailCommand } = require("@aws-sdk/client-ses");
GetObjectCommand,
S3Client
} = require('@aws-sdk/client-s3');
var {
SendEmailCommand,
SESv2Client
} = require('@aws-sdk/client-sesv2');


console.log("AWS Lambda SES Forwarder // @arithmetric // Version 5.0.0");
console.log("AWS Lambda SES Forwarder // @arithmetric // Version 5.0.0");


// Configure the S3 bucket and key prefix for stored raw emails, and the
// Configure the S3 bucket and key prefix for stored raw emails, and the
// mapping of email addresses to forward from and to.
// mapping of email addresses to forward from and to.
//
//
// Expected keys/values:
// Expected keys/values:
//
//
// - fromEmail: Forwarded emails will come from this verified address
// - fromEmail: Forwarded emails will come from this verified address
//
//
// - subjectPrefix: Forwarded emails subject will contain this prefix
// - subjectPrefix: Forwarded emails subject will contain this prefix
//
//
// - emailBucket: S3 bucket name where SES stores emails.
// - emailBucket: S3 bucket name where SES stores emails.
//
//
// - emailKeyPrefix: S3 key name prefix where SES stores email. Include the
// - emailKeyPrefix: S3 key name prefix where SES stores email. Include the
// trailing slash.
// trailing slash.
//
//
// - allowPlusSign: Enables support for plus sign suffixes on email addresses.
// - allowPlusSign: Enables support for plus sign suffixes on email addresses.
// If set to `true`, the username/mailbox part of an email address is parsed
// If set to `true`, the username/mailbox part of an email address is parsed
// to remove anything after a plus sign. For example, an email sent to
// to remove anything after a plus sign. For example, an email sent to
// `example+test@example.com` would be treated as if it was sent to
// `example+test@example.com` would be treated as if it was sent to
// `example@example.com`.
// `example@example.com`.
//
//
// - forwardMapping: Object where the key is the lowercase email address from
// - 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
// which to forward and the value is an array of email addresses to which to
// send the message.
// send the message.
//
//
// To match all email addresses on a domain, use a key without the name part
// 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`).
// 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
// 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`).
// and domain part of an email address (i.e. `info`).
//
//
// To match all email addresses matching no other mapping, use "@" as a key.
// To match all email addresses matching no other mapping, use "@" as a key.
var defaultConfig = {
var defaultConfig = {
fromEmail: "noreply@example.com",
fromEmail: "noreply@example.com",
subjectPrefix: "",
subjectPrefix: "",
emailBucket: "s3-bucket-name",
emailBucket: "s3-bucket-name",
emailKeyPrefix: "emailsPrefix/",
emailKeyPrefix: "emailsPrefix/",
allowPlusSign: true,
allowPlusSign: true,
forwardMapping: {
forwardMapping: {
"info@example.com": [
"info@example.com": [
"example.john@example.com",
"example.john@example.com",
"example.jen@example.com"
"example.jen@example.com"
],
],
"abuse@example.com": [
"abuse@example.com": [
"example.jim@example.com"
"example.jim@example.com"
],
],
"@example.com": [
"@example.com": [
"example.john@example.com"
"example.john@example.com"
],
],
"info": [
"info": [
"info@example.com"
"info@example.com"
]
]
}
}
};
};


/**
/**
* Parses the SES event record provided for the `mail` and `receipients` data.
* Parses the SES event record provided for the `mail` and `receipients` data.
*
*
* @param {object} data - Data bundle with context, email, etc.
* @param {object} data - Data bundle with context, email, etc.
*
*
* @return {object} - Promise resolved with data.
* @return {object} - Promise resolved with data.
*/
*/
exports.parseEvent = function(data) {
exports.parseEvent = function(data) {
// Validate characteristics of a SES event record.
// Validate characteristics of a SES event record.
if (!data.event ||
if (!data.event ||
!data.event.hasOwnProperty('Records') ||
!data.event.hasOwnProperty('Records') ||
data.event.Records.length !== 1 ||
data.event.Records.length !== 1 ||
!data.event.Records[0].hasOwnProperty('eventSource') ||
!data.event.Records[0].hasOwnProperty('eventSource') ||
data.event.Records[0].eventSource !== 'aws:ses' ||
data.event.Records[0].eventSource !== 'aws:ses' ||
data.event.Records[0].eventVersion !== '1.0') {
data.event.Records[0].eventVersion !== '1.0') {
data.log({
data.log({
message: "parseEvent() received invalid SES message:",
message: "parseEvent() received invalid SES message:",
level: "error", event: JSON.stringify(data.event)
level: "error", event: JSON.stringify(data.event)
});
});
return Promise.reject(new Error('Error: Received invalid SES message.'));
return Promise.reject(new Error('Error: Received invalid SES message.'));
}
}


data.email = data.event.Records[0].ses.mail;
data.email = data.event.Records[0].ses.mail;
data.recipients = data.event.Records[0].ses.receipt.recipients;
data.recipients = data.event.Records[0].ses.receipt.recipients;
return Promise.resolve(data);
return Promise.resolve(data);
};
};


/**
/**
* Transforms the original recipients to the desired forwarded destinations.
* Transforms the original recipients to the desired forwarded destinations.
*
*
* @param {object} data - Data bundle with context, email, etc.
* @param {object} data - Data bundle with context, email, etc.
*
*
* @return {object} - Promise resolved with data.
* @return {object} - Promise resolved with data.
*/
*/
exports.transformRecipients = function(data) {
exports.transformRecipients = function(data) {
var newRecipients = [];
var newRecipients = [];
data.originalRecipients = data.recipients;
data.originalRecipients = data.recipients;
data.recipients.forEach(function(origEmail) {
data.recipients.forEach(function(origEmail) {
var origEmailKey = origEmail.toLowerCase();
var origEmailKey = origEmail.toLowerCase();
if (data.config.allowPlusSign) {
if (data.config.allowPlusSign) {
origEmailKey = origEmailKey.replace(/\+.*?@/, '@');
origEmailKey = origEmailKey.replace(/\+.*?@/, '@');
}
}
if (data.config.forwardMapping.hasOwnProperty(origEmailKey)) {
if (data.config.forwardMapping.hasOwnProperty(origEmailKey)) {
newRecipients = newRecipients.concat(
newRecipients = newRecipients.concat(
data.config.forwardMapping[origEmailKey]);
data.config.forwardMapping[origEmailKey]);
data.originalRecipient = origEmail;
data.originalRecipient = origEmail;
} else {
} else {
var origEmailDomain;
var origEmailDomain;
var origEmailUser;
var origEmailUser;
var pos = origEmailKey.lastIndexOf("@");
var pos = origEmailKey.lastIndexOf("@");
if (pos === -1) {
if (pos === -1) {
origEmailUser = origEmailKey;
origEmailUser = origEmailKey;
} else {
} else {
origEmailDomain = origEmailKey.slice(pos);
origEmailDomain = origEmailKey.slice(pos);
origEmailUser = origEmailKey.slice(0, pos);
origEmailUser = origEmailKey.slice(0, pos);
}
}
if (origEmailDomain &&
if (origEmailDomain &&
data.config.forwardMapping.hasOwnProperty(origEmailDomain)) {
data.config.forwardMapping.hasOwnProperty(origEmailDomain)) {
newRecipients = newRecipients.concat(
newRecipients = newRecipients.concat(
data.config.forwardMapping[origEmailDomain]);
data.config.forwardMapping[origEmailDomain]);
data.originalRecipient = origEmail;
data.originalRecipient = origEmail;
} else if (origEmailUser &&
} else if (origEmailUser &&
data.config.forwardMapping.hasOwnProperty(origEmailUser)) {
data.config.forwardMapping.hasOwnProperty(origEmailUser)) {
newRecipients = newRecipients.concat(
newRecipients = newRecipients.concat(
data.config.forwardMapping[origEmailUser]);
data.config.forwardMapping[origEmailUser]);
data.originalRecipient = origEmail;
data.originalRecipient = origEmail;
} else if (data.config.forwardMapping.hasOwnProperty("@")) {
} else if (data.config.forwardMapping.hasOwnProperty("@")) {
newRecipients = newRecipients.concat(
newRecipients = newRecipients.concat(
data.config.forwardMapping["@"]);
data.config.forwardMapping["@"]);
data.originalRecipient = origEmail;
data.originalRecipient = origEmail;
}
}
}
}
});
});


if (!newRecipients.length) {
if (!newRecipients.length) {
data.log({
data.log({
message: "Finishing process. No new recipients found for " +
message: "Finishing process. No new recipients found for " +
"original destinations: " + data.originalRecipients.join(", "),
"original destinations: " + data.originalRecipients.join(", "),
level: "info"
level: "info"
});
});
return data.callback();
return data.callback();
}
}


data.recipients = newRecipients;
data.recipients = newRecipients;
return Promise.resolve(data);
return Promise.resolve(data);
};
};


/**
/**
* Fetches the message data from S3.
* Fetches the message data from S3.
*
*
* @param {object} data - Data bundle with context, email, etc.
* @param {object} data - Data bundle with context, email, etc.
*
*
* @return {object} - Promise resolved with data.
* @return {object} - Promise resolved with data.
*/
*/
exports.fetchMessage = function(data) {
exports.fetchMessage = function(data) {
// Copying email object to ensure read permission
// Copying email object to ensure read permission
data.log({
data.log({
level: "info",
level: "info",
message: "Fetching email at s3://" + data.config.emailBucket + '/' +
message: "Fetching email at s3://" + data.config.emailBucket + '/' +
data.config.emailKeyPrefix + data.email.messageId
data.config.emailKeyPrefix + data.email.messageId
});
});
return new Promise(function(resolve, reject) {
return new Promise(function(resolve, reject) {
data.s3.send(new CopyObjectCommand({
data.s3.send(new CopyObjectCommand({
Bucket: data.config.emailBucket,
Bucket: data.config.emailBucket,
CopySource: data.config.emailBucket + '/' + data.config.emailKeyPrefix +
CopySource: data.config.emailBucket + '/' + data.config.emailKeyPrefix +
data.email.messageId,
data.email.messageId,
Key: data.config.emailKeyPrefix + data.email.messageId,
Key: data.config.emailKeyPrefix + data.email.messageId,
ACL: 'private',
ACL: 'private',
ContentType: 'text/plain',
ContentType: 'text/plain',
StorageClass: 'STANDARD'
StorageClass: 'STANDARD'
}), function(err) {
}), function(err) {
if (err) {
if (err) {
data.log({
data.log({
level: "error",
level: "error",
message: "copyObject() returned error:",
message: "CopyObjectCommand() returned error:",
error: err,
error: err,
stack: err.stack
stack: err.stack
});
});
return reject(
return reject(
new Error("Error: Could not make readable copy of email."));
new Error("Error: Could not make readable copy of email."));
}
}


// Load the raw email from S3
// Load the raw email from S3
data.s3.send(new GetObjectCommand({
data.s3.send(new GetObjectCommand({
Bucket: data.config.emailBucket,
Bucket: data.config.emailBucket,
Key: data.config.emailKeyPrefix + data.email.messageId
Key: data.config.emailKeyPrefix + data.email.messageId
}), function(err, result) {
}), async function(err, result) {
if (err) {
if (err) {
data.log({
data.log({
level: "error",
level: "error",
message: "getObject() returned error:",
message: "GetObjectCommand() returned error:",
error: err,
error: err,
stack: err.stack
stack: err.stack
});
});
return reject(
return reject(
new Error("Error: Failed to load message body from S3."));
new Error("Error: Failed to load message body from S3."));
}
}
result.Body.transformToString().then(
data.emailData = await result.Body.transformToString();
body => {
return resolve(data);
data.emailData = body;
resolve(data);
}
);
});
});
});
});
});
});
};
};


/**
/**
* Processes the message data, making updates to recipients and other headers
* Processes the message data, making updates to recipients and other headers
* before forwarding message.
* before forwarding message.
*
*
* @param {object} data - Data bundle with context, email, etc.
* @param {object} data - Data bundle with context, email, etc.
*
*
* @return {object} - Promise resolved with data.
* @return {object} - Promise resolved with data.
*/
*/
exports.processMessage = function(data) {
exports.processMessage = function(data) {
var match = data.emailData.match(/^((?:.+\r?\n)*)(\r?\n(?:.*\s+)*)/m);
var match = data.emailData.match(/^((?:.+\r?\n)*)(\r?\n(?:.*\s+)*)/m);
var header = match && match[1] ? match[1] : data.emailData;
var header = match && match[1] ? match[1] : data.emailData;
var body = match && match[2] ? match[2] : '';
var body = match && match[2] ? match[2] : '';


// Add "Reply-To:" with the "From" address if it doesn't already exists
// Add "Reply-To:" with the "From" address if it doesn't already exists
if (!/^reply-to:[\t ]?/mi.test(header)) {
if (!/^reply-to:[\t ]?/mi.test(header)) {
match = header.match(/^from:[\t ]?(.*(?:\r?\n\s+.*)*\r?\n)/mi);
match = header.match(/^from:[\t ]?(.*(?:\r?\n\s+.*)*\r?\n)/mi);
var from = match && match[1] ? match[1] : '';
var from = match && match[1] ? match[1] : '';
if (from) {
if (from) {
header = header + 'Reply-To: ' + from;
header = header + 'Reply-To: ' + from;
data.log({
data.log({
level: "info",
level: "info",
message: "Added Reply-To address of: " + from
message: "Added Reply-To address of: " + from
});
});
} else {
} else {
data.log({
data.log({
level: "info",
level: "info",
message: "Reply-To address not added because From address was not " +
message: "Reply-To address not added because From address was not " +
"properly extracted."
"properly extracted."
});
});
}
}
}
}


// SES does not allow sending messages from an unverified address,
// SES does not allow sending messages from an unverified address,
// so replace the message's "From:" header with the original
// so replace the message's "From:" header with the original
// recipient (which is a verified domain)
// recipient (which is a verified domain)
header = header.replace(
header = header.replace(
/^from:[\t ]?(.*(?:\r?\n\s+.*)*)/mgi,
/^from:[\t ]?(.*(?:\r?\n\s+.*)*)/mgi,
function(match, from) {
function(match, from) {
var fromText;
var fromText;
if (data.config.fromEmail) {
if (data.config.fromEmail) {
fromText = 'From: ' + from.replace(/<(.*)>/, '').trim() +
fromText = 'From: ' + from.replace(/<(.*)>/, '').trim() +
' <' + data.config.fromEmail + '>';
' <' + data.config.fromEmail + '>';
} else {
} else {
fromText = 'From: ' + from.replace('<', 'at ').replace('>', '') +
fromText = 'From: ' + from.replace('<', 'at ').replace('>', '') +
' <' + data.originalRecipient + '>';
' <' + data.originalRecipient + '>';
}
}
return fromText;
return fromText;
});
});


// Add a prefix to the Subject
// Add a prefix to the Subject
if (data.config.subjectPrefix) {
if (data.config.subjectPrefix) {
header = header.replace(
header = header.replace(
/^subject:[\t ]?(.*)/mgi,
/^subject:[\t ]?(.*)/mgi,
function(match, subject) {
function(match, subject) {
return 'Subject: ' + data.config.subjectPrefix + subject;
return 'Subject: ' + data.config.subjectPrefix + subject;
});
});
}
}


// Replace original 'To' header with a manually defined one
// Replace original 'To' header with a manually defined one
if (data.config.toEmail) {
if (data.config.toEmail) {
header = header.replace(/^to:[\t ]?(.*)/mgi, () => 'To: ' + data.config.toEmail);
header = header.replace(/^to:[\t ]?(.*)/mgi, () => 'To: ' + data.config.toEmail);
}
}


// Remove the Return-Path header.
// Remove the Return-Path header.
header = header.replace(/^return-path:[\t ]?(.*)\r?\n/mgi, '');
header = header.replace(/^return-path:[\t ]?(.*)\r?\n/mgi, '');


// Remove Sender header.
// Remove Sender header.
header = header.replace(/^sender:[\t ]?(.*)\r?\n/mgi, '');
header = header.replace(/^sender:[\t ]?(.*)\r?\n/mgi, '');


// Remove Message-ID header.
// Remove Message-ID header.
header = header.replace(/^message-id:[\t ]?(.*)\r?\n/mgi, '');
header = header.replace(/^message-id:[\t ]?(.*)\r?\n/mgi, '');


// Remove all DKIM-Signature headers to prevent triggering an
// Remove all DKIM-Signature headers to prevent triggering an
// "InvalidParameterValue: Duplicate header 'DKIM-Signature'" error.
// "InvalidParameterValue: Duplicate header 'DKIM-Signature'" error.
// These signatures will likely be invalid anyways, since the From
// These signatures will likely be invalid anyways, since the From
// header was modified.
// header was modified.
header = header.replace(/^dkim-signature:[\t ]?.*\r?\n(\s+.*\r?\n)*/mgi, '');
header = header.replace(/^dkim-signature:[\t ]?.*\r?\n(\s+.*\r?\n)*/mgi, '');


data.emailData = header + body;
data.emailData = header + body;
return Promise.resolve(data);
return Promise.resolve(data);
};
};


/**
/**
* Send email using the SES sendRawEmail command.
* Send email using the SES sendRawEmail command.
*
*
* @param {object} data - Data bundle with context, email, etc.
* @param {object} data - Data bundle with context, email, etc.
*
*
* @return {object} - Promise resolved with data.
* @return {object} - Promise resolved with data.
*/
*/
exports.sendMessage = function(data) {
exports.sendMessage = function(data) {
var params = {
Destinations: data.recipients,
Source: data.originalRecipient,
RawMessage: {
Data: Buffer.from(data.emailData)
}
};
data.log({
data.log({
level: "info",
level: "info",
message: "sendMessage: Sending email via SES. Original recipients: " +
message: "sendMessage: Sending email via SES. Original recipients: " +
data.originalRecipients.join(", ") + ". Transformed recipients: " +
data.originalRecipients.join(", ") + ". Transformed recipients: " +
data.recipients.join(", ") + "."
data.recipients.join(", ") + "."
});
});
return new Promise(function(resolve, reject) {
return new Promise(function(resolve, reject) {
data.ses.send(new SendEmailCommand({
data.ses.send(new SendRawEmailCommand(params), function(err, result) {
Content: {
Raw: {
Data: Buffer.from(data.emailData)
}
}
}), function(err, result) {
if (err) {
if (err) {
data.log({
data.log({
level: "error",
level: "error",
message: "sendRawEmail() returned error.",
message: "SendRawEmailCommand() returned error.",
error: err,
error: err,
stack: err.stack
stack: err.stack
});
});
return reject(new Error('Error: Email sending failed.'));
return reject(new Error('Error: Email sending failed.'));
}
}
data.log({
data.log({
level: "info",
level: "info",
message: "sendRawEmail() successful.",
message: "SendRawEmailCommand() successful.",
result: result
result: result
});
});
resolve(data);
resolve(data);
});
});
});
});
};
};


/**
/**
* Handler function to be invoked by AWS Lambda with an inbound SES email as
* Handler function to be invoked by AWS Lambda with an inbound SES email as
* the event.
* the event.
*
*
* @param {object} event - Lambda event from inbound email received by AWS SES.
* @param {object} event - Lambda event from inbound email received by AWS SES.
* @param {object} context - Lambda context object.
* @param {object} context - Lambda context object.
* @param {object} callback - Lambda callback object.
* @param {object} callback - Lambda callback object.
* @param {object} overrides - Overrides for the default data, including the
* @param {object} overrides - Overrides for the default data, including the
* configuration, SES object, and S3 object.
* configuration, SES object, and S3 object.
*/
*/
exports.handler = function(event, context, callback, overrides) {
exports.handler = function(event, context, callback, overrides) {
var steps = overrides && overrides.steps ? overrides.steps :
var steps = overrides && overrides.steps ? overrides.steps :
[
[
exports.parseEvent,
exports.parseEvent,
exports.transformRecipients,
exports.transformRecipients,
exports.fetchMessage,
exports.fetchMessage,
exports.processMessage,
exports.processMessage,
exports.sendMessage
exports.sendMessage
];
];
var data = {
var data = {
event: event,
event: event,
callback: callback,
callback: callback,
context: context,
context: context,
config: overrides && overrides.config ? overrides.config : defaultConfig,
config: overrides && overrides.config ? overrides.config : defaultConfig,
log: overrides && overrides.log ? overrides.log : console.log,
log: overrides && overrides.log ? overrides.log : console.log,
ses: overrides && overrides.ses ? overrides.ses : new SESv2Client(),
ses: overrides && overrides.ses ? overrides.ses : new SESClient(),
s3: overrides && overrides.s3 ?
s3: overrides && overrides.s3 ?
overrides.s3 : new S3Client({signatureVersion: 'v4'})
overrides.s3 : new S3Client({signatureVersion: 'v4'})
};
};
Promise.series(steps, data)
Promise.series(steps, data)
.then(function(data) {
.then(function(data) {
data.log({
data.log({
level: "info",
level: "info",
message: "Process finished successfully."
message: "Process finished successfully."
});
});
return data.callback();
return data.callback();
})
})
.catch(function(err) {
.catch(function(err) {
data.log({
data.log({
level: "error",
level: "error",
message: "Step returned error: " + err.message,
message: "Step returned error: " + err.message,
error: err,
error: err,
stack: err.stack
stack: err.stack
});
});
return data.callback(new Error("Error: Step returned error."));
return data.callback(new Error("Error: Step returned error."));
});
});
};
};


Promise.series = function(promises, initValue) {
Promise.series = function(promises, initValue) {
return promises.reduce(function(chain, promise) {
return promises.reduce(function(chain, promise) {
if (typeof promise !== 'function') {
if (typeof promise !== 'function') {
return chain.then(() => {
return chain.then(() => {
throw new Error("Error: Invalid promise item: " + promise);
throw new Error("Error: Invalid promise item: " + promise);
});
});
}
}
return chain.then(promise);
return chain.then(promise);
}, Promise.resolve(initValue));
}, Promise.resolve(initValue));
};
};