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