1# Copyright (c) 2010 Mitch Garnaat http://garnaat.org/
2# Copyright (c) 2011 Harry Marr http://hmarr.com/
3#
4# Permission is hereby granted, free of charge, to any person obtaining a
5# copy of this software and associated documentation files (the
6# "Software"), to deal in the Software without restriction, including
7# without limitation the rights to use, copy, modify, merge, publish, dis-
8# tribute, sublicense, and/or sell copies of the Software, and to permit
9# persons to whom the Software is furnished to do so, subject to the fol-
10# lowing conditions:
11#
12# The above copyright notice and this permission notice shall be included
13# in all copies or substantial portions of the Software.
14#
15# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
16# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
17# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
18# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
19# 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
21# IN THE SOFTWARE.
22import re
23import base64
24
25from boto.compat import six, urllib
26from boto.connection import AWSAuthConnection
27from boto.exception import BotoServerError
28from boto.regioninfo import RegionInfo
29import boto
30import boto.jsonresponse
31from boto.ses import exceptions as ses_exceptions
32
33
34class SESConnection(AWSAuthConnection):
35
36    ResponseError = BotoServerError
37    DefaultRegionName = 'us-east-1'
38    DefaultRegionEndpoint = 'email.us-east-1.amazonaws.com'
39    APIVersion = '2010-12-01'
40
41    def __init__(self, aws_access_key_id=None, aws_secret_access_key=None,
42                 is_secure=True, port=None, proxy=None, proxy_port=None,
43                 proxy_user=None, proxy_pass=None, debug=0,
44                 https_connection_factory=None, region=None, path='/',
45                 security_token=None, validate_certs=True, profile_name=None):
46        if not region:
47            region = RegionInfo(self, self.DefaultRegionName,
48                                self.DefaultRegionEndpoint)
49        self.region = region
50        super(SESConnection, self).__init__(self.region.endpoint,
51                                            aws_access_key_id, aws_secret_access_key,
52                                            is_secure, port, proxy, proxy_port,
53                                            proxy_user, proxy_pass, debug,
54                                            https_connection_factory, path,
55                                            security_token=security_token,
56                                            validate_certs=validate_certs,
57                                            profile_name=profile_name)
58
59    def _required_auth_capability(self):
60        return ['ses']
61
62    def _build_list_params(self, params, items, label):
63        """Add an AWS API-compatible parameter list to a dictionary.
64
65        :type params: dict
66        :param params: The parameter dictionary
67
68        :type items: list
69        :param items: Items to be included in the list
70
71        :type label: string
72        :param label: The parameter list's name
73        """
74        if isinstance(items, six.string_types):
75            items = [items]
76        for i in range(1, len(items) + 1):
77            params['%s.%d' % (label, i)] = items[i - 1]
78
79    def _make_request(self, action, params=None):
80        """Make a call to the SES API.
81
82        :type action: string
83        :param action: The API method to use (e.g. SendRawEmail)
84
85        :type params: dict
86        :param params: Parameters that will be sent as POST data with the API
87            call.
88        """
89        ct = 'application/x-www-form-urlencoded; charset=UTF-8'
90        headers = {'Content-Type': ct}
91        params = params or {}
92        params['Action'] = action
93
94        for k, v in params.items():
95            if isinstance(v, six.text_type):  # UTF-8 encode only if it's Unicode
96                params[k] = v.encode('utf-8')
97
98        response = super(SESConnection, self).make_request(
99            'POST',
100            '/',
101            headers=headers,
102            data=urllib.parse.urlencode(params)
103        )
104        body = response.read().decode('utf-8')
105        if response.status == 200:
106            list_markers = ('VerifiedEmailAddresses', 'Identities',
107                            'DkimTokens', 'DkimAttributes',
108                            'VerificationAttributes', 'SendDataPoints')
109            item_markers = ('member', 'item', 'entry')
110
111            e = boto.jsonresponse.Element(list_marker=list_markers,
112                                          item_marker=item_markers)
113            h = boto.jsonresponse.XmlHandler(e, None)
114            h.parse(body)
115            return e
116        else:
117            # HTTP codes other than 200 are considered errors. Go through
118            # some error handling to determine which exception gets raised,
119            self._handle_error(response, body)
120
121    def _handle_error(self, response, body):
122        """
123        Handle raising the correct exception, depending on the error. Many
124        errors share the same HTTP response code, meaning we have to get really
125        kludgey and do string searches to figure out what went wrong.
126        """
127        boto.log.error('%s %s' % (response.status, response.reason))
128        boto.log.error('%s' % body)
129
130        if "Address blacklisted." in body:
131            # Delivery failures happened frequently enough with the recipient's
132            # email address for Amazon to blacklist it. After a day or three,
133            # they'll be automatically removed, and delivery can be attempted
134            # again (if you write the code to do so in your application).
135            ExceptionToRaise = ses_exceptions.SESAddressBlacklistedError
136            exc_reason = "Address blacklisted."
137        elif "Email address is not verified." in body:
138            # This error happens when the "Reply-To" value passed to
139            # send_email() hasn't been verified yet.
140            ExceptionToRaise = ses_exceptions.SESAddressNotVerifiedError
141            exc_reason = "Email address is not verified."
142        elif "Daily message quota exceeded." in body:
143            # Encountered when your account exceeds the maximum total number
144            # of emails per 24 hours.
145            ExceptionToRaise = ses_exceptions.SESDailyQuotaExceededError
146            exc_reason = "Daily message quota exceeded."
147        elif "Maximum sending rate exceeded." in body:
148            # Your account has sent above its allowed requests a second rate.
149            ExceptionToRaise = ses_exceptions.SESMaxSendingRateExceededError
150            exc_reason = "Maximum sending rate exceeded."
151        elif "Domain ends with dot." in body:
152            # Recipient address ends with a dot/period. This is invalid.
153            ExceptionToRaise = ses_exceptions.SESDomainEndsWithDotError
154            exc_reason = "Domain ends with dot."
155        elif "Local address contains control or whitespace" in body:
156            # I think this pertains to the recipient address.
157            ExceptionToRaise = ses_exceptions.SESLocalAddressCharacterError
158            exc_reason = "Local address contains control or whitespace."
159        elif "Illegal address" in body:
160            # A clearly mal-formed address.
161            ExceptionToRaise = ses_exceptions.SESIllegalAddressError
162            exc_reason = "Illegal address"
163        # The re.search is to distinguish from the
164        # SESAddressNotVerifiedError error above.
165        elif re.search('Identity.*is not verified', body):
166            ExceptionToRaise = ses_exceptions.SESIdentityNotVerifiedError
167            exc_reason = "Identity is not verified."
168        elif "ownership not confirmed" in body:
169            ExceptionToRaise = ses_exceptions.SESDomainNotConfirmedError
170            exc_reason = "Domain ownership is not confirmed."
171        else:
172            # This is either a common AWS error, or one that we don't devote
173            # its own exception to.
174            ExceptionToRaise = self.ResponseError
175            exc_reason = response.reason
176
177        raise ExceptionToRaise(response.status, exc_reason, body)
178
179    def send_email(self, source, subject, body, to_addresses,
180                   cc_addresses=None, bcc_addresses=None,
181                   format='text', reply_addresses=None,
182                   return_path=None, text_body=None, html_body=None):
183        """Composes an email message based on input data, and then immediately
184        queues the message for sending.
185
186        :type source: string
187        :param source: The sender's email address.
188
189        :type subject: string
190        :param subject: The subject of the message: A short summary of the
191                        content, which will appear in the recipient's inbox.
192
193        :type body: string
194        :param body: The message body.
195
196        :type to_addresses: list of strings or string
197        :param to_addresses: The To: field(s) of the message.
198
199        :type cc_addresses: list of strings or string
200        :param cc_addresses: The CC: field(s) of the message.
201
202        :type bcc_addresses: list of strings or string
203        :param bcc_addresses: The BCC: field(s) of the message.
204
205        :type format: string
206        :param format: The format of the message's body, must be either "text"
207                       or "html".
208
209        :type reply_addresses: list of strings or string
210        :param reply_addresses: The reply-to email address(es) for the
211                                message. If the recipient replies to the
212                                message, each reply-to address will
213                                receive the reply.
214
215        :type return_path: string
216        :param return_path: The email address to which bounce notifications are
217                            to be forwarded. If the message cannot be delivered
218                            to the recipient, then an error message will be
219                            returned from the recipient's ISP; this message
220                            will then be forwarded to the email address
221                            specified by the ReturnPath parameter.
222
223        :type text_body: string
224        :param text_body: The text body to send with this email.
225
226        :type html_body: string
227        :param html_body: The html body to send with this email.
228
229        """
230        format = format.lower().strip()
231        if body is not None:
232            if format == "text":
233                if text_body is not None:
234                    raise Warning("You've passed in both a body and a "
235                                  "text_body; please choose one or the other.")
236                text_body = body
237            else:
238                if html_body is not None:
239                    raise Warning("You've passed in both a body and an "
240                                  "html_body; please choose one or the other.")
241                html_body = body
242
243        params = {
244            'Source': source,
245            'Message.Subject.Data': subject,
246        }
247
248        if return_path:
249            params['ReturnPath'] = return_path
250
251        if html_body is not None:
252            params['Message.Body.Html.Data'] = html_body
253        if text_body is not None:
254            params['Message.Body.Text.Data'] = text_body
255
256        if(format not in ("text", "html")):
257            raise ValueError("'format' argument must be 'text' or 'html'")
258
259        if(not (html_body or text_body)):
260            raise ValueError("No text or html body found for mail")
261
262        self._build_list_params(params, to_addresses,
263                                'Destination.ToAddresses.member')
264        if cc_addresses:
265            self._build_list_params(params, cc_addresses,
266                                    'Destination.CcAddresses.member')
267
268        if bcc_addresses:
269            self._build_list_params(params, bcc_addresses,
270                                    'Destination.BccAddresses.member')
271
272        if reply_addresses:
273            self._build_list_params(params, reply_addresses,
274                                    'ReplyToAddresses.member')
275
276        return self._make_request('SendEmail', params)
277
278    def send_raw_email(self, raw_message, source=None, destinations=None):
279        """Sends an email message, with header and content specified by the
280        client. The SendRawEmail action is useful for sending multipart MIME
281        emails, with attachments or inline content. The raw text of the message
282        must comply with Internet email standards; otherwise, the message
283        cannot be sent.
284
285        :type source: string
286        :param source: The sender's email address. Amazon's docs say:
287
288          If you specify the Source parameter, then bounce notifications and
289          complaints will be sent to this email address. This takes precedence
290          over any Return-Path header that you might include in the raw text of
291          the message.
292
293        :type raw_message: string
294        :param raw_message: The raw text of the message. The client is
295          responsible for ensuring the following:
296
297          - Message must contain a header and a body, separated by a blank line.
298          - All required header fields must be present.
299          - Each part of a multipart MIME message must be formatted properly.
300          - MIME content types must be among those supported by Amazon SES.
301            Refer to the Amazon SES Developer Guide for more details.
302          - Content must be base64-encoded, if MIME requires it.
303
304        :type destinations: list of strings or string
305        :param destinations: A list of destinations for the message.
306
307        """
308
309        if isinstance(raw_message, six.text_type):
310            raw_message = raw_message.encode('utf-8')
311
312        params = {
313            'RawMessage.Data': base64.b64encode(raw_message),
314        }
315
316        if source:
317            params['Source'] = source
318
319        if destinations:
320            self._build_list_params(params, destinations,
321                                    'Destinations.member')
322
323        return self._make_request('SendRawEmail', params)
324
325    def list_verified_email_addresses(self):
326        """Fetch a list of the email addresses that have been verified.
327
328        :rtype: dict
329        :returns: A ListVerifiedEmailAddressesResponse structure. Note that
330                  keys must be unicode strings.
331        """
332        return self._make_request('ListVerifiedEmailAddresses')
333
334    def get_send_quota(self):
335        """Fetches the user's current activity limits.
336
337        :rtype: dict
338        :returns: A GetSendQuotaResponse structure. Note that keys must be
339                  unicode strings.
340        """
341        return self._make_request('GetSendQuota')
342
343    def get_send_statistics(self):
344        """Fetches the user's sending statistics. The result is a list of data
345        points, representing the last two weeks of sending activity.
346
347        Each data point in the list contains statistics for a 15-minute
348        interval.
349
350        :rtype: dict
351        :returns: A GetSendStatisticsResponse structure. Note that keys must be
352                  unicode strings.
353        """
354        return self._make_request('GetSendStatistics')
355
356    def delete_verified_email_address(self, email_address):
357        """Deletes the specified email address from the list of verified
358        addresses.
359
360        :type email_adddress: string
361        :param email_address: The email address to be removed from the list of
362                              verified addreses.
363
364        :rtype: dict
365        :returns: A DeleteVerifiedEmailAddressResponse structure. Note that
366                  keys must be unicode strings.
367        """
368        return self._make_request('DeleteVerifiedEmailAddress', {
369            'EmailAddress': email_address,
370        })
371
372    def verify_email_address(self, email_address):
373        """Verifies an email address. This action causes a confirmation email
374        message to be sent to the specified address.
375
376        :type email_adddress: string
377        :param email_address: The email address to be verified.
378
379        :rtype: dict
380        :returns: A VerifyEmailAddressResponse structure. Note that keys must
381                  be unicode strings.
382        """
383        return self._make_request('VerifyEmailAddress', {
384            'EmailAddress': email_address,
385        })
386
387    def verify_domain_dkim(self, domain):
388        """
389        Returns a set of DNS records, or tokens, that must be published in the
390        domain name's DNS to complete the DKIM verification process. These
391        tokens are DNS ``CNAME`` records that point to DKIM public keys hosted
392        by Amazon SES. To complete the DKIM verification process, these tokens
393        must be published in the domain's DNS.  The tokens must remain
394        published in order for Easy DKIM signing to function correctly.
395
396        After the tokens are added to the domain's DNS, Amazon SES will be able
397        to DKIM-sign email originating from that domain.  To enable or disable
398        Easy DKIM signing for a domain, use the ``SetIdentityDkimEnabled``
399        action.  For more information about Easy DKIM, go to the `Amazon SES
400        Developer Guide
401        <http://docs.amazonwebservices.com/ses/latest/DeveloperGuide>`_.
402
403        :type domain: string
404        :param domain: The domain name.
405
406        """
407        return self._make_request('VerifyDomainDkim', {
408            'Domain': domain,
409        })
410
411    def set_identity_dkim_enabled(self, identity, dkim_enabled):
412        """Enables or disables DKIM signing of email sent from an identity.
413
414        * If Easy DKIM signing is enabled for a domain name identity (e.g.,
415        * ``example.com``),
416          then Amazon SES will DKIM-sign all email sent by addresses under that
417          domain name (e.g., ``user@example.com``)
418        * If Easy DKIM signing is enabled for an email address, then Amazon SES
419          will DKIM-sign all email sent by that email address.
420
421        For email addresses (e.g., ``user@example.com``), you can only enable
422        Easy DKIM signing  if the corresponding domain (e.g., ``example.com``)
423        has been set up for Easy DKIM using the AWS Console or the
424        ``VerifyDomainDkim`` action.
425
426        :type identity: string
427        :param identity: An email address or domain name.
428
429        :type dkim_enabled: bool
430        :param dkim_enabled: Specifies whether or not to enable DKIM signing.
431
432        """
433        return self._make_request('SetIdentityDkimEnabled', {
434            'Identity': identity,
435            'DkimEnabled': 'true' if dkim_enabled else 'false'
436        })
437
438    def get_identity_dkim_attributes(self, identities):
439        """Get attributes associated with a list of verified identities.
440
441        Given a list of verified identities (email addresses and/or domains),
442        returns a structure describing identity notification attributes.
443
444        :type identities: list
445        :param identities: A list of verified identities (email addresses
446            and/or domains).
447
448        """
449        params = {}
450        self._build_list_params(params, identities, 'Identities.member')
451        return self._make_request('GetIdentityDkimAttributes', params)
452
453    def list_identities(self):
454        """Returns a list containing all of the identities (email addresses
455        and domains) for a specific AWS Account, regardless of
456        verification status.
457
458        :rtype: dict
459        :returns: A ListIdentitiesResponse structure. Note that
460                  keys must be unicode strings.
461        """
462        return self._make_request('ListIdentities')
463
464    def get_identity_verification_attributes(self, identities):
465        """Given a list of identities (email addresses and/or domains),
466        returns the verification status and (for domain identities)
467        the verification token for each identity.
468
469        :type identities: list of strings or string
470        :param identities: List of identities.
471
472        :rtype: dict
473        :returns: A GetIdentityVerificationAttributesResponse structure.
474                  Note that keys must be unicode strings.
475        """
476        params = {}
477        self._build_list_params(params, identities,
478                                'Identities.member')
479        return self._make_request('GetIdentityVerificationAttributes', params)
480
481    def verify_domain_identity(self, domain):
482        """Verifies a domain.
483
484        :type domain: string
485        :param domain: The domain to be verified.
486
487        :rtype: dict
488        :returns: A VerifyDomainIdentityResponse structure. Note that
489                  keys must be unicode strings.
490        """
491        return self._make_request('VerifyDomainIdentity', {
492            'Domain': domain,
493        })
494
495    def verify_email_identity(self, email_address):
496        """Verifies an email address. This action causes a confirmation
497        email message to be sent to the specified address.
498
499        :type email_adddress: string
500        :param email_address: The email address to be verified.
501
502        :rtype: dict
503        :returns: A VerifyEmailIdentityResponse structure. Note that keys must
504                  be unicode strings.
505        """
506        return self._make_request('VerifyEmailIdentity', {
507            'EmailAddress': email_address,
508        })
509
510    def delete_identity(self, identity):
511        """Deletes the specified identity (email address or domain) from
512        the list of verified identities.
513
514        :type identity: string
515        :param identity: The identity to be deleted.
516
517        :rtype: dict
518        :returns: A DeleteIdentityResponse structure. Note that keys must
519                  be unicode strings.
520        """
521        return self._make_request('DeleteIdentity', {
522            'Identity': identity,
523        })
524
525    def set_identity_notification_topic(self, identity, notification_type, sns_topic=None):
526        """Sets an SNS topic to publish bounce or complaint notifications for
527        emails sent with the given identity as the Source. Publishing to topics
528        may only be disabled when feedback forwarding is enabled.
529
530        :type identity: string
531        :param identity: An email address or domain name.
532
533        :type notification_type: string
534        :param notification_type: The type of feedback notifications that will
535                                  be published to the specified topic.
536                                  Valid Values: Bounce | Complaint | Delivery
537
538        :type sns_topic: string or None
539        :param sns_topic: The Amazon Resource Name (ARN) of the Amazon Simple
540                          Notification Service (Amazon SNS) topic.
541        """
542        params = {
543            'Identity': identity,
544            'NotificationType': notification_type
545        }
546        if sns_topic:
547            params['SnsTopic'] = sns_topic
548        return self._make_request('SetIdentityNotificationTopic', params)
549
550    def set_identity_feedback_forwarding_enabled(self, identity, forwarding_enabled=True):
551        """
552        Enables or disables SES feedback notification via email.
553        Feedback forwarding may only be disabled when both complaint and
554        bounce topics are set.
555
556        :type identity: string
557        :param identity: An email address or domain name.
558
559        :type forwarding_enabled: bool
560        :param forwarding_enabled: Specifies whether or not to enable feedback forwarding.
561        """
562        return self._make_request('SetIdentityFeedbackForwardingEnabled', {
563            'Identity': identity,
564            'ForwardingEnabled': 'true' if forwarding_enabled else 'false'
565        })
566