From 9a92e87f020efb97fe2c1521b7c4ef49c467bf89 Mon Sep 17 00:00:00 2001 From: Steve Pulec Date: Sun, 24 Feb 2013 23:30:51 -0500 Subject: [PATCH] add ses core --- moto/__init__.py | 1 + moto/ses/__init__.py | 2 + moto/ses/models.py | 72 +++++++++++++++++ moto/ses/responses.py | 153 +++++++++++++++++++++++++++++++++++++ moto/ses/urls.py | 7 ++ moto/ses/utils.py | 18 +++++ tests/test_ses/test_ses.py | 86 +++++++++++++++++++++ 7 files changed, 339 insertions(+) create mode 100644 moto/ses/__init__.py create mode 100644 moto/ses/models.py create mode 100644 moto/ses/responses.py create mode 100644 moto/ses/urls.py create mode 100644 moto/ses/utils.py create mode 100644 tests/test_ses/test_ses.py diff --git a/moto/__init__.py b/moto/__init__.py index e9a5815ed..0548f9653 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -4,4 +4,5 @@ logging.getLogger('boto').setLevel(logging.CRITICAL) from .dynamodb import mock_dynamodb from .ec2 import mock_ec2 from .s3 import mock_s3 +from .ses import mock_ses from .sqs import mock_sqs diff --git a/moto/ses/__init__.py b/moto/ses/__init__.py new file mode 100644 index 000000000..77bc397fb --- /dev/null +++ b/moto/ses/__init__.py @@ -0,0 +1,2 @@ +from .models import ses_backend +mock_ses = ses_backend.decorator diff --git a/moto/ses/models.py b/moto/ses/models.py new file mode 100644 index 000000000..546ff5b77 --- /dev/null +++ b/moto/ses/models.py @@ -0,0 +1,72 @@ +import md5 + +from moto.core import BaseBackend +from .utils import get_random_message_id + + +class Message(object): + def __init__(self, message_id, source, subject, body, destination): + self.id = message_id + self.source = source + self.subject = subject + self.body = body + self.destination = destination + + +class RawMessage(object): + def __init__(self, message_id, source, destination, raw_data): + self.id = message_id + self.source = source + self.destination = destination + self.raw_data = raw_data + + +class SESQuota(object): + def __init__(self, messages): + self.messages = messages + + @property + def sent_past_24(self): + return len(self.messages) + + +class SESBackend(BaseBackend): + def __init__(self): + self.addresses = [] + self.sent_messages = [] + + def verify_email_identity(self, address): + self.addresses.append(address) + + def verify_domain(self, domain): + self.addresses.append(domain) + + def list_identities(self): + return self.addresses + + def delete_identity(self, identity): + self.addresses.remove(identity) + + def send_email(self, source, subject, body, destination): + if source not in self.addresses: + return False + + message_id = get_random_message_id() + message = Message(message_id, source, subject, body, destination) + self.sent_messages.append(message) + return message + + def send_raw_email(self, source, destination, raw_data): + if source not in self.addresses: + return False + + message_id = get_random_message_id() + message = RawMessage(message_id, source, destination, raw_data) + self.sent_messages.append(message) + return message + + def get_send_quota(self): + return SESQuota(self.sent_messages) + + +ses_backend = SESBackend() \ No newline at end of file diff --git a/moto/ses/responses.py b/moto/ses/responses.py new file mode 100644 index 000000000..8ea3a7e7f --- /dev/null +++ b/moto/ses/responses.py @@ -0,0 +1,153 @@ +import re +from urlparse import parse_qs + +from jinja2 import Template + +from moto.core.utils import headers_to_dict, camelcase_to_underscores, method_names_from_class +from .models import ses_backend + + +class BaseResponse(object): + def dispatch(self, uri, body, headers): + querystring = parse_qs(body) + + self.path = uri.path + self.querystring = querystring + + action = querystring['Action'][0] + action = camelcase_to_underscores(action) + + method_names = method_names_from_class(self.__class__) + if action in method_names: + method = getattr(self, action) + return method() + raise NotImplementedError("The {} action has not been implemented".format(action)) + + +class EmailResponse(BaseResponse): + + def verify_email_identity(self): + address = self.querystring.get('EmailAddress')[0] + ses_backend.verify_email_identity(address) + template = Template(VERIFY_EMAIL_IDENTITY) + return template.render() + + def list_identities(self): + identities = ses_backend.list_identities() + template = Template(LIST_IDENTITIES_RESPONSE) + return template.render(identities=identities) + + def verify_domain_dkim(self): + domain = self.querystring.get('Domain')[0] + ses_backend.verify_domain(domain) + template = Template(VERIFY_DOMAIN_DKIM_RESPONSE) + return template.render() + + def verify_domain_identity(self): + domain = self.querystring.get('Domain')[0] + ses_backend.verify_domain(domain) + template = Template(VERIFY_DOMAIN_DKIM_RESPONSE) + return template.render() + + def delete_identity(self): + domain = self.querystring.get('Identity')[0] + ses_backend.delete_identity(domain) + template = Template(DELETE_IDENTITY_RESPONSE) + return template.render() + + def send_email(self): + body = self.querystring.get('Message.Body.Text.Data')[0] + source = self.querystring.get('Source')[0] + subject = self.querystring.get('Message.Subject.Data')[0] + destination = self.querystring.get('Destination.ToAddresses.member.1')[0] + message = ses_backend.send_email(source, subject, body, destination) + if not message: + return "Did not have authority to send from email {}".format(source), dict(status=400) + template = Template(SEND_EMAIL_RESPONSE) + return template.render(message=message) + + def send_raw_email(self): + source = self.querystring.get('Source')[0] + destination = self.querystring.get('Destinations.member.1')[0] + raw_data = self.querystring.get('RawMessage.Data')[0] + + message = ses_backend.send_raw_email(source, destination, raw_data) + if not message: + return "Did not have authority to send from email {}".format(source), dict(status=400) + template = Template(SEND_RAW_EMAIL_RESPONSE) + return template.render(message=message) + + def get_send_quota(self): + quota = ses_backend.get_send_quota() + template = Template(GET_SEND_QUOTA_RESPONSE) + return template.render(quota=quota) + + +VERIFY_EMAIL_IDENTITY = """ + + + 47e0ef1a-9bf2-11e1-9279-0100e8cf109a + +""" + +LIST_IDENTITIES_RESPONSE = """ + + + {% for identity in identities %} + {{ identity }} + {% endfor %} + + + + cacecf23-9bf1-11e1-9279-0100e8cf109a + +""" + +VERIFY_DOMAIN_DKIM_RESPONSE = """ + + + vvjuipp74whm76gqoni7qmwwn4w4qusjiainivf6sf + 3frqe7jn4obpuxjpwpolz6ipb3k5nvt2nhjpik2oy + wrqplteh7oodxnad7hsl4mixg2uavzneazxv5sxi2 + + + + 9662c15b-c469-11e1-99d1-797d6ecd6414 + +""" + +DELETE_IDENTITY_RESPONSE = """ + + + d96bd874-9bf2-11e1-8ee7-c98a0037a2b6 + +""" + +SEND_EMAIL_RESPONSE = """ + + {{ message.id }} + + + d5964849-c866-11e0-9beb-01a62d68c57f + +""" + +SEND_RAW_EMAIL_RESPONSE = """ + + {{ message.id }} + + + e0abcdfa-c866-11e0-b6d0-273d09173b49 + +""" + +GET_SEND_QUOTA_RESPONSE = """ + + {{ quota.sent_past_24 }} + 200.0 + 1.0 + + + 273021c6-c866-11e0-b926-699e21c3af9e + +""" diff --git a/moto/ses/urls.py b/moto/ses/urls.py new file mode 100644 index 000000000..33d6a74e6 --- /dev/null +++ b/moto/ses/urls.py @@ -0,0 +1,7 @@ +from .responses import EmailResponse + +base_url = "https://email.us-east-1.amazonaws.com" + +urls = { + '{0}/$'.format(base_url): EmailResponse().dispatch, +} diff --git a/moto/ses/utils.py b/moto/ses/utils.py new file mode 100644 index 000000000..b7edf9c4d --- /dev/null +++ b/moto/ses/utils.py @@ -0,0 +1,18 @@ +import random +import string + + +def random_hex(length): + return ''.join(random.choice(string.lowercase) for x in range(length)) + + +def get_random_message_id(): + return "{}-{}-{}-{}-{}-{}-{}".format( + random_hex(16), + random_hex(8), + random_hex(4), + random_hex(4), + random_hex(4), + random_hex(12), + random_hex(6), + ) diff --git a/tests/test_ses/test_ses.py b/tests/test_ses/test_ses.py new file mode 100644 index 000000000..934742c6f --- /dev/null +++ b/tests/test_ses/test_ses.py @@ -0,0 +1,86 @@ +import email + +import boto +from boto.exception import BotoServerError + +from sure import expect + +from moto import mock_ses + + +@mock_ses +def test_verify_email_identity(): + conn = boto.connect_ses('the_key', 'the_secret') + conn.verify_email_identity("test@example.com") + + identities = conn.list_identities() + address = identities['ListIdentitiesResponse']['ListIdentitiesResult']['Identities'][0] + address.should.equal('test@example.com') + + +@mock_ses +def test_domain_verify(): + conn = boto.connect_ses('the_key', 'the_secret') + + conn.verify_domain_dkim("domain1.com") + conn.verify_domain_identity("domain2.com") + + identities = conn.list_identities() + domains = list(identities['ListIdentitiesResponse']['ListIdentitiesResult']['Identities']) + domains.should.equal(['domain1.com', 'domain2.com']) + + +@mock_ses +def test_delete_identity(): + conn = boto.connect_ses('the_key', 'the_secret') + conn.verify_email_identity("test@example.com") + + conn.list_identities()['ListIdentitiesResponse']['ListIdentitiesResult']['Identities'].should.have.length_of(1) + conn.delete_identity("test@example.com") + conn.list_identities()['ListIdentitiesResponse']['ListIdentitiesResult']['Identities'].should.have.length_of(0) + + +@mock_ses +def test_send_email(): + conn = boto.connect_ses('the_key', 'the_secret') + + conn.send_email.when.called_with("test@example.com", "test subject", + "test body", "test_to@example.com").should.throw(BotoServerError) + + conn.verify_email_identity("test@example.com") + conn.send_email("test@example.com", "test subject", "test body", "test_to@example.com") + + send_quota = conn.get_send_quota() + sent_count = int(send_quota['GetSendQuotaResponse']['GetSendQuotaResult']['SentLast24Hours']) + sent_count.should.equal(1) + + +@mock_ses +def test_send_raw_email(): + conn = boto.connect_ses('the_key', 'the_secret') + + to = 'to@example.com' + message = email.mime.multipart.MIMEMultipart() + message['Subject'] = 'Test' + message['From'] = 'test@example.com' + message['To'] = to + + # Message body + part = email.mime.text.MIMEText('test file attached') + message.attach(part) + + # Attachment + part = email.mime.text.MIMEText('contents of test file here') + part.add_header('Content-Disposition', 'attachment; filename=test.txt') + message.attach(part) + + conn.send_raw_email.when.called_with(source=message['From'], raw_message=message.as_string(), + destinations=message['To']).should.throw(BotoServerError) + + conn.verify_email_identity("test@example.com") + conn.send_raw_email(source=message['From'], raw_message=message.as_string(), + destinations=message['To']) + + send_quota = conn.get_send_quota() + sent_count = int(send_quota['GetSendQuotaResponse']['GetSendQuotaResult']['SentLast24Hours']) + sent_count.should.equal(1)