From 25f5066e2702c4c241fa2c7290621630967926ff Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sat, 27 Apr 2019 17:41:20 -0400 Subject: [PATCH] Added Mailgun support; refs #101 --- README.md | 1 + apprise/plugins/NotifyMailgun.py | 341 +++++++++++++++++++++++++++ packaging/redhat/python-apprise.spec | 2 +- setup.py | 4 +- test/test_rest_plugins.py | 77 ++++++ 5 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 apprise/plugins/NotifyMailgun.py diff --git a/README.md b/README.md index 974a00ba..679af57d 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ The table below identifies the services this tool supports and some example serv | [IFTTT](https://github.com/caronc/apprise/wiki/Notify_ifttt) | ifttt:// | (TCP) 443 | ifttt://webhooksID/Event
ifttt://webhooksID/Event1/Event2/EventN
ifttt://webhooksID/Event1/?+Key=Value
ifttt://webhooksID/Event1/?-Key=value1 | [Join](https://github.com/caronc/apprise/wiki/Notify_join) | join:// | (TCP) 443 | join://apikey/device
join://apikey/device1/device2/deviceN/
join://apikey/group
join://apikey/groupA/groupB/groupN
join://apikey/DeviceA/groupA/groupN/DeviceN/ | [KODI](https://github.com/caronc/apprise/wiki/Notify_kodi) | kodi:// or kodis:// | (TCP) 8080 or 443 | kodi://hostname
kodi://user@hostname
kodi://user:password@hostname:port +| [Mailgun](https://github.com/caronc/apprise/wiki/Notify_mailgun) | mailgun:// | (TCP) 443 | mailgun://user@hostname/apikey
mailgun://user@hostname/apikey/email
mailgun://user@hostname/apikey/email1/email2/emailN
mailgun://user@hostname/apikey/?name="From%20User" | [Matrix](https://github.com/caronc/apprise/wiki/Notify_matrix) | matrix:// or matrixs:// | (TCP) 80 or 443 | matrix://hostname
matrix://user@hostname
matrixs://user:pass@hostname:port/#room_alias
matrixs://user:pass@hostname:port/!room_id
matrixs://user:pass@hostname:port/#room_alias/!room_id/#room2
matrixs://token@hostname:port/?webhook=matrix
matrix://user:token@hostname/?webhook=slack&format=markdown | [Mattermost](https://github.com/caronc/apprise/wiki/Notify_mattermost) | mmost:// | (TCP) 8065 | mmost://hostname/authkey
mmost://hostname:80/authkey
mmost://user@hostname:80/authkey
mmost://hostname/authkey?channel=channel
mmosts://hostname/authkey
mmosts://user@hostname/authkey
| [Microsoft Teams](https://github.com/caronc/apprise/wiki/Notify_msteams) | msteams:// | (TCP) 443 | msteams://TokenA/TokenB/TokenC/ diff --git a/apprise/plugins/NotifyMailgun.py b/apprise/plugins/NotifyMailgun.py new file mode 100644 index 00000000..504a101c --- /dev/null +++ b/apprise/plugins/NotifyMailgun.py @@ -0,0 +1,341 @@ +# -*- 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. + +# Signup @ https://www.mailgun.com/ +# +# Each domain will have an API key associated with it. If you sign up you'll +# get a sandbox domain to use. Or if you set up your own, they'll have +# api keys associated with them too. Find your API key out by visiting +# https://app.mailgun.com/app/domains +# +# From here you can click on the domain you're interested in. You can acquire +# the API Key from here which will look something like: +# 4b4f2918c6c21ba0a26ad2af73c07f4d-dk5f51da-8f91a0df +# +# You'll also need to know the domain that is associated with your API key. +# This will be obvious with a paid account because it will be the domain name +# you've registered with them. But if you're using a test account, it will +# be name of the sandbox you've set up such as: +# sandbox74bda3414c06kb5acb946.mailgun.org +# +# Knowing this, you can buid your mailgun url as follows: +# mailgun://{user}@{domain}/{apikey} +# mailgun://{user}@{domain}/{apikey}/{email} +# +# You can email as many addresses as you want as: +# mailgun://{user}@{domain}/{apikey}/{email1}/{email2}/{emailN} +# +# The {user}@{domain} effectively assembles the 'from' email address +# the email will be transmitted from. If no email address is specified +# then it will also become the 'to' address as well. +# +import re +import requests + +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..utils import parse_list +from ..utils import is_email + + +# Used to validate your personal access apikey +VALIDATE_API_KEY = re.compile(r'^[a-z0-9]{32}-[a-z0-9]{8}-[a-z0-9]{8}$', re.I) + +# Provide some known codes Mailgun uses and what they translate to: +# Based on https://documentation.mailgun.com/en/latest/api-intro.html#errors +MAILGUN_HTTP_ERROR_MAP = { + 400: 'A bad request was made to the server.', + 401: 'The provided API Key was not valid.', + 402: 'The request failed for a reason out of your control.', + 404: 'The requested API query is not valid.', + 413: 'Provided attachment is to big.', +} + + +# Priorities +class MailgunRegion(object): + US = 'us' + EU = 'eu' + + +# Mailgun APIs +MAILGUN_API_LOOKUP = { + MailgunRegion.US: 'https://api.mailgun.net/v3/', + MailgunRegion.EU: 'https://api.eu.mailgun.net/v3/', +} + +# A List of our regions we can use for verification +MAILGUN_REGIONS = ( + MailgunRegion.US, + MailgunRegion.EU, +) + + +class NotifyMailgun(NotifyBase): + """ + A wrapper for Mailgun Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'Mailgun' + + # The services URL + service_url = 'https://www.mailgun.com/' + + # All pushover requests are secure + secure_protocol = 'mailgun' + + # Mailgun advertises they allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.20 + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_mailgun' + + # The default region to use if one isn't otherwise specified + mailgun_default_region = MailgunRegion.US + + def __init__(self, apikey, targets, from_name=None, region_name=None, + **kwargs): + """ + Initialize Mailgun Object + """ + super(NotifyMailgun, self).__init__(**kwargs) + + try: + # The personal access apikey associated with the account + self.apikey = apikey.strip() + + except AttributeError: + # Token was None + msg = 'No API Key was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + if not VALIDATE_API_KEY.match(self.apikey): + msg = 'The API Key specified ({}) is invalid.' \ + .format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate our username + if not self.user: + msg = 'No username was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Parse our targets + self.targets = parse_list(targets) + + # Store our region + try: + self.region_name = self.mailgun_default_region \ + if region_name is None else region_name.lower() + + if self.region_name not in MAILGUN_REGIONS: + # allow the outer except to handle this common response + raise + except: + # Invalid region specified + msg = 'The region specified ({}) is invalid.' \ + .format(region_name) + self.logger.warning(msg) + raise TypeError(msg) + + # Get our From username (if specified) + self.from_name = from_name + + # Get our from email address + self.from_addr = '{user}@{host}'.format(user=self.user, host=self.host) + + if not is_email(self.from_addr): + # Parse Source domain based on from_addr + msg = 'Invalid ~From~ email format: {}'.format(self.from_addr) + self.logger.warning(msg) + raise TypeError(msg) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform Mailgun Notification + """ + + # error tracking (used for function return) + has_error = False + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + } + + # Prepare our payload + payload = { + 'from': '{name} <{addr}>'.format( + name=self.app_id if not self.from_name else self.from_name, + addr=self.from_addr), + 'subject': title, + 'text': body, + } + + # Prepare our URL as it's based on our hostname + url = '{}{}/messages'.format( + MAILGUN_API_LOOKUP[self.region_name], self.host) + + # Create a copy of the targets list + emails = list(self.targets) + + if len(emails) == 0: + # No email specified; use the from + emails.append(self.from_addr) + + while len(emails): + # Get our email to notify + email = emails.pop(0) + + # Prepare our user + payload['to'] = '{} <{}>'.format(email, email) + + # Some Debug Logging + self.logger.debug('Mailgun POST URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('Mailgun Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + url, + auth=("api", self.apikey), + data=payload, + headers=headers, + verify=self.verify_certificate, + ) + + if r.status_code != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code, MAILGUN_API_LOOKUP) + + self.logger.warning( + 'Failed to send Mailgun notification to {}: ' + '{}{}error={}.'.format( + email, + 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.info( + 'Sent Mailgun notification to {}.'.format(email)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occured sending Mailgun:%s ' % ( + email) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any arguments set + args = { + 'format': self.notify_format, + 'overflow': self.overflow_mode, + 'verify': 'yes' if self.verify_certificate else 'no', + 'region': self.region_name, + } + + if self.from_name is not None: + # from_name specified; pass it back on the url + args['name'] = self.from_name + + return '{schema}://{user}@{host}/{apikey}/{targets}/?{args}'.format( + schema=self.secure_protocol, + host=self.host, + user=NotifyMailgun.quote(self.user, safe=''), + apikey=NotifyMailgun.quote(self.apikey, safe=''), + targets='/'.join( + [NotifyMailgun.quote(x, safe='') for x in self.targets]), + args=NotifyMailgun.urlencode(args)) + + @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 + + # Get our entries; split_path() looks after unquoting content for us + # by default + results['targets'] = NotifyMailgun.split_path(results['fullpath']) + + # Our very first entry is reserved for our api key + try: + results['apikey'] = results['targets'].pop(0) + + except IndexError: + # We're done - no API Key found + results['apikey'] = None + + if 'name' in results['qsd'] and len(results['qsd']['name']): + # Extract from name to associate with from address + results['from_name'] = \ + NotifyMailgun.unquote(results['qsd']['name']) + + if 'region' in results['qsd'] and len(results['qsd']['region']): + # Extract from name to associate with from address + results['region_name'] = \ + NotifyMailgun.unquote(results['qsd']['region']) + + # Support the 'to' variable so that we can support targets this way too + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifyMailgun.parse_list(results['qsd']['to']) + + return results diff --git a/packaging/redhat/python-apprise.spec b/packaging/redhat/python-apprise.spec index 3862b2f9..6e22d706 100644 --- a/packaging/redhat/python-apprise.spec +++ b/packaging/redhat/python-apprise.spec @@ -48,7 +48,7 @@ notification services that are out there. Apprise opens the door and makes it easy to access: Boxcar, Discord, E-Mail, Emby, Faast, Flock, Gitter, Gotify, Growl, IFTTT, -Join, KODI, MatterMost, Matrix, Microsoft Windows Notifications, +Join, KODI, Mailgun, MatterMost, Matrix, Microsoft Windows Notifications, Microsoft Teams, Notify My Android, Prowl, Pushalot, PushBullet, Pushjet, Pushover, Rocket.Chat, Slack, Super Toasty, Stride, Telegram, Twitter, XBMC, XMPP, Webex Teams} diff --git a/setup.py b/setup.py index 810da56a..31d3b0d5 100755 --- a/setup.py +++ b/setup.py @@ -57,8 +57,8 @@ setup( long_description_content_type='text/markdown', url='https://github.com/caronc/apprise', keywords='Push Notifications Alerts Email AWS SNS Boxcar Discord Dbus ' - 'Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join KODI Matrix ' - 'Mattermost Matrix Prowl PushBullet Pushjet Pushed Pushover ' + 'Emby Faast Flock Gitter Gnome Gotify Growl IFTTT Join KODI Mailgun ' + 'Matrix Mattermost Prowl PushBullet Pushjet Pushed Pushover ' 'Rocket.Chat Ryver Slack Stride Telegram Twitter XBMC Microsoft ' 'Windows Webex CLI API', author='Chris Caron', diff --git a/test/test_rest_plugins.py b/test/test_rest_plugins.py index b2c158a3..11c12eb9 100644 --- a/test/test_rest_plugins.py +++ b/test/test_rest_plugins.py @@ -757,6 +757,83 @@ TEST_URLS = ( 'test_requests_exceptions': True, }), + ################################## + # NotifyMailgun + ################################## + ('mailgun://', { + 'instance': None, + }), + ('mailgun://:@/', { + 'instance': None, + }), + # No Token specified + ('mailgun://user@host', { + 'instance': TypeError, + }), + # Token specified but it's invalid + ('mailgun://user@host/{}'.format('a' * 12), { + 'instance': TypeError, + }), + # Token is valid, but no user name specified + ('mailgun://host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), { + 'instance': TypeError, + }), + # Invalid from email address + ('mailgun://!@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), { + 'instance': TypeError, + }), + # No To email address, but everything else is valid + ('mailgun://user@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + }), + # valid url with region specified (case insensitve) + ('mailgun://user@host/{}-{}-{}?region=uS'.format( + 'a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + }), + # valid url with region specified (case insensitve) + ('mailgun://user@host/{}-{}-{}?region=EU'.format( + 'a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + }), + # invalid url with region specified (case insensitve) + ('mailgun://user@host/{}-{}-{}?region=invalid'.format( + 'a' * 32, 'b' * 8, 'c' * 8), { + 'instance': TypeError, + }), + # One To Email address + ('mailgun://user@host/{}-{}-{}/test@example.com'.format( + 'a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + }), + ('mailgun://user@host/{}-{}-{}?to=test@example.com'.format( + 'a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + }), + # One To Email address, a from name specified too + ('mailgun://user@host/{}-{}-{}/test@example.com?name="Frodo"'.format( + 'a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + }), + ('mailgun://user@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + # force a failure + 'response': False, + 'requests_response_code': requests.codes.internal_server_error, + }), + ('mailgun://user@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + # throw a bizzare code forcing us to fail to look it up + 'response': False, + 'requests_response_code': 999, + }), + ('mailgun://user@host/{}-{}-{}'.format('a' * 32, 'b' * 8, 'c' * 8), { + 'instance': plugins.NotifyMailgun, + # Throws a series of connection and transfer exceptions when this flag + # is set and tests that we gracfully handle them + 'test_requests_exceptions': True, + }), + ################################## # NotifyMatrix ##################################