mirror of
https://github.com/morpheus65535/bazarr.git
synced 2026-08-02 11:10:10 -04:00
1085 lines
37 KiB
Python
1085 lines
37 KiB
Python
# BSD 2-Clause License
|
|
#
|
|
# Apprise - Push Notification Library.
|
|
# Copyright (c) 2026, Chris Caron <lead2gold@gmail.com>
|
|
#
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# 1. Redistributions of source code must retain the above copyright notice,
|
|
# this list of conditions and the following disclaimer.
|
|
#
|
|
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
|
# this list of conditions and the following disclaimer in the documentation
|
|
# and/or other materials provided with the distribution.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
# POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
# API Information:
|
|
# - https://docs.aws.amazon.com/ses/latest/APIReference/API_SendRawEmail.html
|
|
#
|
|
# AWS Credentials (access_key and secret_access_key)
|
|
# - https://docs.aws.amazon.com/sdk-for-java/v1/developer-guide/\
|
|
# setup-credentials.html
|
|
# - https://docs.aws.amazon.com/toolkit-for-eclipse/v1/user-guide/\
|
|
# setup-credentials.html
|
|
#
|
|
# Other systems write these credentials to:
|
|
# - ~/.aws/credentials on Linux, macOS, or Unix
|
|
# - C:\Users\USERNAME\.aws\credentials on Windows
|
|
#
|
|
#
|
|
# To get A users access key ID and secret access key
|
|
#
|
|
# 1. Open the IAM console: https://console.aws.amazon.com/iam/home
|
|
# 2. On the navigation menu, choose Users.
|
|
# 3. Choose your IAM user name (not the check box).
|
|
# 4. Open the Security credentials tab, and then choose:
|
|
# Create Access key - Programmatic access
|
|
# 5. To see the new access key, choose Show. Your credentials resemble
|
|
# the following:
|
|
# Access key ID: AKIAIOSFODNN7EXAMPLE
|
|
# Secret access key: wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
|
|
#
|
|
# To download the key pair, choose Download .csv file. Store the keys
|
|
# The account requries this permssion to 'SES v2 : SendEmail' in order to
|
|
# work
|
|
#
|
|
# To get the root users account (if you're logged in as that) you can
|
|
# visit: https://console.aws.amazon.com/iam/home#/\
|
|
# security_credentials$access_key
|
|
#
|
|
# This information is vital to work with SES
|
|
|
|
|
|
# To use/test the service, i logged into the portal via:
|
|
# - https://portal.aws.amazon.com
|
|
#
|
|
# Go to the dashboard of the Amazon SES (Simple Email Service)
|
|
# 1. You must have a verified identity; click on that option and create one
|
|
# if you don't already have one. Until it's verified, you won't be able to
|
|
# do the next step.
|
|
# 2. From here you'll be able to retrieve your ARN associated with your
|
|
# identity you want Apprise to send emails on behalf. It might look
|
|
# something like:
|
|
# arn:aws:ses:us-east-2:133216123003:identity/user@example.com
|
|
#
|
|
# This is your ARN (Amazon Record Name)
|
|
#
|
|
#
|
|
|
|
import base64
|
|
from collections import OrderedDict
|
|
from datetime import datetime, timezone
|
|
from email.header import Header
|
|
from email.mime.application import MIMEApplication
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from email.utils import formataddr
|
|
from hashlib import sha256
|
|
import hmac
|
|
import re
|
|
from urllib.parse import quote
|
|
from xml.etree import ElementTree
|
|
|
|
import requests
|
|
|
|
from ..common import NotifyFormat, NotifyType
|
|
from ..locale import gettext_lazy as _
|
|
from ..url import PrivacyMode
|
|
from ..utils.parse import is_email, parse_emails, validate_regex
|
|
from .base import NotifyBase
|
|
|
|
# Our Regin Identifier
|
|
# support us-gov-west-1 syntax as well
|
|
IS_REGION = re.compile(
|
|
r"^\s*(?P<country>[a-z]{2})-(?P<area>[a-z-]+?)-(?P<no>[0-9]+)\s*$", re.I
|
|
)
|
|
|
|
# Extend HTTP Error Messages
|
|
AWS_HTTP_ERROR_MAP = {
|
|
403: "Unauthorized - Invalid Access/Secret Key Combination.",
|
|
}
|
|
|
|
|
|
class NotifySES(NotifyBase):
|
|
"""A wrapper for AWS SES (Amazon Simple Email Service)"""
|
|
|
|
# The default descriptive name associated with the Notification
|
|
service_name = "AWS Simple Email Service (SES)"
|
|
|
|
# The services URL
|
|
service_url = "https://aws.amazon.com/ses/"
|
|
|
|
# The default secure protocol
|
|
secure_protocol = "ses"
|
|
|
|
# A URL that takes you to the setup/help of the specific protocol
|
|
setup_url = "https://appriseit.com/services/ses/"
|
|
|
|
# Support attachments
|
|
attachment_support = True
|
|
|
|
# AWS is pretty good for handling data load so request limits
|
|
# can occur in much shorter bursts
|
|
request_rate_per_sec = 2.5
|
|
|
|
# Default Notify Format
|
|
notify_format = NotifyFormat.HTML
|
|
|
|
# Define object templates
|
|
templates = (
|
|
(
|
|
"{schema}://{from_email}/{access_key_id}/{secret_access_key}/"
|
|
"{region}/{targets}"
|
|
),
|
|
"{schema}://{from_email}/{access_key_id}/{secret_access_key}/{region}",
|
|
)
|
|
|
|
# Define our template tokens
|
|
template_tokens = dict(
|
|
NotifyBase.template_tokens,
|
|
**{
|
|
"token": {
|
|
# Session token for temporary/IAM credentials (optional).
|
|
# May be supplied in the URL password field
|
|
# (ses://user:{token}@host/...) or via ?token=.
|
|
"name": _("Session Token"),
|
|
"type": "string",
|
|
"private": True,
|
|
"map_to": "session_token",
|
|
},
|
|
"from_email": {
|
|
"name": _("From Email"),
|
|
"type": "string",
|
|
"map_to": "from_addr",
|
|
"required": True,
|
|
},
|
|
"access_key_id": {
|
|
"name": _("Access Key ID"),
|
|
"type": "string",
|
|
"private": True,
|
|
"required": True,
|
|
},
|
|
"secret_access_key": {
|
|
"name": _("Secret Access Key"),
|
|
"type": "string",
|
|
"private": True,
|
|
"required": True,
|
|
},
|
|
"region": {
|
|
"name": _("Region"),
|
|
"type": "string",
|
|
"regex": (r"^[a-z]{2}-[a-z-]+?-[0-9]+$", "i"),
|
|
"required": True,
|
|
"map_to": "region_name",
|
|
},
|
|
"targets": {
|
|
"name": _("Target Emails"),
|
|
"type": "list:string",
|
|
},
|
|
},
|
|
)
|
|
|
|
# Define our template arguments
|
|
template_args = dict(
|
|
NotifyBase.template_args,
|
|
**{
|
|
"to": {
|
|
"alias_of": "targets",
|
|
},
|
|
"from": {
|
|
"alias_of": "from_email",
|
|
},
|
|
"reply": {
|
|
"name": _("Reply To Email"),
|
|
"type": "string",
|
|
"map_to": "reply_to",
|
|
},
|
|
"name": {
|
|
"name": _("From Name"),
|
|
"type": "string",
|
|
"map_to": "from_name",
|
|
},
|
|
"token": {
|
|
# ?token= kwarg mirrors the token template token
|
|
"alias_of": "token",
|
|
},
|
|
"access": {
|
|
"alias_of": "access_key_id",
|
|
},
|
|
"key": {
|
|
# Intuitive alias for access_key_id
|
|
"alias_of": "access_key_id",
|
|
},
|
|
"secret": {
|
|
"alias_of": "secret_access_key",
|
|
},
|
|
"region": {
|
|
"alias_of": "region",
|
|
},
|
|
"cc": {
|
|
"name": _("Carbon Copy"),
|
|
"type": "list:string",
|
|
},
|
|
"bcc": {
|
|
"name": _("Blind Carbon Copy"),
|
|
"type": "list:string",
|
|
},
|
|
},
|
|
)
|
|
|
|
def __init__(
|
|
self,
|
|
access_key_id,
|
|
secret_access_key,
|
|
region_name,
|
|
reply_to=None,
|
|
from_addr=None,
|
|
from_name=None,
|
|
targets=None,
|
|
cc=None,
|
|
bcc=None,
|
|
session_token=None,
|
|
**kwargs,
|
|
):
|
|
"""Initialize Notify AWS SES Object."""
|
|
super().__init__(**kwargs)
|
|
|
|
# Store optional session token for temporary/IAM credentials
|
|
self.aws_session_token = session_token if session_token else None
|
|
|
|
# Store our AWS API Access Key
|
|
self.aws_access_key_id = validate_regex(access_key_id)
|
|
if not self.aws_access_key_id:
|
|
msg = "An invalid AWS Access Key ID was specified."
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
|
|
# Store our AWS API Secret Access key
|
|
self.aws_secret_access_key = validate_regex(secret_access_key)
|
|
if not self.aws_secret_access_key:
|
|
msg = (
|
|
"An invalid AWS Secret Access Key "
|
|
f"({secret_access_key}) was specified."
|
|
)
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
|
|
# Acquire our AWS Region Name:
|
|
# eg. us-east-1, cn-north-1, us-west-2, ...
|
|
self.aws_region_name = validate_regex(
|
|
region_name, *self.template_tokens["region"]["regex"]
|
|
)
|
|
if not self.aws_region_name:
|
|
msg = f"An invalid AWS Region ({region_name}) was specified."
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
|
|
# Acquire Email 'To'
|
|
self.targets = []
|
|
|
|
# Acquire Carbon Copies
|
|
self.cc = set()
|
|
|
|
# Acquire Blind Carbon Copies
|
|
self.bcc = set()
|
|
|
|
# For tracking our email -> name lookups
|
|
self.names = {}
|
|
|
|
# Set our notify_url based on our region
|
|
self.notify_url = f"https://email.{self.aws_region_name}.amazonaws.com"
|
|
|
|
# AWS Service Details
|
|
self.aws_service_name = "ses"
|
|
self.aws_canonical_uri = "/"
|
|
|
|
# AWS Authentication Details
|
|
self.aws_auth_version = "AWS4"
|
|
self.aws_auth_algorithm = "AWS4-HMAC-SHA256"
|
|
self.aws_auth_request = "aws4_request"
|
|
|
|
# Get our From username (if specified)
|
|
self.from_name = from_name
|
|
|
|
if from_addr:
|
|
self.from_addr = from_addr
|
|
|
|
else:
|
|
# Get our from email address
|
|
self.from_addr = f"{self.user}@{self.host}" if self.user else None
|
|
|
|
if not (self.from_addr and is_email(self.from_addr)):
|
|
msg = "An invalid AWS From ({}) was specified.".format(
|
|
f"{self.user}@{self.host}"
|
|
)
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
|
|
self.reply_to = None
|
|
if reply_to:
|
|
result = is_email(reply_to)
|
|
if not result:
|
|
msg = "An invalid AWS Reply To ({}) was specified.".format(
|
|
f"{reply_to}"
|
|
)
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
|
|
self.reply_to = (
|
|
result["name"] if result["name"] else False,
|
|
result["full_email"],
|
|
)
|
|
|
|
if targets:
|
|
# Validate recipients (to:) and drop bad ones:
|
|
for recipient in parse_emails(targets):
|
|
result = is_email(recipient)
|
|
if result:
|
|
self.targets.append(
|
|
(
|
|
result["name"] if result["name"] else False,
|
|
result["full_email"],
|
|
)
|
|
)
|
|
continue
|
|
|
|
self.logger.warning(
|
|
f"Dropped invalid To email ({recipient}) specified.",
|
|
)
|
|
|
|
else:
|
|
# If our target email list is empty we want to add ourselves to it
|
|
self.targets.append(
|
|
(self.from_name if self.from_name else False, self.from_addr)
|
|
)
|
|
|
|
# Validate recipients (cc:) and drop bad ones:
|
|
for recipient in parse_emails(cc):
|
|
email = is_email(recipient)
|
|
if email:
|
|
self.cc.add(email["full_email"])
|
|
|
|
# Index our name (if one exists)
|
|
self.names[email["full_email"]] = (
|
|
email["name"] if email["name"] else False
|
|
)
|
|
continue
|
|
|
|
self.logger.warning(
|
|
f"Dropped invalid Carbon Copy email ({recipient}) specified.",
|
|
)
|
|
|
|
# Validate recipients (bcc:) and drop bad ones:
|
|
for recipient in parse_emails(bcc):
|
|
email = is_email(recipient)
|
|
if email:
|
|
self.bcc.add(email["full_email"])
|
|
|
|
# Index our name (if one exists)
|
|
self.names[email["full_email"]] = (
|
|
email["name"] if email["name"] else False
|
|
)
|
|
continue
|
|
|
|
self.logger.warning(
|
|
"Dropped invalid Blind Carbon Copy email "
|
|
f"({recipient}) specified.",
|
|
)
|
|
|
|
return
|
|
|
|
def send(
|
|
self,
|
|
body,
|
|
title="",
|
|
notify_type=NotifyType.INFO,
|
|
attach=None,
|
|
**kwargs,
|
|
):
|
|
"""Wrapper to send_notification since we can alert more then one
|
|
channel."""
|
|
|
|
if not self.targets:
|
|
# There is no one to email; we're done
|
|
self.logger.warning("There are no SES email recipients to notify")
|
|
return False
|
|
|
|
# error tracking (used for function return)
|
|
has_error = False
|
|
|
|
# Initialize our default from name
|
|
from_name = (
|
|
self.from_name
|
|
if self.from_name
|
|
else (
|
|
self.reply_to[0]
|
|
if self.reply_to and self.reply_to[0]
|
|
else self.app_desc
|
|
)
|
|
)
|
|
|
|
reply_to = (
|
|
from_name,
|
|
self.from_addr if not self.reply_to else self.reply_to[1],
|
|
)
|
|
|
|
# Create a copy of the targets list
|
|
emails = list(self.targets)
|
|
while len(emails):
|
|
# Get our email to notify
|
|
to_name, to_addr = emails.pop(0)
|
|
|
|
# Strip target out of cc list if in To or Bcc
|
|
cc = self.cc - self.bcc - {to_addr}
|
|
|
|
# Strip target out of bcc list if in To
|
|
bcc = self.bcc - {to_addr}
|
|
|
|
# Format our cc addresses to support the Name field
|
|
cc = [
|
|
formataddr(
|
|
(self.names.get(addr, False), addr), charset="utf-8"
|
|
)
|
|
for addr in cc
|
|
]
|
|
|
|
# Format our bcc addresses to support the Name field
|
|
bcc = [
|
|
formataddr(
|
|
(self.names.get(addr, False), addr), charset="utf-8"
|
|
)
|
|
for addr in bcc
|
|
]
|
|
|
|
self.logger.debug(
|
|
"Email From: {} <{}>".format(
|
|
quote(reply_to[0], " "), quote(reply_to[1], "@ ")
|
|
)
|
|
)
|
|
|
|
self.logger.debug(f"Email To: {to_addr}")
|
|
if cc:
|
|
self.logger.debug("Email Cc: {}".format(", ".join(cc)))
|
|
if bcc:
|
|
self.logger.debug("Email Bcc: {}".format(", ".join(bcc)))
|
|
|
|
# Prepare Email Message
|
|
if self.notify_format == NotifyFormat.HTML:
|
|
content = MIMEText(body, "html", "utf-8")
|
|
|
|
else:
|
|
content = MIMEText(body, "plain", "utf-8")
|
|
|
|
# Create a Multipart container if there is an attachment
|
|
base = (
|
|
MIMEMultipart()
|
|
if attach and self.attachment_support
|
|
else content
|
|
)
|
|
|
|
# TODO: Deduplicate with `NotifyEmail`?
|
|
base["Subject"] = Header(title, "utf-8")
|
|
base["From"] = formataddr(
|
|
(from_name if from_name else False, self.from_addr),
|
|
charset="utf-8",
|
|
)
|
|
base["To"] = formataddr((to_name, to_addr), charset="utf-8")
|
|
if reply_to[1] != self.from_addr:
|
|
base["Reply-To"] = formataddr(reply_to, charset="utf-8")
|
|
base["Cc"] = ",".join(cc)
|
|
base["Date"] = datetime.now(timezone.utc).strftime(
|
|
"%a, %d %b %Y %H:%M:%S +0000"
|
|
)
|
|
base["X-Application"] = self.app_id
|
|
|
|
if attach and self.attachment_support:
|
|
# First attach our body to our content as the first element
|
|
base.attach(content)
|
|
|
|
# Now store our attachments
|
|
for no, attachment in enumerate(attach, start=1):
|
|
if not attachment:
|
|
# We could not load the attachment; take an early
|
|
# exit since this isn't what the end user wanted
|
|
|
|
# We could not access the attachment
|
|
self.logger.error(
|
|
"Could not access attachment"
|
|
f" {attachment.url(privacy=True)}."
|
|
)
|
|
|
|
return False
|
|
|
|
self.logger.debug(
|
|
"Preparing Email attachment"
|
|
f" {attachment.url(privacy=True)}"
|
|
)
|
|
|
|
with open(attachment.path, "rb") as abody:
|
|
app = MIMEApplication(abody.read())
|
|
app.set_type(attachment.mimetype)
|
|
|
|
filename = (
|
|
attachment.name
|
|
if attachment.name
|
|
else f"file{no:03}.dat"
|
|
)
|
|
|
|
app.add_header(
|
|
"Content-Disposition",
|
|
'attachment; filename="{}"'.format(
|
|
Header(filename, "utf-8")
|
|
),
|
|
)
|
|
|
|
base.attach(app)
|
|
|
|
# Prepare our payload object
|
|
payload = {
|
|
"Action": "SendRawEmail",
|
|
"Version": "2010-12-01",
|
|
"RawMessage.Data": (
|
|
base64.b64encode(base.as_string().encode("utf-8")).decode(
|
|
"utf-8"
|
|
)
|
|
),
|
|
}
|
|
|
|
for no, email in enumerate(([to_addr, *bcc, *cc]), start=1):
|
|
payload[f"Destinations.member.{no}"] = email
|
|
|
|
# Specify from address
|
|
payload["Source"] = "{} <{}>".format(
|
|
quote(from_name, " "), quote(self.from_addr, "@ ")
|
|
)
|
|
|
|
(result, _response) = self._post(payload=payload, to=to_addr)
|
|
if not result:
|
|
# Mark our failure
|
|
has_error = True
|
|
continue
|
|
|
|
return not has_error
|
|
|
|
def _post(self, payload, to):
|
|
"""Wrapper to request.post() to manage it's response better and make
|
|
the send() function cleaner and easier to maintain.
|
|
|
|
This function returns True if the _post was successful and False if it
|
|
wasn't.
|
|
"""
|
|
|
|
# Always call throttle before any remote server i/o is made; for AWS
|
|
# time plays a huge factor in the headers being sent with the payload.
|
|
# So for AWS (SES) requests we must throttle before they're generated
|
|
# and not directly before the i/o call like other notification
|
|
# services do.
|
|
self.throttle()
|
|
|
|
# Convert our payload from a dict() into a urlencoded string
|
|
payload = NotifySES.urlencode(payload)
|
|
|
|
# Prepare our Notification URL
|
|
# Prepare our AWS Headers based on our payload
|
|
headers = self.aws_prepare_request(payload)
|
|
|
|
self.logger.debug(
|
|
"AWS SES POST URL:"
|
|
f" {self.notify_url} (cert_verify={self.verify_certificate!r})"
|
|
)
|
|
self.logger.debug("AWS SES Payload (%d bytes)", len(payload))
|
|
|
|
try:
|
|
r = requests.post(
|
|
self.notify_url,
|
|
data=payload,
|
|
headers=headers,
|
|
verify=self.verify_certificate,
|
|
timeout=self.request_timeout,
|
|
allow_redirects=self.redirects,
|
|
)
|
|
|
|
if r.status_code != requests.codes.ok:
|
|
# We had a problem
|
|
status_str = NotifySES.http_response_code_lookup(
|
|
r.status_code, AWS_HTTP_ERROR_MAP
|
|
)
|
|
|
|
self.logger.warning(
|
|
"Failed to send AWS SES notification to {}: "
|
|
"{}{}error={}.".format(
|
|
to,
|
|
status_str,
|
|
", " if status_str else "",
|
|
r.status_code,
|
|
)
|
|
)
|
|
|
|
self.logger.debug(
|
|
"Response Details:\r\n%r", (r.content or b"")[:2000]
|
|
)
|
|
|
|
return (False, NotifySES.aws_response_to_dict(r.text))
|
|
|
|
else:
|
|
self.logger.info(f'Sent AWS SES notification to "{to}".')
|
|
|
|
except requests.RequestException as e:
|
|
self.logger.warning(
|
|
"A Connection error occurred sending AWS SES "
|
|
f'notification to "{to}".',
|
|
)
|
|
self.logger.debug(f"Socket Exception: {e!s}")
|
|
return (False, NotifySES.aws_response_to_dict(None))
|
|
|
|
return (True, NotifySES.aws_response_to_dict(r.text))
|
|
|
|
def aws_prepare_request(self, payload, reference=None):
|
|
"""Takes the intended payload and returns the headers for it.
|
|
|
|
The payload is presumed to have been already urlencoded()
|
|
"""
|
|
|
|
# Define our AWS SES header
|
|
headers = {
|
|
"User-Agent": self.app_id,
|
|
"Content-Type": "application/x-www-form-urlencoded; charset=utf-8",
|
|
# Populated below
|
|
"Content-Length": 0,
|
|
"Authorization": None,
|
|
"X-Amz-Date": None,
|
|
}
|
|
|
|
# Get a reference time (used for header construction)
|
|
reference = datetime.now(timezone.utc)
|
|
|
|
# Provide Content-Length
|
|
headers["Content-Length"] = str(len(payload))
|
|
|
|
# Amazon Date Format
|
|
amzdate = reference.strftime("%Y%m%dT%H%M%SZ")
|
|
headers["X-Amz-Date"] = amzdate
|
|
|
|
# Credential Scope
|
|
scope = "{date}/{region}/{service}/{request}".format(
|
|
date=reference.strftime("%Y%m%d"),
|
|
region=self.aws_region_name,
|
|
service=self.aws_service_name,
|
|
request=self.aws_auth_request,
|
|
)
|
|
|
|
# Similar to headers; but a subset. keys must be lowercase
|
|
signed_headers = OrderedDict(
|
|
[
|
|
("content-type", headers["Content-Type"]),
|
|
("host", f"email.{self.aws_region_name}.amazonaws.com"),
|
|
("x-amz-date", headers["X-Amz-Date"]),
|
|
]
|
|
)
|
|
|
|
# Include session token in signed headers for temporary credentials;
|
|
# x-amz-security-token sorts after x-amz-date alphabetically and
|
|
# must appear after it to keep the canonical request valid.
|
|
if self.aws_session_token:
|
|
headers["X-Amz-Security-Token"] = self.aws_session_token
|
|
signed_headers["x-amz-security-token"] = self.aws_session_token
|
|
|
|
#
|
|
# Build Canonical Request Object
|
|
#
|
|
canonical_request = "\n".join(
|
|
[
|
|
# Method
|
|
"POST",
|
|
# URL
|
|
self.aws_canonical_uri,
|
|
# Query String (none set for POST)
|
|
"",
|
|
# Header Content (must include \n at end!)
|
|
# All entries except characters in amazon date must be
|
|
# lowercase
|
|
"\n".join([f"{k}:{v}" for k, v in signed_headers.items()])
|
|
+ "\n",
|
|
# Header Entries (in same order identified above)
|
|
";".join(signed_headers.keys()),
|
|
# Payload
|
|
sha256(payload.encode("utf-8")).hexdigest(),
|
|
]
|
|
)
|
|
|
|
# Prepare Unsigned Signature
|
|
to_sign = "\n".join(
|
|
[
|
|
self.aws_auth_algorithm,
|
|
amzdate,
|
|
scope,
|
|
sha256(canonical_request.encode("utf-8")).hexdigest(),
|
|
]
|
|
)
|
|
|
|
# Our Authorization header
|
|
headers["Authorization"] = ", ".join(
|
|
[
|
|
(
|
|
f"{self.aws_auth_algorithm} "
|
|
f"Credential={self.aws_access_key_id}/{scope}"
|
|
),
|
|
"SignedHeaders={signed_headers}".format(
|
|
signed_headers=";".join(signed_headers.keys()),
|
|
),
|
|
f"Signature={self.aws_auth_signature(to_sign, reference)}",
|
|
]
|
|
)
|
|
|
|
return headers
|
|
|
|
def aws_auth_signature(self, to_sign, reference):
|
|
"""Generates a AWS v4 signature based on provided payload which should
|
|
be in the form of a string."""
|
|
|
|
def _sign(key, msg, to_hex=False):
|
|
"""Perform AWS Signing."""
|
|
if to_hex:
|
|
return hmac.new(key, msg.encode("utf-8"), sha256).hexdigest()
|
|
return hmac.new(key, msg.encode("utf-8"), sha256).digest()
|
|
|
|
date = _sign(
|
|
(self.aws_auth_version + self.aws_secret_access_key).encode(
|
|
"utf-8"
|
|
),
|
|
reference.strftime("%Y%m%d"),
|
|
)
|
|
|
|
region = _sign(date, self.aws_region_name)
|
|
service = _sign(region, self.aws_service_name)
|
|
signed = _sign(service, self.aws_auth_request)
|
|
return _sign(signed, to_sign, to_hex=True)
|
|
|
|
@staticmethod
|
|
def aws_response_to_dict(aws_response):
|
|
"""Takes an AWS Response object as input and returns it as a dictionary
|
|
but not befor extracting out what is useful to us first.
|
|
|
|
eg:
|
|
IN:
|
|
|
|
<SendRawEmailResponse
|
|
xmlns="http://ses.amazonaws.com/doc/2010-12-01/">
|
|
<SendRawEmailResult>
|
|
<MessageId>
|
|
010f017d87656ee2-a2ea291f-79ea-
|
|
44f3-9d25-00d041de3007-000000</MessageId>
|
|
</SendRawEmailResult>
|
|
<ResponseMetadata>
|
|
<RequestId>7abb454e-904b-4e46-a23c-2f4d2fc127a6</RequestId>
|
|
</ResponseMetadata>
|
|
</SendRawEmailResponse>
|
|
|
|
OUT:
|
|
{
|
|
'type': 'SendRawEmailResponse',
|
|
'message_id': '010f017d87656ee2-a2ea291f-79ea-
|
|
44f3-9d25-00d041de3007-000000',
|
|
'request_id': '7abb454e-904b-4e46-a23c-2f4d2fc127a6',
|
|
}
|
|
"""
|
|
|
|
# Define ourselves a set of directives we want to keep if found and
|
|
# then identify the value we want to map them to in our response
|
|
# object
|
|
aws_keep_map = {
|
|
"RequestId": "request_id",
|
|
"MessageId": "message_id",
|
|
# Error Message Handling
|
|
"Type": "error_type",
|
|
"Code": "error_code",
|
|
"Message": "error_message",
|
|
}
|
|
|
|
# A default response object that we'll manipulate as we pull more data
|
|
# from our AWS Response object
|
|
response = {
|
|
"type": None,
|
|
"request_id": None,
|
|
"message_id": None,
|
|
}
|
|
|
|
try:
|
|
# we build our tree, but not before first eliminating any
|
|
# reference to namespacing (if present) as it makes parsing
|
|
# the tree so much easier.
|
|
root = ElementTree.fromstring(
|
|
re.sub(r' xmlns="[^"]+"', "", aws_response, count=1)
|
|
)
|
|
|
|
# Store our response tag object name
|
|
response["type"] = str(root.tag)
|
|
|
|
def _xml_iter(root, response):
|
|
if len(root) > 0:
|
|
for child in root:
|
|
# use recursion to parse everything
|
|
_xml_iter(child, response)
|
|
|
|
elif root.tag in aws_keep_map:
|
|
response[aws_keep_map[root.tag]] = (root.text).strip()
|
|
|
|
# Recursivly iterate over our AWS Response to extract the
|
|
# fields we're interested in in efforts to populate our response
|
|
# object.
|
|
_xml_iter(root, response)
|
|
|
|
except (ElementTree.ParseError, TypeError):
|
|
# bad data just causes us to generate a bad response
|
|
pass
|
|
|
|
return response
|
|
|
|
@property
|
|
def url_identifier(self):
|
|
"""Returns all of the identifiers that make this URL unique from
|
|
another simliar one.
|
|
|
|
Targets or end points should never be identified here.
|
|
"""
|
|
return (
|
|
self.secure_protocol,
|
|
self.from_addr,
|
|
self.aws_access_key_id,
|
|
self.aws_secret_access_key,
|
|
self.aws_region_name,
|
|
)
|
|
|
|
def url(self, privacy=False, *args, **kwargs):
|
|
"""Returns the URL built dynamically based on specified arguments."""
|
|
|
|
# Acquire any global URL parameters
|
|
params = self.url_parameters(privacy=privacy, *args, **kwargs)
|
|
|
|
if self.from_name is not None:
|
|
# from_name specified; pass it back on the url
|
|
params["name"] = self.from_name
|
|
|
|
if self.cc:
|
|
# Handle our Carbon Copy Addresses
|
|
params["cc"] = ",".join(
|
|
[
|
|
"{}{}".format(
|
|
"" if not e not in self.names else f"{self.names[e]}:",
|
|
e,
|
|
)
|
|
for e in self.cc
|
|
]
|
|
)
|
|
|
|
if self.bcc:
|
|
# Handle our Blind Carbon Copy Addresses
|
|
params["bcc"] = ",".join(self.bcc)
|
|
|
|
if self.reply_to:
|
|
# Handle our reply to address
|
|
params["reply"] = (
|
|
"{} <{}>".format(*self.reply_to)
|
|
if self.reply_to[0]
|
|
else self.reply_to[1]
|
|
)
|
|
|
|
# a simple boolean check as to whether we display our target emails
|
|
# or not
|
|
has_targets = not (
|
|
len(self.targets) == 1 and self.targets[0][1] == self.from_addr
|
|
)
|
|
|
|
# Build the from-address URL segment; include the session token in
|
|
# the password position when present:
|
|
# ses://sender:{token}@example.com/...
|
|
if self.aws_session_token:
|
|
# Split from_addr into local and domain parts;
|
|
# from_addr always contains @ (enforced by __init__ validation)
|
|
fa_local, fa_domain = self.from_addr.split("@", 1)
|
|
from_url_part = "{}:{}@{}".format(
|
|
NotifySES.quote(fa_local, safe=""),
|
|
self.pprint(self.aws_session_token, privacy, safe="+=")
|
|
if privacy
|
|
else NotifySES.quote(self.aws_session_token, safe="+="),
|
|
NotifySES.quote(fa_domain, safe=""),
|
|
)
|
|
else:
|
|
from_url_part = NotifySES.quote(self.from_addr, safe="@")
|
|
|
|
return (
|
|
"{schema}://{from_addr}/{key_id}/{key_secret}/{region}/"
|
|
"{targets}/?{params}".format(
|
|
schema=self.secure_protocol,
|
|
from_addr=from_url_part,
|
|
key_id=self.pprint(self.aws_access_key_id, privacy, safe=""),
|
|
key_secret=self.pprint(
|
|
self.aws_secret_access_key,
|
|
privacy,
|
|
mode=PrivacyMode.Secret,
|
|
safe="",
|
|
),
|
|
region=NotifySES.quote(self.aws_region_name, safe=""),
|
|
targets=(
|
|
""
|
|
if not has_targets
|
|
else "/".join(
|
|
[
|
|
NotifySES.quote(
|
|
"{}{}".format(
|
|
"" if not e[0] else f"{e[0]}:", e[1]
|
|
),
|
|
safe="",
|
|
)
|
|
for e in self.targets
|
|
]
|
|
)
|
|
),
|
|
params=NotifySES.urlencode(params),
|
|
)
|
|
)
|
|
|
|
def __len__(self):
|
|
"""Returns the number of targets associated with this notification."""
|
|
targets = len(self.targets)
|
|
return targets if targets > 0 else 1
|
|
|
|
@staticmethod
|
|
def parse_url(url):
|
|
"""Parses the URL and returns enough arguments that can allow us to re-
|
|
instantiate this object."""
|
|
results = NotifyBase.parse_url(url, verify_host=False)
|
|
if not results:
|
|
# We're done early as we couldn't load the results
|
|
return results
|
|
|
|
# Get our entries; split_path() looks after unquoting content for us
|
|
# by default
|
|
entries = NotifySES.split_path(results["fullpath"])
|
|
|
|
# The AWS Access Key ID is stored in the first entry
|
|
access_key_id = entries.pop(0) if entries else None
|
|
|
|
# Our AWS Access Key Secret contains slashes in it which unfortunately
|
|
# means it is of variable length after the hostname. Since we require
|
|
# that the user provides the region code, we intentionally use this
|
|
# as our delimiter to detect where our Secret is.
|
|
secret_access_key = None
|
|
region_name = None
|
|
|
|
# We need to iterate over each entry in the fullpath and find our
|
|
# region. Once we get there we stop and build our secret from our
|
|
# accumulated data.
|
|
secret_access_key_parts = []
|
|
|
|
# Section 1: Get Region and Access Secret
|
|
index = 0
|
|
for index, entry in enumerate(entries, start=1):
|
|
# Are we at the region yet?
|
|
result = IS_REGION.match(entry)
|
|
if result:
|
|
# Ensure region is nicely formatted
|
|
region_name = "{country}-{area}-{no}".format(
|
|
country=result.group("country").lower(),
|
|
area=result.group("area").lower(),
|
|
no=result.group("no"),
|
|
)
|
|
|
|
# We're done with Section 1 of our url (the credentials)
|
|
break
|
|
|
|
elif is_email(entry):
|
|
# We're done with Section 1 of our url (the credentials)
|
|
index -= 1
|
|
break
|
|
|
|
# Store our secret parts
|
|
secret_access_key_parts.append(entry)
|
|
|
|
# Prepare our Secret Access Key
|
|
secret_access_key = (
|
|
"/".join(secret_access_key_parts)
|
|
if secret_access_key_parts
|
|
else None
|
|
)
|
|
|
|
# Section 2: Get our Recipients (basically all remaining entries)
|
|
results["targets"] = entries[index:]
|
|
|
|
if "name" in results["qsd"] and len(results["qsd"]["name"]):
|
|
# Extract from name to associate with from address
|
|
results["from_name"] = NotifySES.unquote(results["qsd"]["name"])
|
|
|
|
# Handle 'to' email address
|
|
if "to" in results["qsd"] and len(results["qsd"]["to"]):
|
|
results["targets"].append(results["qsd"]["to"])
|
|
|
|
# Handle Carbon Copy Addresses
|
|
if "cc" in results["qsd"] and len(results["qsd"]["cc"]):
|
|
results["cc"] = NotifySES.parse_list(results["qsd"]["cc"])
|
|
|
|
# Handle Blind Carbon Copy Addresses
|
|
if "bcc" in results["qsd"] and len(results["qsd"]["bcc"]):
|
|
results["bcc"] = NotifySES.parse_list(results["qsd"]["bcc"])
|
|
|
|
# Handle From Address handling
|
|
if "from" in results["qsd"] and len(results["qsd"]["from"]):
|
|
results["from_addr"] = NotifySES.unquote(results["qsd"]["from"])
|
|
|
|
# Handle Reply To Address
|
|
if "reply" in results["qsd"] and len(results["qsd"]["reply"]):
|
|
results["reply_to"] = NotifySES.unquote(results["qsd"]["reply"])
|
|
|
|
# Handle secret_access_key over-ride
|
|
if "secret" in results["qsd"] and len(results["qsd"]["secret"]):
|
|
results["secret_access_key"] = NotifySES.unquote(
|
|
results["qsd"]["secret"]
|
|
)
|
|
else:
|
|
results["secret_access_key"] = secret_access_key
|
|
|
|
# Handle access key id override; ?key= is the preferred alias,
|
|
# ?access= is retained for backwards compatibility
|
|
if "key" in results["qsd"] and len(results["qsd"]["key"]):
|
|
results["access_key_id"] = NotifySES.unquote(results["qsd"]["key"])
|
|
elif "access" in results["qsd"] and len(results["qsd"]["access"]):
|
|
results["access_key_id"] = NotifySES.unquote(
|
|
results["qsd"]["access"]
|
|
)
|
|
else:
|
|
results["access_key_id"] = access_key_id
|
|
|
|
# Handle region name id over-ride
|
|
if "region" in results["qsd"] and len(results["qsd"]["region"]):
|
|
results["region_name"] = NotifySES.unquote(
|
|
results["qsd"]["region"]
|
|
)
|
|
else:
|
|
results["region_name"] = region_name
|
|
|
|
# Session token may come from the URL password field or the
|
|
# ?token= query parameter; ?token= takes priority when both
|
|
# are present.
|
|
results["session_token"] = None
|
|
if results.get("password"):
|
|
results["session_token"] = NotifySES.unquote(results["password"])
|
|
if "token" in results["qsd"] and len(results["qsd"]["token"]):
|
|
results["session_token"] = NotifySES.unquote(
|
|
results["qsd"]["token"]
|
|
)
|
|
|
|
# Return our result set
|
|
return results
|