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