From 5e1e4857f1d3b5a4ae352e87fffae6e3cde3c721 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Fri, 30 Aug 2024 22:26:50 -0400 Subject: [PATCH] basic tests passing --- apprise/plugins/sendpulse.py | 204 +++++++++++++++++++++++----------- test/test_plugin_sendpulse.py | 63 +++++++---- 2 files changed, 183 insertions(+), 84 deletions(-) diff --git a/apprise/plugins/sendpulse.py b/apprise/plugins/sendpulse.py index 24b4016a..d2e4af92 100644 --- a/apprise/plugins/sendpulse.py +++ b/apprise/plugins/sendpulse.py @@ -30,7 +30,7 @@ # - https://sendpulse.com/integrations/api/smtp import requests -from json import dumps +from json import dumps, loads import base64 from .base import NotifyBase @@ -69,6 +69,9 @@ class NotifySendPulse(NotifyBase): # The default Email API URL to use notify_email_url = 'https://api.sendpulse.com/smtp/emails' + # Our OAuth Query + notify_oauth_url = 'https://api.sendpulse.com/oauth/access_token' + # Support attachments attachment_support = True @@ -167,6 +170,15 @@ class NotifySendPulse(NotifyBase): """ super().__init__(**kwargs) + # Api Key is acquired upon a sucessful login + self.access_token = None + + result = is_email(from_email) + if not result: + msg = 'Invalid ~From~ email specified: {}'.format(from_email) + self.logger.warning(msg) + raise TypeError(msg) + # Client ID self.client_id = validate_regex( client_id, *self.template_tokens['client_id']['regex']) @@ -185,12 +197,6 @@ class NotifySendPulse(NotifyBase): self.logger.warning(msg) raise TypeError(msg) - result = is_email(from_email) - if not result: - msg = 'Invalid ~From~ email specified: {}'.format(from_email) - self.logger.warning(msg) - raise TypeError(msg) - # Tracks emails to name lookups (if they exist) self.__email_map = {} @@ -208,6 +214,8 @@ class NotifySendPulse(NotifyBase): # Acquire Blind Carbon Copies self.bcc = set() + # No template + self.template = None if template: try: # Store our template @@ -229,9 +237,9 @@ class NotifySendPulse(NotifyBase): 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'])) + self.targets.append(result['full_email']) + if result['name']: + self.__email_map[result['full_email']] = result['name'] continue self.logger.warning( @@ -250,7 +258,7 @@ class NotifySendPulse(NotifyBase): if result: self.cc.add(result['full_email']) if result['name']: - self.__email_lookup[result['full_email']] = result['name'] + self.__email_map[result['full_email']] = result['name'] continue self.logger.warning( @@ -265,7 +273,7 @@ class NotifySendPulse(NotifyBase): if result: self.bcc.add(result['full_email']) if result['name']: - self.__email_lookup[result['full_email']] = result['name'] + self.__email_map[result['full_email']] = result['name'] continue self.logger.warning( @@ -300,8 +308,8 @@ class NotifySendPulse(NotifyBase): # Handle our Carbon Copy Addresses params['cc'] = ','.join([ formataddr( - (self.__email_lookup[e] - if e in self.__email_lookup else False, e), + (self.__email_map[e] + if e in self.__email_map else False, e), # Swap comma for it's escaped url code (if detected) since # we're using that as a delimiter charset='utf-8').replace(',', '%2C') @@ -311,8 +319,8 @@ class NotifySendPulse(NotifyBase): # Handle our Blind Carbon Copy Addresses params['bcc'] = ','.join([ formataddr( - (self.__email_lookup[e] - if e in self.__email_lookup else False, e), + (self.__email_map[e] + if e in self.__email_map else False, e), # Swap comma for it's escaped url code (if detected) since # we're using that as a delimiter charset='utf-8').replace(',', '%2C') @@ -353,10 +361,24 @@ class NotifySendPulse(NotifyBase): Perform SendPulse Notification """ - headers = { - 'User-Agent': self.app_id, - 'Content-Type': 'application/json', - 'Authorization': 'Bearer {}'.format(self.apikey), + if not self.access_token: + # Attempt to acquire acquire a login + _payload = { + 'grant_type': 'client_credentials', + 'client_id': self.client_id, + 'client_secret': self.client_secret, + } + + success, response = self._fetch(self.notify_oauth_url, _payload) + if not success: + return False + + # If we get here, we're authenticated + self.access_token = response.get('access_token') + + # Store our bearer + extended_headers = { + 'Authorization': 'Bearer {}'.format(self.access_token), } # error tracking (used for function return) @@ -451,8 +473,8 @@ class NotifySendPulse(NotifyBase): to = { 'email': target } - if target in self.__email_lookup: - to['name'] = self.__email_lookup[target] + if target in self.__email_map: + to['name'] = self.__email_map[target] # Set our target payload['email']['to'] = [to] @@ -463,8 +485,8 @@ class NotifySendPulse(NotifyBase): item = { 'email': email, } - if email in self.__email_lookup: - item['name'] = self.__email_lookup[email] + if email in self.__email_map: + item['name'] = self.__email_map[email] payload['email']['cc'].append(item) @@ -474,33 +496,77 @@ class NotifySendPulse(NotifyBase): item = { 'email': email, } - if email in self.__email_lookup: - item['name'] = self.__email_lookup[email] + if email in self.__email_map: + item['name'] = self.__email_map[email] payload['email']['bcc'].append(item) - self.logger.debug('SendPulse POST URL: %s (cert_verify=%r)' % ( - self.notify_email_url, self.verify_certificate, - )) - self.logger.debug('SendPulse Payload: %s' % str(payload)) + # Perform our post + success, response = self._fetch( + self.notify_email_url, payload, target, extended_headers) + if not success: + has_error = True + continue + + return not has_error + + def _fetch(self, url, payload, target=None, extended_headers={}): + """ + 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. + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + } + + if extended_headers: + headers.update(extended_headers) + + self.logger.debug('SendPulse POST URL: %s (cert_verify=%r)' % ( + self.url, self.verify_certificate, + )) + self.logger.debug('SendPulse Payload: %s' % str(payload)) + + # Prepare our default response + response = {} + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.url, + data=dumps(payload), + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) - # Always call throttle before any remote server i/o is made - self.throttle() try: - r = requests.post( - self.notify_email_url, - data=dumps(payload), - headers=headers, - verify=self.verify_certificate, - timeout=self.request_timeout, - ) - if r.status_code not in ( - requests.codes.ok, requests.codes.accepted): - # We had a problem - status_str = \ - NotifySendPulse.http_response_code_lookup( - r.status_code) + response = loads(r.content) + except (AttributeError, TypeError, ValueError): + # This gets thrown if we can't parse our JSON Response + # - ValueError = r.content is Unparsable + # - TypeError = r.content is None + # - AttributeError = r is None + self.logger.warning('Invalid response from SendPulse server.') + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + return (False, {}) + + if r.status_code not in ( + requests.codes.ok, requests.codes.accepted): + # We had a problem + status_str = \ + NotifySendPulse.http_response_code_lookup( + r.status_code) + + if target: self.logger.warning( 'Failed to send SendPulse notification to {}: ' '{}{}error={}.'.format( @@ -508,29 +574,33 @@ class NotifySendPulse(NotifyBase): status_str, ', ' if status_str else '', r.status_code)) - - self.logger.debug( - 'Response Details:\r\n{}'.format(r.content)) - - # Mark our failure - has_error = True - continue - else: + self.logger.warning( + 'SendPulse Authentication Request failed: ' + '{}{}error={}.'.format( + status_str, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + else: + if target: self.logger.info( 'Sent SendPulse notification to {}.'.format(target)) + else: + self.logger.debug('SendPulse authentication successful') - except requests.RequestException as e: - self.logger.warning( - 'A Connection error occurred sending SendPulse ' - 'notification to {}.'.format(target)) - self.logger.debug('Socket Exception: %s' % str(e)) + return (True, response) - # Mark our failure - has_error = True - continue + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SendPulse ' + 'notification to {}.'.format(target)) + self.logger.debug('Socket Exception: %s' % str(e)) - return not has_error + return (False, response) @staticmethod def parse_url(url): @@ -545,6 +615,12 @@ class NotifySendPulse(NotifyBase): # We're done early as we couldn't load the results return results + # Define our minimum requirements; defining them now saves us from + # having to if/else all kinds of branches below... + results['from_email'] = None + results['client_id'] = None + results['client_secret'] = None + # Our URL looks like this: # {schema}://{from_email}:{client_id}/{client_secret}/{targets} # @@ -564,7 +640,7 @@ class NotifySendPulse(NotifyBase): results['client_id'] = \ NotifySendPulse.unquote(results['qsd']['id']) - else: + elif results['targets']: # Store our Client ID results['client_id'] = results['targets'].pop(0) @@ -573,7 +649,7 @@ class NotifySendPulse(NotifyBase): results['client_secret'] = \ NotifySendPulse.unquote(results['qsd']['secret']) - else: + elif results['targets']: # Store our Client Secret results['client_secret'] = results['targets'].pop(0) diff --git a/test/test_plugin_sendpulse.py b/test/test_plugin_sendpulse.py index 31031b8d..8dbc4ac3 100644 --- a/test/test_plugin_sendpulse.py +++ b/test/test_plugin_sendpulse.py @@ -26,6 +26,7 @@ # ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE # POSSIBILITY OF SUCH DAMAGE. +from json import dumps from unittest import mock import os @@ -42,6 +43,12 @@ from helpers import AppriseURLTester import logging logging.disable(logging.CRITICAL) +SENDPULSE_GOOD_RESPONSE = dumps({ + "access_token": 'abc123' +}) + +SENDPULSE_BAD_RESPONSE = '{' + # Attachment Directory TEST_VAR_DIR = os.path.join(os.path.dirname(__file__), 'var') @@ -54,61 +61,76 @@ apprise_url_tests = ( 'instance': None, }), ('sendpulse://abcd', { - # Just an broken email (no email, client_id or secret) - 'instance': None, + # invalid from email + 'instance': TypeError, }), - ('sendpulse://abcd@host', { + ('sendpulse://abcd@host.com', { # Just an Email specified, no client_id or client_secret - 'instance': None, + 'instance': TypeError, }), - ('sendpulse://user@example.com/client_id/client_secret/', { + ('sendpulse://user@example.com/client_id/cs1/', { 'instance': NotifySendPulse, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, }), - ('sendpulse://user@example.com/client_id/client_secret/' + ('sendpulse://user@example.com/client_id/cs1a/', { + 'instance': NotifySendPulse, + 'requests_response_text': SENDPULSE_BAD_RESPONSE, + # Notify will fail because auth failed + 'response': False, + }), + ('sendpulse://user@example.com/client_id/cs2/' '?bcc=l2g@nuxref.com', { # A good email with Blind Carbon Copy 'instance': NotifySendPulse, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, }), - ('sendpulse://user@example.com/client_id/client_secret/' + ('sendpulse://user@example.com/client_id/cs3/' '?cc=l2g@nuxref.com', { # A good email with Carbon Copy 'instance': NotifySendPulse, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, }), - ('sendpulse://user@example.com/client_id/client_secret/' + ('sendpulse://user@example.com/client_id/cs4/' '?to=l2g@nuxref.com', { # A good email with Carbon Copy 'instance': NotifySendPulse, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, }), - ('sendpulse://user@example.com/client_id/client_secret/' + ('sendpulse://user@example.com/client_id/cs5/' '?template=1234', { # A good email with a template + no substitutions 'instance': NotifySendPulse, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, }), - ('sendpulse://user@example.com/client_id/client_secret/' + ('sendpulse://user@example.com/client_id/cs6/' '?template=1234&+sub=value&+sub2=value2', { # A good email with a template + substitutions 'instance': NotifySendPulse, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, # Our expected url(privacy=True) startswith() response: - 'privacy_url': 'sendpulse://a...d:user@example.com/', + 'privacy_url': 'sendpulse://user@example.com/c...d/c...6/', }), - ('sendpulse://user@example.com/client_id/client_secret/', { + ('sendpulse://user@example.com/client_id/cs7/', { 'instance': NotifySendPulse, # force a failure 'response': False, 'requests_response_code': requests.codes.internal_server_error, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, }), - ('sendpulse://user@example.com/client_id/client_secret/', { + ('sendpulse://user@example.com/client_id/cs8/', { 'instance': NotifySendPulse, # throw a bizzare code forcing us to fail to look it up 'response': False, 'requests_response_code': 999, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, }), - ('sendpulse://user@example.com/client_id/client_secret/', { + ('sendpulse://user@example.com/client_id/cs9/', { 'instance': NotifySendPulse, # Throws a series of connection and transfer exceptions when this flag # is set and tests that we gracfully handle them 'test_requests_exceptions': True, + 'requests_response_text': SENDPULSE_GOOD_RESPONSE, }), ) @@ -139,27 +161,27 @@ def test_plugin_sendpulse_edge_cases(mock_post, mock_get): with pytest.raises(TypeError): NotifySendPulse( - client_id=None, client_secret='abcd', + client_id=None, client_secret='abcd123', from_email='user@example.com') # invalid from email with pytest.raises(TypeError): NotifySendPulse( - client_id='abcd', client_secret='abcd', from_email='!invalid') + client_id='abcd', client_secret='abcd456', from_email='!invalid') # no email with pytest.raises(TypeError): NotifySendPulse( - client_id='abcd', client_secret='abcd', from_email=None) + client_id='abcd', client_secret='abcd789', from_email=None) # Invalid To email address NotifySendPulse( - client_id='abcd', client_secret='abcd', + client_id='abcd', client_secret='abcd321', from_email='user@example.com', targets="!invalid") # Test invalid bcc/cc entries mixed with good ones assert isinstance(NotifySendPulse( - client_id='abcd', client_secret='abcd', + client_id='abcd', client_secret='abcd654', from_email='l2g@example.com', bcc=('abc@def.com', '!invalid'), cc=('abc@test.org', '!invalid')), NotifySendPulse) @@ -175,6 +197,7 @@ def test_plugin_sendpulse_attachments(mock_post, mock_get): request = mock.Mock() request.status_code = requests.codes.ok + request.content = SENDPULSE_GOOD_RESPONSE # Prepare Mock mock_post.return_value = request @@ -182,7 +205,7 @@ def test_plugin_sendpulse_attachments(mock_post, mock_get): path = os.path.join(TEST_VAR_DIR, 'apprise-test.gif') attach = AppriseAttachment(path) - obj = Apprise.instantiate('sendpulse://user@example.com/abcd/abcd') + obj = Apprise.instantiate('sendpulse://user@example.com/aaaa/bbbb') assert isinstance(obj, NotifySendPulse) assert obj.notify( body='body', title='title', notify_type=NotifyType.INFO,