├── .gitignore ├── package.json ├── SampleIAMPolicy.json ├── config.js ├── LICENSE ├── README.md └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | aws-lambda-ses-forwarding.zip 3 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aws-lambda-forwarder-for-ses", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "async": "^1.5.2", 8 | "aws-sdk": "^2.2.22", 9 | "content-type": "^1.0.2", 10 | "mailparser": "^0.5.3", 11 | "node-uuid": "^1.4.7" 12 | }, 13 | "devDependencies": {}, 14 | "scripts": { 15 | "test": "echo \"Error: no test specified\" && exit 1" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "" 20 | }, 21 | "author": "", 22 | "license": "ISC" 23 | } 24 | -------------------------------------------------------------------------------- /SampleIAMPolicy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Effect": "Allow", 6 | "Action": [ 7 | "logs:CreateLogGroup", 8 | "logs:CreateLogStream", 9 | "logs:PutLogEvents" 10 | ], 11 | "Resource": "arn:aws:logs:*:*:*" 12 | }, 13 | { 14 | "Effect": "Allow", 15 | "Action": [ 16 | "cloudwatch:PutMetricData" 17 | ], 18 | "Resource": "*" 19 | }, 20 | { 21 | "Effect": "Allow", 22 | "Action": [ 23 | "ses:SendEmail" 24 | ], 25 | "Resource": "*" 26 | }, 27 | { 28 | "Effect": "Allow", 29 | "Action": [ 30 | "s3:*" 31 | ], 32 | "Resource": "*" 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | var config = { 4 | "attachmentsBucket": "mybucket.mydomain.com", 5 | "attachmentsPrefix": "attachments", 6 | "debug": true, 7 | "rules": { // first match 8 | "^mail.example.com$": { // exact "Object key prefix" match 9 | "to": "somerecipient@yourdomain.com", 10 | "from": "mail ", 11 | "subject": "[mail] ", 12 | }, 13 | "example.com": { // matches any "Object key prefix" containing "example.com" 14 | "to": "somerecipient+wildcard@yourdomain.com", 15 | "from": "mail ", 16 | "subject": "", 17 | }, 18 | ".*": { // matches everything - don't forget the dot! 19 | "to": "catchall@yourdomain.com", 20 | "from": "mail ", 21 | "subject": "[catchall] ", 22 | } 23 | } 24 | } 25 | 26 | module.exports = config 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | SES sending code is Copyright (c) 2015 Eleven41 Software Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS Lambda Forwarder for SES Email 2 | 3 | Accept incoming email with AWS SES - use this AWS Lambda function to forward it somewhere else. 4 | 5 | ## Features 6 | * No server required thanks to AWS Lambda 7 | * Simple regex-based forwarding rules 8 | * Free for basic usage, super cheap at scale: 9 | * SES free tier: 2,000 emails/day (1000 in, 1000 out) 10 | * SNS free tier: 1,000,000 requests/month 11 | * S3 free tier: 5 GB storage and 2,000 put requests/month 12 | * SES defaults allow for up to 2000 domains (no per-domain cost) 13 | 14 | ### Attachment handling 15 | SES has the folling email size limitations: 16 | * Receiving email: 30MB 17 | * Sending email: 10MB 18 | 19 | To avoid dropping attachments due of this inconsistency, this tool saves all attachments in S3 and inserts links into the email. Attachment links should be almost impossible to guess, but are they are also public - **if you share them, anyone can download your attachments**! 20 | 21 | ## Deployment 22 | 1. Create an S3 bucket for SES to store emails in. 23 | 2. Optionally create a different S3 bucket for attachments (or use the same bucket for both). 24 | 3. Setup SES 25 | 3. Add your domains to SES and verify them. 26 | 3. Add a new Rule to your active Rule Set. 27 | 3. Add up to 20 recipients per Rule. 28 | 3. Configure an S3 Action for your Rule using the bucket you created. 29 | 3. Set a meaningful Object Key Prefix. 30 | 3. Select "Create SNS Topic" and pick a meaningful topic name. 31 | 4. Create a role for this Lambda function in IAM. See "SampleIAMPolicy.json" for an example. 32 | 5. Create a new Lambda function. 33 | 5. Zip up the function: `npm install && zip -r aws-lambda-ses-forwarding.zip node_modules config.js index.js` 34 | 5. Upload the `aws-lambda-ses-forwarding.zip` file in the Lambda console. 35 | 5. Add an Event Source of type SNS, and subscribe to the SNS topic you setup in SES. 36 | 5. If you want to handle large attachments, increase the RAM to 512MB and the timeout to 2 minutes. 37 | 38 | To update the lambda function or config: 39 | 1. Create a new ZIP file: `npm install && zip -r aws-lambda-ses-forwarding.zip node_modules config.js index.js` 40 | 2. Upload using the web interface OR this command: `aws lambda update-function-code --function-name YOUR-FUNCTION-NAME --zip-file fileb://aws-lambda-ses-forwarding.zip --publish` 41 | 42 | 43 | ## Configuration 44 | * Attachments are stored in S3 at attachmentsBucket/attachmentsPrefix/... 45 | * Delivery rules are regular expressions based on the "Object key prefix" 46 | * Configured under SES -> Rule Sets -> Actions 47 | * Default SES limits allow for 100 different rules, each with different "Object key prefix" 48 | * Only the first matching delivery rule is used 49 | * The delivery rule subject is used as prefix to original subject 50 | 51 | ## Sample "config.js" 52 | ``` 53 | "use strict"; 54 | 55 | var config = { 56 | "attachmentsBucket": "mybucket.mydomain.com", 57 | "attachmentsPrefix": "attachments", 58 | "debug": true, 59 | "rules": { // first match 60 | "^mail.example.com$": { // exact "Object key prefix" match 61 | "to": "somerecipient@yourdomain.com", 62 | "from": "mail ", 63 | "subject": "[mail] ", 64 | }, 65 | "example.com": { // matches any "Object key prefix" containing "example.com" 66 | "to": "somerecipient+wildcard@yourdomain.com", 67 | "from": "mail ", 68 | "subject": "", 69 | }, 70 | ".*": { // matches everything - don't forget the dot! 71 | "to": "catchall@yourdomain.com", 72 | "from": "mail ", 73 | "subject": "[catchall] ", 74 | } 75 | } 76 | } 77 | 78 | module.exports = config 79 | ``` 80 | 81 | ## Props to: 82 | * Lambda SES sending example: https://github.com/eleven41/aws-lambda-send-ses-email 83 | * Alternative implementation: https://github.com/arithmetric/aws-lambda-ses-forwarder 84 | 85 | 86 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | // aws-lambda-ses-forwarding 2 | 3 | var version = "0.1.000"; 4 | var aws = require("aws-sdk"); 5 | var uuid = require("node-uuid"); 6 | var async = require("async"); 7 | var contentType = require("content-type"); 8 | 9 | var ses = new aws.SES(); 10 | var s3 = new aws.S3(); 11 | 12 | var containerReuse = 0; 13 | 14 | exports.handler = function(event, context) { 15 | console.log(version); 16 | if (containerReuse > 0) { 17 | console.log("Container reuse == ", containerReuse); 18 | } 19 | containerReuse++; 20 | 21 | var MailParser = require("mailparser").MailParser; 22 | var mailparser = new MailParser(); 23 | var fs = require("fs"); 24 | 25 | var config = require("./config.js"); 26 | 27 | if (config.debug) { 28 | console.log("New event: ", JSON.stringify(event)); 29 | } 30 | if (event.Records == null) { 31 | context.fail("Error: no records found in SNS message"); 32 | return; 33 | } else if (event.Records.length != 1) { 34 | context.fail("Error: wrong # of records in SNS message - we expect exactly one"); 35 | return; 36 | } 37 | 38 | var record = event.Records[0]; 39 | if (record.EventSource != "aws:sns") { 40 | context.fail("Error: this doesnt look like an SES Received message"); 41 | return; 42 | } else if (record.Sns.Type != "Notification" || record.Sns.Subject != "Amazon SES Email Receipt Notification") { 43 | context.fail("Error: this doesnt look like an SES Email Receipt Notification"); 44 | return; 45 | } 46 | 47 | var message = JSON.parse(record.Sns.Message); 48 | if (message.mail.messageId == null) { 49 | context.fail("Error: mail.messageId is missing"); 50 | return; 51 | } else if (message.content != null) { 52 | context.fail("Error: mail content is present - seems like this should be going through S3"); 53 | return; 54 | } else if (message.receipt.action.type != "S3") { 55 | context.fail("Error: mail action is not S3!"); 56 | return; 57 | } else if (!message.receipt.action.bucketName || !message.receipt.action.objectKeyPrefix || !message.receipt.action.objectKey) { 58 | context.fail("Error: mail S3 details are missing"); 59 | return; 60 | } 61 | 62 | message.s3Url = "s3://" + message.receipt.action.bucketName + "/" + message.receipt.action.objectKey; 63 | if (config.debug) { 64 | console.log("Fetching message from " + message.s3Url); 65 | } 66 | 67 | s3.getObject({ 68 | Bucket: message.receipt.action.bucketName, 69 | Key: message.receipt.action.objectKey, 70 | }, function(err, data) { 71 | if (err) { 72 | console.log(err); 73 | context.fail("Error: Failed to load message from S3"); 74 | return; 75 | } 76 | 77 | var rawEmail = data.Body.toString() 78 | mailparser.on("end", function(parsedmail) { 79 | var charset = "UTF-8"; 80 | var contentTypeHeader = parsedmail.headers['content-type']; 81 | if (contentTypeHeader) { 82 | var parsed = contentType.parse(contentTypeHeader); 83 | if (parsed.parameters.charset) { 84 | charset = parsed.parameters.charset; 85 | } 86 | } 87 | if (config.debug) { 88 | console.log("Charset: " + charset); 89 | } 90 | // look for a matching rule 91 | var deliveryRule = null; 92 | for (var rule in config.rules) { 93 | var re = new RegExp(rule); 94 | if (re.test(message.receipt.action.objectKeyPrefix)) { 95 | deliveryRule = config.rules[rule]; 96 | break; 97 | } 98 | } 99 | if (deliveryRule === null) { 100 | console.log("Skipped: No matching rule", message.receipt.action.objectKeyPrefix); 101 | context.succeed("Skipped: No matching rule."); 102 | return; 103 | } 104 | 105 | var subject = deliveryRule.subject + parsedmail.subject + " [" + parsedmail.from[0].address + " -> " + parsedmail.to[0].address + "]" 106 | if (config.debug) { 107 | console.log("Subject: " + subject); 108 | console.log("Body: " + parsedmail.text); 109 | console.log("Object Key Prefix: " + message.receipt.action.objectKeyPrefix); 110 | } 111 | 112 | var params = { 113 | Destination: { 114 | ToAddresses: [deliveryRule.to] 115 | }, 116 | Source: deliveryRule.from, 117 | ReplyToAddresses: [message.mail.source], 118 | Message: { 119 | Subject: { 120 | Data: subject, 121 | Charset: charset 122 | }, 123 | Body: { 124 | Text: { 125 | Data: parsedmail.text, 126 | Charset: charset 127 | } 128 | } 129 | } 130 | }; 131 | if (parsedmail.html && parsedmail.html.length > 0) { 132 | params.Message.Body.Html = { 133 | Data: parsedmail.html, 134 | Charset: charset 135 | }; 136 | } 137 | 138 | var objs = []; 139 | var linkText = ""; 140 | var linkHTML = "
"; 141 | if (parsedmail.attachments) { // link to attachments in S3 142 | if (config.debug) { 143 | console.log("Handling " + parsedmail.attachments.length + " attachments"); 144 | } 145 | parsedmail.attachments.forEach(function(attachment) { 146 | var prefix = config.attachmentsPrefix + "/" + uuid.v4() + "/"; 147 | objs.push({ 148 | Bucket: config.attachmentsBucket, 149 | Key: prefix + attachment.fileName, 150 | ACL: "public-read", 151 | Body: attachment.content 152 | }); 153 | var url = "https://s3.amazonaws.com/" + config.attachmentsBucket + "/" + prefix + encodeURIComponent(attachment.fileName); 154 | linkText += attachment.fileName + ": " + url + "\n"; 155 | linkHTML += "Attachment " + attachment.fileName + "" + "
\n"; 156 | }); 157 | if (linkText.length > 0) { 158 | linkText = parsedmail.attachments.length + " ATTACHMENTS:\n" + linkText + "___________________________________________________________\n\n"; 159 | } 160 | linkHTML += "
\n"; 161 | if (config.debug) { 162 | console.log(linkText); 163 | } 164 | params.Message.Body.Text.Data = linkText + params.Message.Body.Text.Data; 165 | if (params.Message.Body.Html) { 166 | params.Message.Body.Html.Data = linkHTML + params.Message.Body.Html.Data; 167 | } 168 | } 169 | async.each(objs, 170 | function(item, callback) { 171 | s3.putObject(item, callback); 172 | }, 173 | function(err) { 174 | if (err) { 175 | console.log(err); 176 | context.fail("Error posting attachments to S3"); 177 | return; 178 | } 179 | 180 | // send email once all attachments are handled 181 | ses.sendEmail(params, function(err, data) { 182 | if (err) { 183 | console.log(err); 184 | context.fail("Error: SES send failed"); 185 | return; 186 | } 187 | if (config.debug) { 188 | console.log("Successful send to: " + deliveryRule.to); 189 | } 190 | context.succeed("Successful send to: " + deliveryRule.to); 191 | return; 192 | }); 193 | return; 194 | } 195 | ); 196 | 197 | }); 198 | mailparser.write(rawEmail); 199 | mailparser.end(); 200 | }); 201 | }; 202 | --------------------------------------------------------------------------------