Skip to content

Commit

Permalink
persistent storage added
Browse files Browse the repository at this point in the history
  • Loading branch information
caronc committed Sep 1, 2024
1 parent 47c0ed3 commit abf3735
Show file tree
Hide file tree
Showing 2 changed files with 195 additions and 31 deletions.
105 changes: 75 additions & 30 deletions apprise/plugins/sendpulse.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
from .. import exception
from ..common import NotifyFormat
from ..common import NotifyType
from ..common import PersistentStoreMode
from ..utils import is_email
from ..utils import parse_emails
from ..conversion import convert_between
Expand Down Expand Up @@ -79,6 +80,18 @@ class NotifySendPulse(NotifyBase):
# 60/300 = 0.2
request_rate_per_sec = 0.2

# Our default is to no not use persistent storage beyond in-memory
# reference
storage_mode = PersistentStoreMode.AUTO

# Token expiry if not detected in seconds (below is 1 hr)
token_expiry = 3600

# The number of seconds to grace for early token renewal
# Below states that 10 seconds bfore our token expiry, we'll
# attempt to renew it
token_expiry_edge = 10

# Support attachments
attachment_support = True

Expand Down Expand Up @@ -174,9 +187,6 @@ def __init__(self, client_id, client_secret, from_addr=None, targets=None,
"""
super().__init__(**kwargs)

# Api Key is acquired upon a sucessful login
self.access_token = None

# For tracking our email -> name lookups
self.names = {}

Expand Down Expand Up @@ -395,31 +405,58 @@ def __len__(self):
"""
return len(self.targets)

def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
def login(self):
"""
Perform SendPulse Notification
Authenticates with the server to get a access_token
"""
self.store.clear('access_token')
payload = {
'grant_type': 'client_credentials',
'client_id': self.client_id,
'client_secret': self.client_secret,
}

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

success, response = self._fetch(self.notify_oauth_url, _payload)
if not success:
access_token = response.get('access_token')

# If we get here, we're authenticated
try:
expires = \
int(response.get('expires_in')) - self.token_expiry_edge
if expires <= self.token_expiry_edge:
self.logger.error(
'SendPulse token expiry limit returned was invalid')
return False

# If we get here, we're authenticated
self.access_token = response.get('access_token')
elif expires > self.token_expiry:
self.logger.warning(
'SendPulse token expiry limit fixed to: {}s'
.format(self.token_expiry))
expires = self.token_expiry - self.token_expiry_edge

# Store our bearer
extended_headers = {
'Authorization': 'Bearer {}'.format(self.access_token),
}
except (AttributeError, TypeError, ValueError):
# expires_in was not an integer
self.logger.warning(
'SendPulse token expiry limit presumed to be: {}s'.format(
self.token_expiry))
expires = self.token_expiry - self.token_expiry_edge

self.store.set('access_token', access_token, expires=expires)

return access_token

def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
**kwargs):
"""
Perform SendPulse Notification
"""

access_token = self.store.get('access_token') or self.login()
if not access_token:
return False

# error tracking (used for function return)
has_error = False
Expand Down Expand Up @@ -543,29 +580,29 @@ def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,

# Perform our post
success, response = self._fetch(
self.notify_email_url, payload, target, extended_headers)
self.notify_email_url, payload, target, retry=1)
if not success:
has_error = True
continue

return not has_error

def _fetch(self, url, payload, target=None, extended_headers={}):
def _fetch(self, url, payload, target=None, retry=0):
"""
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)
access_token = self.store.get('access_token')
if access_token:
headers.update({'Authorization': f'Bearer {access_token}'})

self.logger.debug('SendPulse POST URL: %s (cert_verify=%r)' % (
url, self.verify_certificate,
Expand Down Expand Up @@ -599,12 +636,20 @@ def _fetch(self, url, payload, target=None, extended_headers={}):
'Response Details:\r\n{}'.format(r.content))
return (False, {})

if r.status_code not in (
# Reference status code
status_code = r.status_code

if status_code == requests.codes.unauthorized:
# Key likely expired, we'll reset it and try one more time
if retry and self.login():
return self._fetch(url, payload, target, retry=retry - 1)

if status_code not in (
requests.codes.ok, requests.codes.accepted):
# We had a problem
status_str = \
NotifySendPulse.http_response_code_lookup(
r.status_code)
status_code)

if target:
self.logger.warning(
Expand All @@ -613,14 +658,14 @@ def _fetch(self, url, payload, target=None, extended_headers={}):
target,
status_str,
', ' if status_str else '',
r.status_code))
status_code))
else:
self.logger.warning(
'SendPulse Authentication Request failed: '
'{}{}error={}.'.format(
status_str,
', ' if status_str else '',
r.status_code))
status_code))

self.logger.debug(
'Response Details:\r\n{}'.format(r.content))
Expand Down
121 changes: 120 additions & 1 deletion test/test_plugin_sendpulse.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@
logging.disable(logging.CRITICAL)

SENDPULSE_GOOD_RESPONSE = dumps({
"access_token": 'abc123'
"access_token": 'abc123',
"expires_in": 3600,
})

SENDPULSE_BAD_RESPONSE = '{'
Expand Down Expand Up @@ -353,6 +354,124 @@ def test_plugin_sendpulse_edge_cases(mock_post):
assert obj.notify(
body='body', title='title', notify_type=NotifyType.INFO) is False

# Test re-authentication
mock_post.reset_mock()
request = mock.Mock()
obj = Apprise.instantiate('sendpulse://[email protected]/ci/cs/?from=Retry')

class sendpulse():
def __init__(self):
# 200 login okay
# 401 on retrival
# recursive re-attempt to login returns 200
# fetch after works
self._side_effect = iter([
requests.codes.ok, requests.codes.unauthorized,
requests.codes.ok, requests.codes.ok,
])

@property
def status_code(self):
return next(self._side_effect)

@property
def content(self):
return SENDPULSE_GOOD_RESPONSE

mock_post.return_value = sendpulse()

assert obj.notify(
body='body', title='title', notify_type=NotifyType.INFO) is True

assert mock_post.call_count == 4
# Authentication
assert mock_post.call_args_list[0][0][0] == \
'https://api.sendpulse.com/oauth/access_token'
# 401 received
assert mock_post.call_args_list[1][0][0] == \
'https://api.sendpulse.com/smtp/emails'
# Re-authenticate
assert mock_post.call_args_list[2][0][0] == \
'https://api.sendpulse.com/oauth/access_token'
# Try again
assert mock_post.call_args_list[3][0][0] == \
'https://api.sendpulse.com/smtp/emails'

# Test re-authentication (no recursive loops)
mock_post.reset_mock()
request = mock.Mock()
obj = Apprise.instantiate('sendpulse://[email protected]/ci/cs/?from=Retry')

class sendpulse():
def __init__(self):
# oauth always returns okay but notify returns 401
# recursive re-attempt only once
self._side_effect = iter([
requests.codes.ok, requests.codes.unauthorized,
requests.codes.ok, requests.codes.unauthorized,
requests.codes.ok, requests.codes.unauthorized,
requests.codes.ok, requests.codes.unauthorized,
requests.codes.ok, requests.codes.unauthorized,
requests.codes.ok, requests.codes.unauthorized,
requests.codes.ok, requests.codes.unauthorized,
requests.codes.ok, requests.codes.unauthorized,
])

@property
def status_code(self):
return next(self._side_effect)

@property
def content(self):
return SENDPULSE_GOOD_RESPONSE

mock_post.return_value = sendpulse()

assert obj.notify(
body='body', title='title', notify_type=NotifyType.INFO) is False

assert mock_post.call_count == 4
# Authentication
assert mock_post.call_args_list[0][0][0] == \
'https://api.sendpulse.com/oauth/access_token'
# 401 received
assert mock_post.call_args_list[1][0][0] == \
'https://api.sendpulse.com/smtp/emails'
# Re-authenticate
assert mock_post.call_args_list[2][0][0] == \
'https://api.sendpulse.com/oauth/access_token'
# Last failed attempt
assert mock_post.call_args_list[3][0][0] == \
'https://api.sendpulse.com/smtp/emails'

mock_post.side_effect = None
request = mock.Mock()
request.status_code = requests.codes.ok
request.content = SENDPULSE_GOOD_RESPONSE
mock_post.return_value = request
for expires_in in (None, -1, 'garbage', 3600, 300000):
request.content = dumps({
"access_token": 'abc123',
"expires_in": expires_in,
})

# Instantiate our object
obj = Apprise.instantiate('sendpulse://[email protected]/ci/cs/')

# Test variations of responses
obj.notify(
body='body', title='title', notify_type=NotifyType.INFO)

# expires_in is missing
request.content = dumps({
"access_token": 'abc123',
})

# Instantiate our object
obj = Apprise.instantiate('sendpulse://[email protected]/ci/cs/')
obj.notify(
body='body', title='title', notify_type=NotifyType.INFO) is True


def test_plugin_sendpulse_fail_cases():
"""
Expand Down

0 comments on commit abf3735

Please sign in to comment.