diff --git a/README.md b/README.md
index 63f0c50a..a2e2d8f5 100644
--- a/README.md
+++ b/README.md
@@ -31,6 +31,7 @@ The table below identifies the services this tool supports and some example serv
| Notification Service | Service ID | Default Port | Example Syntax |
| -------------------- | ---------- | ------------ | -------------- |
+| [AWS SNS](https://github.com/caronc/apprise/wiki/Notify_sns) | sns:// | (TCP) 443 | sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo
sns://AccessKeyID/AccessSecretKey/RegionName/+PhoneNo1/+PhoneNo2/+PhoneNoN
sns://AccessKeyID/AccessSecretKey/RegionName/Topic
sns://AccessKeyID/AccessSecretKey/RegionName/Topic1/Topic2/TopicN
| [Boxcar](https://github.com/caronc/apprise/wiki/Notify_boxcar) | boxcar:// | (TCP) 443 | boxcar://hostname
boxcar://hostname/@tag
boxcar://hostname/device_token
boxcar://hostname/device_token1/device_token2/device_tokenN
boxcar://hostname/@tag/@tag2/device_token
| [Discord](https://github.com/caronc/apprise/wiki/Notify_discord) | discord:// | (TCP) 443 | discord://webhook_id/webhook_token
discord://avatar@webhook_id/webhook_token
| [Dbus](https://github.com/caronc/apprise/wiki/Notify_dbus) | dbus://
qt://
glib://
kde:// | n/a | dbus://
qt://
glib://
kde://
diff --git a/apprise/plugins/NotifySNS.py b/apprise/plugins/NotifySNS.py
new file mode 100644
index 00000000..1cc7d859
--- /dev/null
+++ b/apprise/plugins/NotifySNS.py
@@ -0,0 +1,333 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import re
+
+# Phase 1; use boto3 for a proof of concept
+import boto3
+from botocore.exceptions import ClientError
+from botocore.exceptions import EndpointConnectionError
+
+from .NotifyBase import NotifyBase
+from ..utils import compat_is_basestring
+
+# Some Phone Number Detection
+IS_PHONE_NO = re.compile(r'^\+?(?P[0-9\s)(+-]+)\s*$')
+
+# Topic Detection
+# Summary: 256 Characters max, only alpha/numeric plus underscore (_) and
+# dash (-) additionally allowed.
+#
+# Soure: https://docs.aws.amazon.com/AWSSimpleQueueService/latest\
+# /SQSDeveloperGuide/sqs-limits.html#limits-queues
+#
+# Allow a starting hashtag (#) specification to help eliminate possible
+# ambiguity between a topic that is comprised of all digits and a phone number
+IS_TOPIC = re.compile(r'^#?(?P[A-Za-z0-9_-]+)\s*$')
+
+# Used to break apart list of potential tags by their delimiter
+# into a usable list.
+LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+')
+
+# Because our AWS Access Key Secret contains slashes, we actually use the
+# region as a delimiter. This is a bit hacky; but it's much easier than having
+# users of this product search though this Access Key Secret and escape all
+# of the forward slashes!
+IS_REGION = re.compile(
+ r'^\s*(?P[a-z]{2})-(?P[a-z]+)-(?P[0-9]+)\s*$', re.I)
+
+
+class NotifySNS(NotifyBase):
+ """
+ A wrapper for AWS SNS (Amazon Simple Notification)
+ """
+
+ # The default descriptive name associated with the Notification
+ service_name = 'AWS Simple Notification Service (SNS)'
+
+ # The services URL
+ service_url = 'https://aws.amazon.com/sns/'
+
+ # The default secure protocol
+ secure_protocol = 'sns'
+
+ # A URL that takes you to the setup/help of the specific protocol
+ setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sns'
+
+ # The maximum length of the body
+ body_maxlen = 256
+
+ def __init__(self, access_key_id, secret_access_key, region_name,
+ recipients=None, **kwargs):
+ """
+ Initialize Notify AWS SNS Object
+ """
+ super(NotifySNS, self).__init__(**kwargs)
+
+ # Initialize topic list
+ self.topics = list()
+
+ # Initialize numbers list
+ self.phone = list()
+
+ # Store our AWS API Key
+ self.access_key_id = access_key_id
+
+ # Store our AWS API Secret Access key
+ self.secret_access_key = secret_access_key
+
+ # Acquire our AWS Region Name:
+ # eg. us-east-1, cn-north-1, us-west-2, ...
+ self.region_name = region_name
+
+ if not access_key_id:
+ raise TypeError(
+ 'An invalid AWS Access Key ID was specified.'
+ )
+
+ if not secret_access_key:
+ raise TypeError(
+ 'An invalid AWS Secret Access Key was specified.'
+ )
+
+ if not (region_name and IS_REGION.match(region_name)):
+ raise TypeError(
+ 'An invalid AWS Region was specified.'
+ )
+
+ if recipients is None:
+ recipients = []
+
+ elif compat_is_basestring(recipients):
+ recipients = [x for x in filter(bool, LIST_DELIM.split(
+ recipients,
+ ))]
+
+ elif not isinstance(recipients, (set, tuple, list)):
+ recipients = []
+
+ # Validate recipients and drop bad ones:
+ for recipient in recipients:
+ result = IS_PHONE_NO.match(recipient)
+ if result:
+ # Further check our phone # for it's digit count
+ # if it's less than 10, then we can assume it's
+ # a poorly specified phone no and spit a warning
+ result = ''.join(re.findall(r'\d+', result.group('phone')))
+ if len(result) < 11 or len(result) > 14:
+ self.logger.warning(
+ 'Dropped invalid phone # '
+ '(%s) specified.' % recipient,
+ )
+ continue
+
+ # store valid phone number
+ self.phone.append('+{}'.format(result))
+ continue
+
+ result = IS_TOPIC.match(recipient)
+ if result:
+ # store valid topic
+ self.topics.append(result.group('name'))
+ continue
+
+ self.logger.warning(
+ 'Dropped invalid phone/topic '
+ '(%s) specified.' % recipient,
+ )
+
+ if len(self.phone) == 0 and len(self.topics) == 0:
+ self.logger.warning(
+ 'There are no valid recipient identified to notify.')
+
+ def notify(self, title, body, notify_type, **kwargs):
+ """
+ wrapper to send_notification since we can alert more then one channel
+ """
+
+ # Create an SNS client
+ client = boto3.client(
+ "sns",
+ aws_access_key_id=self.access_key_id,
+ aws_secret_access_key=self.secret_access_key,
+ region_name=self.region_name,
+ )
+
+ # Initiaize our error tracking
+ has_error = False
+
+ # Create a copy of our phone #'s and topics to notify against
+ phone = list(self.phone)
+ topics = list(self.topics)
+
+ while len(phone) > 0:
+ # Get Phone No
+ no = phone.pop(0)
+
+ try:
+ if not client.publish(PhoneNumber=no, Message=body):
+
+ # toggle flag
+ has_error = True
+
+ except ClientError as e:
+ self.logger.warning("The credentials specified were invalid.")
+ self.logger.debug('AWS Exception: %s' % str(e))
+
+ # We can take an early exit at this point since there
+ # is no need to potentialy get this error message again
+ # for the remaining (if any) topic/phone to process
+ return False
+
+ except EndpointConnectionError as e:
+ self.logger.warning(
+ "The region specified is invalid.")
+ self.logger.debug('AWS Exception: %s' % str(e))
+
+ # We can take an early exit at this point since there
+ # is no need to potentialy get this error message again
+ # for the remaining (if any) topic/phone to process
+ return False
+
+ if len(phone) + len(topics) > 0:
+ # Prevent thrashing requests
+ self.throttle()
+
+ # Send all our defined topic id's
+ while len(topics):
+ # Get Topic
+ topic = topics.pop(0)
+
+ # Create the topic if it doesn't exist; nothing breaks if it does
+ topic = client.create_topic(Name=topic)
+
+ # Get the Amazon Resource Name
+ topic_arn = topic['TopicArn']
+
+ # Publish a message.
+ try:
+ if not client.publish(Message=body, TopicArn=topic_arn):
+
+ # toggle flag
+ has_error = True
+
+ except ClientError as e:
+ self.logger.warning(
+ "The credentials specified were invalid.")
+ self.logger.debug('AWS Exception: %s' % str(e))
+
+ # We can take an early exit at this point since there
+ # is no need to potentialy get this error message again
+ # for the remaining (if any) topics to process
+ return False
+
+ except EndpointConnectionError as e:
+ self.logger.warning(
+ "The region specified is invalid.")
+ self.logger.debug('AWS Exception: %s' % str(e))
+
+ # We can take an early exit at this point since there
+ # is no need to potentialy get this error message again
+ # for the remaining (if any) topic/phone to process
+ return False
+
+ if len(topics) > 0:
+ # Prevent thrashing requests
+ self.throttle()
+
+ return not has_error
+
+ @staticmethod
+ def parse_url(url):
+ """
+ Parses the URL and returns enough arguments that can allow
+ us to substantiate this object.
+
+ """
+ results = NotifyBase.parse_url(url)
+
+ if not results:
+ # We're done early as we couldn't load the results
+ return results
+
+ #
+ # Apply our settings now
+ #
+
+ # The AWS Access Key ID is stored in the hostname
+ access_key_id = results['host']
+
+ # 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 = list()
+
+ # Section 1: Get Region and Access Secret
+ index = 0
+ for i, entry in enumerate(NotifyBase.split_path(results['fullpath'])):
+
+ # Are we at the region yet?
+ result = IS_REGION.match(entry)
+ if result:
+ # We found our Region; Rebuild our access key secret based on
+ # all entries we found prior to this:
+ secret_access_key = '/'.join(secret_access_key_parts)
+
+ # 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'),
+ )
+
+ # Track our index as we'll use this to grab the remaining
+ # content in the next Section
+ index = i + 1
+
+ # We're done with Section 1
+ break
+
+ # Store our secret parts
+ secret_access_key_parts.append(entry)
+
+ # Section 2: Get our Recipients (basically all remaining entries)
+ results['recipients'] = [
+ NotifyBase.unquote(x) for x in filter(bool, NotifyBase.split_path(
+ results['fullpath']))][index:]
+
+ # Store our other detected data (if at all)
+ results['region_name'] = region_name
+ results['access_key_id'] = access_key_id
+ results['secret_access_key'] = secret_access_key
+
+ # Return our result set
+ return results
diff --git a/apprise/plugins/__init__.py b/apprise/plugins/__init__.py
index 4ce3210c..bffdce92 100644
--- a/apprise/plugins/__init__.py
+++ b/apprise/plugins/__init__.py
@@ -68,7 +68,7 @@ __all__ = [
'NotifyFaast', 'NotifyGnome', 'NotifyGrowl', 'NotifyIFTTT', 'NotifyJoin',
'NotifyJSON', 'NotifyMatrix', 'NotifyMatterMost', 'NotifyProwl',
'NotifyPushed', 'NotifyPushBullet', 'NotifyPushjet',
- 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack',
+ 'NotifyPushover', 'NotifyRocketChat', 'NotifySlack', 'NotifySNS',
'NotifyTwitter', 'NotifyTelegram', 'NotifyXBMC',
'NotifyXML', 'NotifyWindows',
diff --git a/requirements.txt b/requirements.txt
index f6ef661f..d44a223d 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,3 +6,4 @@ urllib3
six
click >= 5.0
markdown
+boto3
diff --git a/test/test_sns_plugin.py b/test/test_sns_plugin.py
new file mode 100644
index 00000000..c80cd40c
--- /dev/null
+++ b/test/test_sns_plugin.py
@@ -0,0 +1,307 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2019 Chris Caron
+# All rights reserved.
+#
+# This code is licensed under the MIT License.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files(the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and / or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions :
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+# THE SOFTWARE.
+
+import mock
+from botocore.exceptions import ClientError
+from botocore.exceptions import EndpointConnectionError
+
+from apprise import plugins
+from apprise import Apprise
+
+TEST_ACCESS_KEY_ID = 'AHIAJGNT76XIMXDBIJYA'
+TEST_ACCESS_KEY_SECRET = 'bu1dHSdO22pfaaVy/wmNsdljF4C07D3bndi9PQJ9'
+TEST_REGION = 'us-east-2'
+
+
+@mock.patch('boto3.client')
+def test_object_notifications(mock_client):
+ """
+ API: NotifySNS Plugin() notifications
+
+ """
+
+ # Create our object
+ a = Apprise()
+ assert(a.add('sns://oh/yeah/us-west-2/12223334444') is True)
+ # Multi Number Support
+ assert(a.add('sns://oh/yeah/us-west-2/12223334444/12223334445') is True)
+
+ # Set a successful notification
+ client = mock.Mock()
+ client.publish.return_value = True
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is True)
+
+ # Set an unsuccessful notification
+ client = mock.Mock()
+ client.publish.return_value = False
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is False)
+
+ client = mock.Mock()
+ client.publish.return_value = True
+ client.publish.side_effect = \
+ ClientError({'ResponseMetadata': {'RetryAttempts': 1}}, '')
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is False)
+
+ client = mock.Mock()
+ client.publish.return_value = True
+ client.publish.side_effect = EndpointConnectionError(endpoint_url='')
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is False)
+
+ # Create a new object
+ a = Apprise()
+ assert(a.add('sns://oh/yeah/us-east-2/ATopic') is True)
+ # Multi-Topic
+ assert(a.add('sns://oh/yeah/us-east-2/ATopic/AnotherTopic') is True)
+
+ # Set a successful notification
+ client = mock.Mock()
+ client.publish.return_value = True
+ client.create_topic.return_value = {'TopicArn': 'goodtopic'}
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is True)
+
+ # Set an unsuccessful notification
+ client = mock.Mock()
+ client.publish.return_value = False
+ client.create_topic.return_value = {'TopicArn': 'goodtopic'}
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is False)
+
+ client = mock.Mock()
+ client.publish.return_value = True
+ client.publish.side_effect = \
+ ClientError({'ResponseMetadata': {'RetryAttempts': 1}}, '')
+ client.create_topic.return_value = {'TopicArn': 'goodtopic'}
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is False)
+
+ client = mock.Mock()
+ client.publish.return_value = True
+ client.publish.side_effect = EndpointConnectionError(endpoint_url='')
+ client.create_topic.return_value = {'TopicArn': 'goodtopic'}
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is False)
+
+ # Create a new object
+ a = Apprise()
+ # Combiniation handling
+ assert(a.add('sns://oh/yeah/us-west-2/12223334444/ATopicToo') is True)
+
+ client = mock.Mock()
+ client.publish.return_value = True
+ client.create_topic.return_value = {'TopicArn': 'goodtopic'}
+ mock_client.return_value = client
+ assert(a.notify(title='', body='apprise notification') is True)
+
+
+def test_object_initialization():
+ """
+ API: NotifySNS Plugin() initialization
+
+ """
+
+ # Initializes the plugin with a valid access, but invalid access key
+ try:
+ # No access_key_id specified
+ plugins.NotifySNS(
+ access_key_id=None,
+ secret_access_key=TEST_ACCESS_KEY_SECRET,
+ region_name=TEST_REGION,
+ recipients='+1800555999',
+ )
+ # The entries above are invalid, our code should never reach here
+ assert(False)
+
+ except TypeError:
+ # Exception correctly caught
+ assert(True)
+
+ try:
+ # No secret_access_key specified
+ plugins.NotifySNS(
+ access_key_id=TEST_ACCESS_KEY_ID,
+ secret_access_key=None,
+ region_name=TEST_REGION,
+ recipients='+1800555999',
+ )
+ # The entries above are invalid, our code should never reach here
+ assert(False)
+
+ except TypeError:
+ # Exception correctly caught
+ assert(True)
+
+ try:
+ # No region_name specified
+ plugins.NotifySNS(
+ access_key_id=TEST_ACCESS_KEY_ID,
+ secret_access_key=TEST_ACCESS_KEY_SECRET,
+ region_name=None,
+ recipients='+1800555999',
+ )
+ # The entries above are invalid, our code should never reach here
+ assert(False)
+
+ except TypeError:
+ # Exception correctly caught
+ assert(True)
+
+ try:
+ # No recipients
+ plugins.NotifySNS(
+ access_key_id=TEST_ACCESS_KEY_ID,
+ secret_access_key=TEST_ACCESS_KEY_SECRET,
+ region_name=TEST_REGION,
+ recipients=None,
+ )
+ # Still valid even without recipients
+ assert(True)
+
+ except TypeError:
+ # Exception correctly caught
+ assert(False)
+
+ try:
+ # No recipients - garbage recipients object
+ plugins.NotifySNS(
+ access_key_id=TEST_ACCESS_KEY_ID,
+ secret_access_key=TEST_ACCESS_KEY_SECRET,
+ region_name=TEST_REGION,
+ recipients=object(),
+ )
+ # Still valid even without recipients
+ assert(True)
+
+ except TypeError:
+ # Exception correctly caught
+ assert(False)
+
+ try:
+ # The phone number is invalid, and without it, there is nothing
+ # to notify
+ plugins.NotifySNS(
+ access_key_id=TEST_ACCESS_KEY_ID,
+ secret_access_key=TEST_ACCESS_KEY_SECRET,
+ region_name=TEST_REGION,
+ recipients='+1809',
+ )
+ # The recipient is invalid, but it's still okay; this Notification
+ # still becomes pretty much useless at this point though
+ assert(True)
+
+ except TypeError:
+ # Exception correctly caught
+ assert(False)
+
+ try:
+ # The phone number is invalid, and without it, there is nothing
+ # to notify; we
+ plugins.NotifySNS(
+ access_key_id=TEST_ACCESS_KEY_ID,
+ secret_access_key=TEST_ACCESS_KEY_SECRET,
+ region_name=TEST_REGION,
+ recipients='#(invalid-topic-because-of-the-brackets)',
+ )
+ # The recipient is invalid, but it's still okay; this Notification
+ # still becomes pretty much useless at this point though
+ assert(True)
+
+ except TypeError:
+ # Exception correctly caught
+ assert(False)
+
+
+def test_url_parsing():
+ """
+ API: NotifySNS Plugin() URL Parsing
+
+ """
+
+ # No recipients
+ results = plugins.NotifySNS.parse_url('sns://%s/%s/%s/' % (
+ TEST_ACCESS_KEY_ID,
+ TEST_ACCESS_KEY_SECRET,
+ TEST_REGION)
+ )
+
+ # Confirm that there were no recipients found
+ assert(len(results['recipients']) == 0)
+ assert('region_name' in results)
+ assert(TEST_REGION == results['region_name'])
+ assert('access_key_id' in results)
+ assert(TEST_ACCESS_KEY_ID == results['access_key_id'])
+ assert('secret_access_key' in results)
+ assert(TEST_ACCESS_KEY_SECRET == results['secret_access_key'])
+
+ # Detect recipients
+ results = plugins.NotifySNS.parse_url('sns://%s/%s/%s/%s/%s/' % (
+ TEST_ACCESS_KEY_ID,
+ TEST_ACCESS_KEY_SECRET,
+ # Uppercase Region won't break anything
+ TEST_REGION.upper(),
+ '+18001234567',
+ 'MyTopic')
+ )
+
+ # Confirm that our recipients were found
+ assert(len(results['recipients']) == 2)
+ assert('+18001234567' in results['recipients'])
+ assert('MyTopic' in results['recipients'])
+ assert('region_name' in results)
+ assert(TEST_REGION == results['region_name'])
+ assert('access_key_id' in results)
+ assert(TEST_ACCESS_KEY_ID == results['access_key_id'])
+ assert('secret_access_key' in results)
+ assert(TEST_ACCESS_KEY_SECRET == results['secret_access_key'])
+
+
+def test_object_parsing():
+ """
+ API: NotifySNS Plugin() Object Parsing
+
+ """
+
+ # Create our object
+ a = Apprise()
+
+ # Now test failing variations of our URL
+ assert(a.add('sns://') is False)
+ assert(a.add('sns://nosecret') is False)
+ assert(a.add('sns://nosecret/noregion/') is False)
+
+ # This is valid, but a rather useless URL; there is nothing to notify
+ assert(a.add('sns://norecipient/norecipient/us-west-2') is True)
+ assert(len(a) == 1)
+
+ # Parse a good one
+ assert(a.add('sns://oh/yeah/us-west-2/abcdtopic/+12223334444') is True)
+ assert(len(a) == 2)
+
+ assert(a.add('sns://oh/yeah/us-west-2/12223334444') is True)
+ assert(len(a) == 3)