├── .gitignore
├── .idea
├── AccountManager.iml
├── aws.xml
├── misc.xml
├── modules.xml
└── vcs.xml
├── README.md
├── env.json
├── event.json
├── hello_world
├── __init__.py
├── app.py
└── requirements.txt
├── ses_verify
├── __init__.py
├── app.py
└── requirements.txt
├── template.yaml
└── tests
└── unit
├── __init__.py
└── test_handler.py
/.gitignore:
--------------------------------------------------------------------------------
1 |
2 | output.yaml
3 |
4 | # Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode
5 |
6 | ### Linux ###
7 | *~
8 |
9 | # temporary files which can be created if a process still has a handle open of a deleted file
10 | .fuse_hidden*
11 |
12 | # KDE directory preferences
13 | .directory
14 |
15 | # Linux trash folder which might appear on any partition or disk
16 | .Trash-*
17 |
18 | # .nfs files are created when an open file is removed but is still being accessed
19 | .nfs*
20 |
21 | ### OSX ###
22 | *.DS_Store
23 | .AppleDouble
24 | .LSOverride
25 |
26 | # Icon must end with two \r
27 | Icon
28 |
29 | # Thumbnails
30 | ._*
31 |
32 | # Files that might appear in the root of a volume
33 | .DocumentRevisions-V100
34 | .fseventsd
35 | .Spotlight-V100
36 | .TemporaryItems
37 | .Trashes
38 | .VolumeIcon.icns
39 | .com.apple.timemachine.donotpresent
40 |
41 | # Directories potentially created on remote AFP share
42 | .AppleDB
43 | .AppleDesktop
44 | Network Trash Folder
45 | Temporary Items
46 | .apdisk
47 |
48 | ### PyCharm ###
49 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm
50 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
51 |
52 | # User-specific stuff:
53 | .idea/**/workspace.xml
54 | .idea/**/tasks.xml
55 | .idea/dictionaries
56 |
57 | # Sensitive or high-churn files:
58 | .idea/**/dataSources/
59 | .idea/**/dataSources.ids
60 | .idea/**/dataSources.xml
61 | .idea/**/dataSources.local.xml
62 | .idea/**/sqlDataSources.xml
63 | .idea/**/dynamic.xml
64 | .idea/**/uiDesigner.xml
65 |
66 | # Gradle:
67 | .idea/**/gradle.xml
68 | .idea/**/libraries
69 |
70 | # CMake
71 | cmake-build-debug/
72 |
73 | # Mongo Explorer plugin:
74 | .idea/**/mongoSettings.xml
75 |
76 | ## File-based project format:
77 | *.iws
78 |
79 | ## Plugin-specific files:
80 |
81 | # IntelliJ
82 | /out/
83 |
84 | # mpeltonen/sbt-idea plugin
85 | .idea_modules/
86 |
87 | # JIRA plugin
88 | atlassian-ide-plugin.xml
89 |
90 | # Cursive Clojure plugin
91 | .idea/replstate.xml
92 |
93 | # Ruby plugin and RubyMine
94 | /.rakeTasks
95 |
96 | # Crashlytics plugin (for Android Studio and IntelliJ)
97 | com_crashlytics_export_strings.xml
98 | crashlytics.properties
99 | crashlytics-build.properties
100 | fabric.properties
101 |
102 | ### PyCharm Patch ###
103 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
104 |
105 | # *.iml
106 | # modules.xml
107 | # .idea/misc.xml
108 | # *.ipr
109 |
110 | # Sonarlint plugin
111 | .idea/sonarlint
112 |
113 | ### Python ###
114 | # Byte-compiled / optimized / DLL files
115 | __pycache__/
116 | *.py[cod]
117 | *$py.class
118 |
119 | # C extensions
120 | *.so
121 |
122 | # Distribution / packaging
123 | .Python
124 | build/
125 | develop-eggs/
126 | dist/
127 | downloads/
128 | eggs/
129 | .eggs/
130 | lib/
131 | lib64/
132 | parts/
133 | sdist/
134 | var/
135 | wheels/
136 | *.egg-info/
137 | .installed.cfg
138 | *.egg
139 |
140 | # PyInstaller
141 | # Usually these files are written by a python script from a template
142 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
143 | *.manifest
144 | *.spec
145 |
146 | # Installer logs
147 | pip-log.txt
148 | pip-delete-this-directory.txt
149 |
150 | # Unit test / coverage reports
151 | htmlcov/
152 | .tox/
153 | .coverage
154 | .coverage.*
155 | .cache
156 | .pytest_cache/
157 | nosetests.xml
158 | coverage.xml
159 | *.cover
160 | .hypothesis/
161 |
162 | # Translations
163 | *.mo
164 | *.pot
165 |
166 | # Flask stuff:
167 | instance/
168 | .webassets-cache
169 |
170 | # Scrapy stuff:
171 | .scrapy
172 |
173 | # Sphinx documentation
174 | docs/_build/
175 |
176 | # PyBuilder
177 | target/
178 |
179 | # Jupyter Notebook
180 | .ipynb_checkpoints
181 |
182 | # pyenv
183 | .python-version
184 |
185 | # celery beat schedule file
186 | celerybeat-schedule.*
187 |
188 | # SageMath parsed files
189 | *.sage.py
190 |
191 | # Environments
192 | .env
193 | .venv
194 | env/
195 | venv/
196 | ENV/
197 | env.bak/
198 | venv.bak/
199 |
200 | # Spyder project settings
201 | .spyderproject
202 | .spyproject
203 |
204 | # Rope project settings
205 | .ropeproject
206 |
207 | # mkdocs documentation
208 | /site
209 |
210 | # mypy
211 | .mypy_cache/
212 |
213 | ### VisualStudioCode ###
214 | .vscode/*
215 | !.vscode/settings.json
216 | !.vscode/tasks.json
217 | !.vscode/launch.json
218 | !.vscode/extensions.json
219 | .history
220 |
221 | ### Windows ###
222 | # Windows thumbnail cache files
223 | Thumbs.db
224 | ehthumbs.db
225 | ehthumbs_vista.db
226 |
227 | # Folder config file
228 | Desktop.ini
229 |
230 | # Recycle Bin used on file shares
231 | $RECYCLE.BIN/
232 |
233 | # Windows Installer files
234 | *.cab
235 | *.msi
236 | *.msm
237 | *.msp
238 |
239 | # Windows shortcuts
240 | *.lnk
241 |
242 | # Build folder
243 |
244 | */build/*
245 |
246 | # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode
247 |
--------------------------------------------------------------------------------
/.idea/AccountManager.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/.idea/aws.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 |
10 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # AWS
2 |
3 | _If we keep ignoring usability bugs, the customers will fix them themselves. Ideally with Lambda._ --AWS, presumably
4 |
5 | This is an AWS Accounts Email Organizer. It will help users of AWS Organizations to keep track of which email addresses are tied to which accounts.
6 |
7 | Here's how it works.
8 |
9 | * You reserve a new domain name. It can be whatever you want. It doesn't "NEED" to stay private, but it's better if fewer people outside your Team know about it. For this example we will assume you reserved TwitterForPets.com
10 |
11 | * Deploy the stack with the Domain:TwitterForPets.com, MyHostedZone:\[Public Hosted Zone ID that Route53 created for TwitterForPets.com\], DefaultEmail:Mike@TwitterForPets.com
12 |
13 | * When you create a new AWS Account in AWS Organizations, give the account a meaningful account name then pick whatever unused email address you want from your new domain, for example Corey@TwitterForPets.com. You don't have to do any other work to prepare the email address, you just need to make sure that it isn't in use by another one of your AWS Accounts.
14 |
15 | * When the new AWS Account receives an email, a Lambda Function will look up the account email and account id and add them to a dynamoDB Table, then forward the email to the provided default email address with the subject line prepended with the account ID.
16 |
17 | * You can update the table entry to point to any other email address you want. You can also update the table before the first email, but letting the first automatic one create the Item keeps me from making a typo.
18 |
19 |
20 | You will need to cut a free support ticket to AWS Support to ask them to un-sandbox AWS SES inside your account.
21 |
22 | The current version of this app require that it be deployed in the root so that it can access AWS Organizations to dereference emails to account IDs. If you wanted to maintain the table yourself, you could put this stack in any account you wish.
23 |
24 | # Installation
25 |
26 | Installation is as a standard AWS SAM project, with 4 environment specific parameters:
27 |
28 | 0) Clone the repo, if you haven't already
29 |
30 | 1) Have a bucket handy
31 | Your AWS account should have an S3 bucket, and a Route53 Hosted Zone
32 |
33 | 2) Setup your 4 paramaters in environment variables
34 | ```
35 | export BUCKETNAME=
36 | export HOSTEDZONEID=
37 | export DOMAINNAME=
38 | export DEFAULTEMAIL=
39 | ```
40 |
41 | 3) From a CLI with correct AWS access:
42 | ```
43 | sam build
44 | sam package --s3-bucket ${BUCKETNAME} --output-template-file output.yaml
45 | sam deploy --template-file ./output.yaml --stack-name AWS-Account-Manager-Email-Manager --capabilities CAPABILITY_AUTO_EXPAND CAPABILITY_IAM --parameter-overrides "MyHostedZone=${HOSTEDZONEID}" "DefaultEmail=${DEFAULTEMAIL}" "Domain=${DOMAINNAME}"
46 | ```
47 |
48 | 4) Create some accounts using AWS Organizations, and watch the emails flow.
49 |
50 | For a further challenge, try getting AWS Control Tower to create your accounts through Service Catalog & AWS Organizations
51 |
52 | _Its been releases GA, so should be fine_ -- said someone
53 |
54 | (Hint - change your Accounts email to use your new domainname before you try using Control Tower)
55 |
--------------------------------------------------------------------------------
/env.json:
--------------------------------------------------------------------------------
1 | {
2 | "HelloWorldFunction": {
3 | "TABLE_NAME": "sesTest03-EmailToAccountId-ZL204HJGDVZU",
4 | "DEFAULT_EMAIL": "boydrh@gmail.com"
5 | }
6 | }
--------------------------------------------------------------------------------
/event.json:
--------------------------------------------------------------------------------
1 | {
2 | "Records": [
3 | {
4 | "eventVersion": "2.0",
5 | "eventSource": "aws:s3",
6 | "awsRegion": "us-east-1",
7 | "eventTime": "1970-01-01T00:00:00.000Z",
8 | "eventName": "ObjectCreated:Put",
9 | "userIdentity": {
10 | "principalId": "EXAMPLE"
11 | },
12 | "requestParameters": {
13 | "sourceIPAddress": "127.0.0.1"
14 | },
15 | "responseElements": {
16 | "x-amz-request-id": "EXAMPLE123456789",
17 | "x-amz-id-2": "EXAMPLE123/5678abcdefghijklambdaisawesome/mnopqrstuvwxyzABCDEFGH"
18 | },
19 | "s3": {
20 | "s3SchemaVersion": "1.0",
21 | "configurationId": "testConfigRule",
22 | "bucket": {
23 | "name": "inbound-email-637546244970-us-east-1",
24 | "ownerIdentity": {
25 | "principalId": "EXAMPLE"
26 | },
27 | "arn": "arn:aws:s3:::example-bucket"
28 | },
29 | "object": {
30 | "key": "inbound_email/c4tcsfkk20ooq1e73o16b2lkv8dknb4p04jrb2o3",
31 | "size": 1024,
32 | "eTag": "0123456789abcdef0123456789abcdef",
33 | "sequencer": "0A1B2C3D4E5F678901"
34 | }
35 | }
36 | }
37 | ]
38 | }
--------------------------------------------------------------------------------
/hello_world/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rhboyd/AWSAccountManager/faf774c3802f3396ac455be98067d217ade57ebb/hello_world/__init__.py
--------------------------------------------------------------------------------
/hello_world/app.py:
--------------------------------------------------------------------------------
1 | import json
2 | import os
3 | import email
4 | from email import policy
5 | from email.parser import Parser
6 | import boto3
7 | from botocore.exceptions import ClientError
8 |
9 | DEFAULT_EMAIL = os.environ['DEFAULT_EMAIL']
10 |
11 | ses_client = boto3.client('ses')
12 | s3 = boto3.resource('s3')
13 | s3Client = boto3.client('s3')
14 | dynamodb = boto3.resource('dynamodb')
15 | table = dynamodb.Table(os.environ['TABLE_NAME'])
16 |
17 | class AccountDeets(object):
18 | def __init__(self, email_address: str, account_id: str, internal_email_address: str):
19 | self._email_address = email_address
20 | self._account_id = account_id
21 | self._internal_email_address = internal_email_address
22 |
23 | @property
24 | def email_address(self) -> str:
25 | return self._email_address
26 |
27 | @property
28 | def account_id(self) -> str:
29 | return self._account_id
30 |
31 | @property
32 | def internal_email_address(self) -> str:
33 | return self._internal_email_address
34 |
35 |
36 | def get_account_info(email_address: str) -> AccountDeets:
37 | # Ensure we have only the email - not the realname part.
38 | _, email_address = email.utils.parseaddr(email_address)
39 |
40 | try:
41 | response = table.get_item(
42 | Key={
43 | 'EmailAddress': email_address
44 | }
45 | )
46 | return AccountDeets(
47 | email_address=email_address,
48 | account_id=response['Item']['AccountId'],
49 | internal_email_address=response['Item']['InternalEmail']
50 | )
51 | except KeyError as e:
52 | orgs_client = boto3.client('organizations')
53 | paginator = orgs_client.get_paginator('list_accounts')
54 | response_iterator = paginator.paginate()
55 | for page in response_iterator:
56 | for account in page['Accounts']:
57 | if account['Email'] == email_address:
58 | table.put_item(
59 | Item={
60 | 'EmailAddress': account['Email'],
61 | 'AccountId': account['Id'],
62 | 'InternalEmail': DEFAULT_EMAIL
63 | },
64 | )
65 | return AccountDeets(
66 | email_address=account['Email'],
67 | account_id=account['Id'],
68 | internal_email_address=DEFAULT_EMAIL
69 | )
70 | raise Exception("Account not found for Email Address")
71 | except Exception as e:
72 | print(e)
73 | raise Exception("Ya done goofed! Make sure you set the Table Name correctly and your permissions are good")
74 |
75 |
76 | def lambda_handler(event, context):
77 | for record in event['Records']:
78 |
79 | bucket = record["s3"]["bucket"]["name"]
80 | key = record["s3"]["object"]["key"]
81 | obj = s3.Object(bucket, key)
82 | raw_contents = obj.get()['Body'].read()
83 | msg = Parser(policy=policy.default).parsestr(raw_contents.decode('utf-8'))
84 |
85 | orig_to = msg['to']
86 | orig_subject = msg['subject']
87 |
88 | print('To: ', msg['to'])
89 | print('From: ', msg['from'])
90 | print('Subject: ', msg['subject'])
91 |
92 | account = get_account_info(msg['to'])
93 |
94 | del msg['DKIM-Signature']
95 | del msg['Sender']
96 | del msg['subject']
97 | del msg['Source']
98 | del msg['From']
99 | del msg['Return-Path']
100 |
101 | msg['subject'] = "[{}]: {}".format(account.account_id, orig_subject)
102 |
103 | try:
104 | response = ses_client.send_raw_email(
105 | RawMessage=dict(Data=msg.as_string()),
106 | Destinations=[
107 | account.internal_email_address
108 | ],
109 | Source=orig_to
110 | )
111 | except ClientError as e:
112 | print(e.response['Error']['Message'])
113 | raise e
114 | else:
115 | print("Email sent. Message ID: ", response['MessageId'])
116 |
117 | return {
118 | "statusCode": 200,
119 | "body": json.dumps(response),
120 | }
121 |
--------------------------------------------------------------------------------
/hello_world/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
--------------------------------------------------------------------------------
/ses_verify/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rhboyd/AWSAccountManager/faf774c3802f3396ac455be98067d217ade57ebb/ses_verify/__init__.py
--------------------------------------------------------------------------------
/ses_verify/app.py:
--------------------------------------------------------------------------------
1 | import json
2 | import boto3
3 | import requests
4 | import uuid
5 |
6 | def send(event, context, responseStatus, responseData, physicalResourceId):
7 | responseUrl = event['ResponseURL']
8 | print(responseUrl)
9 | responseBody = {}
10 | responseBody['Status'] = responseStatus
11 | responseBody['Reason'] = 'See the details in CloudWatch Log Stream: ' + context.log_stream_name
12 | responseBody['PhysicalResourceId'] = physicalResourceId
13 | responseBody['StackId'] = event['StackId']
14 | responseBody['RequestId'] = event['RequestId']
15 | responseBody['LogicalResourceId'] = event['LogicalResourceId']
16 | responseBody['Data'] = responseData
17 | json_responseBody = json.dumps(responseBody)
18 | print("Response body:\n" + json_responseBody)
19 | headers = {
20 | 'content-type': '',
21 | 'content-length': str(len(json_responseBody))
22 | }
23 | try:
24 | response = requests.put(responseUrl,
25 | data=json_responseBody,
26 | headers=headers)
27 | print("Status code: " + response.reason)
28 | except Exception as e:
29 | print("send(..) failed executing requests.put(..): " + str(e))
30 |
31 | def _get_hosted_zone_name(hosted_zone_id):
32 | route53 = boto3.client('route53')
33 | route53_resp = route53.get_hosted_zone(
34 | Id=hosted_zone_id
35 | )
36 | return route53_resp['HostedZone']['Name']
37 |
38 | def verify_ses(hosted_zone_id, action):
39 | ses = boto3.client('ses')
40 | print("Retrieving Hosted Zone name")
41 | hosted_zone_name = _get_hosted_zone_name(hosted_zone_id=hosted_zone_id)
42 | print('Hosted zone name: {hosted_zone_name}'.format(hosted_zone_name=hosted_zone_name))
43 | domain = hosted_zone_name.rstrip('.')
44 | verification_token = ses.verify_domain_identity(
45 | Domain=domain
46 | )['VerificationToken']
47 | dkim_tokens = ses.verify_domain_dkim(
48 | Domain=domain
49 | )['DkimTokens']
50 | print('Changing resource record sets')
51 | changes = [
52 | {
53 | 'Action': action,
54 | 'ResourceRecordSet': {
55 | 'Name': "_amazonses.{hosted_zone_name}".format(hosted_zone_name=hosted_zone_name),
56 | 'Type': 'TXT',
57 | 'TTL': 1800,
58 | 'ResourceRecords': [
59 | {
60 | 'Value': '"{verification_token}"'.format(verification_token=verification_token)
61 | }
62 | ]
63 | }
64 | }
65 | ]
66 | for dkim_token in dkim_tokens:
67 | change = {
68 | 'Action': action,
69 | 'ResourceRecordSet': {
70 | 'Name': "{dkim_token}._domainkey.{hosted_zone_name}".format(
71 | dkim_token=dkim_token,
72 | hosted_zone_name=hosted_zone_name
73 | ),
74 | 'Type': 'CNAME',
75 | 'TTL': 1800,
76 | 'ResourceRecords': [
77 | {
78 | 'Value': "{dkim_token}.dkim.amazonses.com".format(dkim_token=dkim_token)
79 | }
80 | ]
81 | }
82 | }
83 | changes.append(change)
84 | boto3.client('route53').change_resource_record_sets(
85 | ChangeBatch={
86 | 'Changes': changes
87 | },
88 | HostedZoneId=hosted_zone_id
89 | )
90 |
91 |
92 | def lambda_handler(event, context):
93 | print("Received event: ")
94 | print(event)
95 | resource_type = event['ResourceType']
96 | request_type = event['RequestType']
97 | resource_properties = event['ResourceProperties']
98 | hosted_zone_id = resource_properties['HostedZoneId']
99 | ruleset_name = resource_properties['RuleSetName']
100 | ses = boto3.client('ses')
101 | ses.set_active_receipt_rule_set(RuleSetName=ruleset_name)
102 | physical_resource_id = event.get('PhysicalResourceId', str(uuid.uuid4()))
103 | try:
104 | if resource_type == "Custom::AmazonSesVerificationRecords":
105 | if request_type == 'Create':
106 | verify_ses(hosted_zone_id=hosted_zone_id, action='UPSERT')
107 | elif request_type == 'Delete':
108 | verify_ses(hosted_zone_id=hosted_zone_id, action='DELETE')
109 | elif request_type == 'Update':
110 | old_hosted_zone_id = event['OldResourceProperties']['HostedZoneId']
111 | verify_ses(hosted_zone_id=old_hosted_zone_id, action='DELETE')
112 | verify_ses(hosted_zone_id=hosted_zone_id, action='UPSERT')
113 | else:
114 | print('Request type is {request_type}, doing nothing.'.format(request_type=request_type))
115 | response_data = {"domain": _get_hosted_zone_name(hosted_zone_id)}
116 | else:
117 | raise ValueError("Unexpected resource_type: {resource_type}".format(resource_type=resource_type))
118 | except Exception:
119 | send(
120 | event,
121 | context,
122 | responseStatus="FAILED" if request_type != 'Delete' else "SUCCESS",
123 | responseData=None,
124 | physicalResourceId=physical_resource_id,
125 | )
126 | raise
127 | else:
128 | send(
129 | event,
130 | context,
131 | responseStatus="SUCCESS",
132 | responseData=response_data,
133 | physicalResourceId=physical_resource_id,
134 | )
--------------------------------------------------------------------------------
/ses_verify/requirements.txt:
--------------------------------------------------------------------------------
1 | requests
2 | boto3
--------------------------------------------------------------------------------
/template.yaml:
--------------------------------------------------------------------------------
1 | AWSTemplateFormatVersion: '2010-09-09'
2 | Transform: AWS::Serverless-2016-10-31
3 | Description: >
4 | AWS
5 |
6 | Sample SAM Template for AWS
7 |
8 | # More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
9 | Parameters:
10 | MyHostedZone:
11 | Type: String
12 | Description: HostedZone ID for the Domain's Public Hosted Zone in Route53
13 | Domain:
14 | Type: String
15 | Description: Domain Name to use for the MX Record
16 | DefaultEmail:
17 | Type: String
18 | Description: Email to use as a Default in the event that the Table wasn't updated
19 |
20 |
21 | Resources:
22 | SESVerifierFunction:
23 | Type: AWS::Serverless::Function
24 | Properties:
25 | CodeUri: ses_verify/
26 | Handler: app.lambda_handler
27 | Runtime: python3.7
28 | Timeout: 300
29 | Policies:
30 | - Statement:
31 | - Action:
32 | - route53:GetHostedZone
33 | - route53:ChangeResourceRecordSets
34 | Effect: "Allow"
35 | Resource: !Sub "arn:aws:route53:::hostedzone/${MyHostedZone}"
36 | - Statement:
37 | - Action:
38 | - ses:VerifyDomainDkim
39 | - ses:VerifyDomainIdentity
40 | - ses:SetActiveReceiptRuleSet
41 | Effect: "Allow"
42 | Resource: "*"
43 |
44 | SesVerificationRecords:
45 | Type: Custom::AmazonSesVerificationRecords
46 | Properties:
47 | ServiceToken: !GetAtt SESVerifierFunction.Arn
48 | HostedZoneId: !Ref MyHostedZone
49 | RuleSetName: !Ref RuleSet
50 |
51 | HelloWorldFunction:
52 | Type: AWS::Serverless::Function
53 | Properties:
54 | CodeUri: hello_world/
55 | Handler: app.lambda_handler
56 | Runtime: python3.7
57 | Timeout: 300
58 | Policies:
59 | - Statement:
60 | - Action:
61 | - "s3:GetObject"
62 | Effect: "Allow"
63 | Resource: !Sub "arn:aws:s3:::inbound-email-${AWS::AccountId}-${AWS::Region}/inbound_email/*"
64 | - Statement:
65 | - Action:
66 | - "ses:SendEmail"
67 | - "ses:SendRawEmail"
68 | Effect: "Allow"
69 | Resource: "*"
70 | - Statement:
71 | - Action:
72 | - "organizations:ListAccounts"
73 | Effect: "Allow"
74 | Resource: "*"
75 | - Statement:
76 | - Action:
77 | - "dynamodb:GetItem"
78 | - "dynamodb:PutItem"
79 | Effect: "Allow"
80 | Resource: !GetAtt EmailToAccountId.Arn
81 | Environment:
82 | Variables:
83 | TABLE_NAME: !Ref EmailToAccountId
84 | DEFAULT_EMAIL: !Ref DefaultEmail
85 | BucketPermission:
86 | Type: AWS::Lambda::Permission
87 | Properties:
88 | Action: 'lambda:InvokeFunction'
89 | FunctionName: !Ref HelloWorldFunction
90 | Principal: s3.amazonaws.com
91 | SourceAccount: !Ref "AWS::AccountId"
92 | SourceArn: !Sub "arn:aws:s3:::inbound-email-${AWS::AccountId}-${AWS::Region}"
93 |
94 | EmailBucket:
95 | DependsOn: BucketPermission
96 | Type: AWS::S3::Bucket
97 | Properties:
98 | BucketName: !Sub "inbound-email-${AWS::AccountId}-${AWS::Region}"
99 | NotificationConfiguration:
100 | LambdaConfigurations:
101 | - Event: s3:ObjectCreated:*
102 | Filter:
103 | S3Key:
104 | Rules:
105 | - Name: prefix
106 | Value: inbound_email/
107 |
108 | Function: !GetAtt HelloWorldFunction.Arn
109 |
110 | PutEmailBucketPolicy:
111 | Type: AWS::S3::BucketPolicy
112 | Properties:
113 | Bucket: !Ref EmailBucket
114 | PolicyDocument:
115 | Statement:
116 | - Action:
117 | - "s3:PutObject"
118 | Effect: "Allow"
119 | Resource: !Sub "arn:aws:s3:::${EmailBucket}/inbound_email/*"
120 | Principal:
121 | Service: "ses.amazonaws.com"
122 | Condition:
123 | StringEquals:
124 | aws:Referer: !Sub "${AWS::AccountId}"
125 | RuleSet:
126 | Type: AWS::SES::ReceiptRuleSet
127 | Properties:
128 | RuleSetName: SendToS3
129 |
130 | EmailRule:
131 | DependsOn: PutEmailBucketPolicy
132 | Type: AWS::SES::ReceiptRule
133 | Properties:
134 | Rule:
135 | Actions:
136 | - S3Action:
137 | BucketName: !Ref EmailBucket
138 | ObjectKeyPrefix: inbound_email/
139 | Enabled: True
140 | ScanEnabled: True
141 | TlsPolicy: Require
142 | RuleSetName: !Ref RuleSet
143 |
144 | EmailRecordsForRoute53:
145 | Type: AWS::Route53::RecordSet
146 | Properties:
147 | Type: MX
148 | TTL: "300"
149 | Name: !Sub "${Domain}."
150 | HostedZoneName: !Sub "${Domain}."
151 | ResourceRecords:
152 | - !Sub "10 inbound-smtp.${AWS::Region}.amazonaws.com"
153 |
154 | EmailToAccountId:
155 | Type: AWS::DynamoDB::Table
156 | Properties:
157 | AttributeDefinitions:
158 | -
159 | AttributeName: "EmailAddress"
160 | AttributeType: "S"
161 | KeySchema:
162 | -
163 | AttributeName: "EmailAddress"
164 | KeyType: "HASH"
165 | ProvisionedThroughput:
166 | ReadCapacityUnits: "3"
167 | WriteCapacityUnits: "1"
168 |
--------------------------------------------------------------------------------
/tests/unit/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/rhboyd/AWSAccountManager/faf774c3802f3396ac455be98067d217ade57ebb/tests/unit/__init__.py
--------------------------------------------------------------------------------
/tests/unit/test_handler.py:
--------------------------------------------------------------------------------
1 | import json
2 |
3 | import pytest
4 |
5 | from hello_world import app
6 |
7 |
8 | @pytest.fixture()
9 | def apigw_event():
10 | """ Generates API GW Event"""
11 |
12 | return {
13 | "body": '{ "test": "body"}',
14 | "resource": "/{proxy+}",
15 | "requestContext": {
16 | "resourceId": "123456",
17 | "apiId": "1234567890",
18 | "resourcePath": "/{proxy+}",
19 | "httpMethod": "POST",
20 | "requestId": "c6af9ac6-7b61-11e6-9a41-93e8deadbeef",
21 | "accountId": "123456789012",
22 | "identity": {
23 | "apiKey": "",
24 | "userArn": "",
25 | "cognitoAuthenticationType": "",
26 | "caller": "",
27 | "userAgent": "Custom User Agent String",
28 | "user": "",
29 | "cognitoIdentityPoolId": "",
30 | "cognitoIdentityId": "",
31 | "cognitoAuthenticationProvider": "",
32 | "sourceIp": "127.0.0.1",
33 | "accountId": "",
34 | },
35 | "stage": "prod",
36 | },
37 | "queryStringParameters": {"foo": "bar"},
38 | "headers": {
39 | "Via": "1.1 08f323deadbeefa7af34d5feb414ce27.cloudfront.net (CloudFront)",
40 | "Accept-Language": "en-US,en;q=0.8",
41 | "CloudFront-Is-Desktop-Viewer": "true",
42 | "CloudFront-Is-SmartTV-Viewer": "false",
43 | "CloudFront-Is-Mobile-Viewer": "false",
44 | "X-Forwarded-For": "127.0.0.1, 127.0.0.2",
45 | "CloudFront-Viewer-Country": "US",
46 | "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
47 | "Upgrade-Insecure-Requests": "1",
48 | "X-Forwarded-Port": "443",
49 | "Host": "1234567890.execute-api.us-east-1.amazonaws.com",
50 | "X-Forwarded-Proto": "https",
51 | "X-Amz-Cf-Id": "aaaaaaaaaae3VYQb9jd-nvCd-de396Uhbp027Y2JvkCPNLmGJHqlaA==",
52 | "CloudFront-Is-Tablet-Viewer": "false",
53 | "Cache-Control": "max-age=0",
54 | "User-Agent": "Custom User Agent String",
55 | "CloudFront-Forwarded-Proto": "https",
56 | "Accept-Encoding": "gzip, deflate, sdch",
57 | },
58 | "pathParameters": {"proxy": "/examplepath"},
59 | "httpMethod": "POST",
60 | "stageVariables": {"baz": "qux"},
61 | "path": "/examplepath",
62 | }
63 |
64 |
65 | def test_lambda_handler(apigw_event, mocker):
66 |
67 | ret = app.lambda_handler(apigw_event, "")
68 | data = json.loads(ret["body"])
69 |
70 | assert ret["statusCode"] == 200
71 | assert "message" in ret["body"]
72 | assert data["message"] == "hello world"
73 | # assert "location" in data.dict_keys()
74 |
--------------------------------------------------------------------------------