├── .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 | 15 | 16 | 18 | -------------------------------------------------------------------------------- /.idea/aws.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 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 | --------------------------------------------------------------------------------