diff options
Diffstat (limited to 'libs/apprise/plugins/NotifySendGrid.py')
-rw-r--r-- | libs/apprise/plugins/NotifySendGrid.py | 475 |
1 files changed, 475 insertions, 0 deletions
diff --git a/libs/apprise/plugins/NotifySendGrid.py b/libs/apprise/plugins/NotifySendGrid.py new file mode 100644 index 000000000..b82e3e60d --- /dev/null +++ b/libs/apprise/plugins/NotifySendGrid.py @@ -0,0 +1,475 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron <[email protected]> +# +# 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. + +# You will need an API Key for this plugin to work. +# From the Settings -> API Keys you can click "Create API Key" if you don't +# have one already. The key must have at least the "Mail Send" permission +# to work. +# +# The schema to use the plugin looks like this: +# {schema}://{apikey}:{from_email} +# +# Your {from_email} must be comprissed of your Sendgrid Authenticated +# Domain. The same domain must have 'Link Branding' turned on as well or it +# will not work. This can be seen from Settings -> Sender Authentication. + +# If you're (SendGrid) verified domain is example.com, then your schema may +# look something like this: + +# Simple API Reference: +# - https://sendgrid.com/docs/API_Reference/Web_API_v3/index.html +# - https://sendgrid.com/docs/ui/sending-email/\ +# how-to-send-an-email-with-dynamic-transactional-templates/ + +import requests +from json import dumps + +from .NotifyBase import NotifyBase +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import parse_list +from ..utils import is_email +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +# Extend HTTP Error Messages +SENDGRID_HTTP_ERROR_MAP = { + 401: 'Unauthorized - You do not have authorization to make the request.', + 413: 'Payload To Large - The JSON payload you have included in your ' + 'request is too large.', + 429: 'Too Many Requests - The number of requests you have made exceeds ' + 'SendGrid’s rate limitations.', +} + + +class NotifySendGrid(NotifyBase): + """ + A wrapper for Notify SendGrid Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SendGrid' + + # The services URL + service_url = 'https://sendgrid.com' + + # The default secure protocol + secure_protocol = 'sendgrid' + + # A URL that takes you to the setup/help of the specific protocol + setup_url = 'https://github.com/caronc/apprise/wiki/Notify_sendgrid' + + # Default to markdown + notify_format = NotifyFormat.HTML + + # The default Email API URL to use + notify_url = 'https://api.sendgrid.com/v3/mail/send' + + # Allow 300 requests per minute. + # 60/300 = 0.2 + request_rate_per_sec = 0.2 + + # The default subject to use if one isn't specified. + default_empty_subject = '<no subject>' + + # Define object templates + templates = ( + '{schema}://{apikey}:{from_email}', + '{schema}://{apikey}:{from_email}/{targets}', + ) + + # Define our template arguments + template_tokens = dict(NotifyBase.template_tokens, **{ + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + 'regex': (r'^[A-Z0-9._-]+$', 'i'), + }, + 'from_email': { + 'name': _('Source Email'), + 'type': 'string', + 'required': True, + }, + 'target_email': { + 'name': _('Target Email'), + 'type': 'string', + 'map_to': 'targets', + }, + 'targets': { + 'name': _('Targets'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'template': { + # Template ID + # The template ID is 64 characters with one dash (d-uuid) + 'name': _('Template'), + 'type': 'string', + }, + }) + + # Support Template Dynamic Variables (Substitutions) + template_kwargs = { + 'template_data': { + 'name': _('Template Data'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, from_email, targets=None, cc=None, + bcc=None, template=None, template_data=None, **kwargs): + """ + Initialize Notify SendGrid Object + """ + super().__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex( + apikey, *self.template_tokens['apikey']['regex']) + if not self.apikey: + msg = 'An invalid SendGrid API Key ' \ + '({}) was specified.'.format(apikey) + 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) + + # Store email address + self.from_email = result['full_email'] + + # Acquire Targets (To Emails) + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # Now our dynamic template (if defined) + self.template = template + + # Now our dynamic template data (if defined) + self.template_data = template_data \ + if isinstance(template_data, dict) else {} + + # Validate recipients (to:) and drop bad ones: + for recipient in parse_list(targets): + + result = is_email(recipient) + if result: + self.targets.append(result['full_email']) + continue + + self.logger.warning( + 'Dropped invalid email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_list(cc): + + result = is_email(recipient) + if result: + self.cc.add(result['full_email']) + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_list(bcc): + + result = is_email(recipient) + if result: + self.bcc.add(result['full_email']) + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + if len(self.targets) == 0: + # Notify ourselves + self.targets.append(self.from_email) + + return + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Our URL parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if len(self.cc) > 0: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join(self.cc) + + if len(self.bcc) > 0: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + if self.template: + # Handle our Template ID if if was specified + params['template'] = self.template + + # Append our template_data into our parameter list + params.update( + {'+{}'.format(k): v for k, v in self.template_data.items()}) + + # 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] == self.from_email) + + return '{schema}://{apikey}:{from_email}/{targets}?{params}'.format( + schema=self.secure_protocol, + apikey=self.pprint(self.apikey, privacy, safe=''), + # never encode email since it plays a huge role in our hostname + from_email=self.from_email, + targets='' if not has_targets else '/'.join( + [NotifySendGrid.quote(x, safe='') for x in self.targets]), + params=NotifySendGrid.urlencode(params), + ) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + + def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): + """ + Perform SendGrid Notification + """ + + headers = { + 'User-Agent': self.app_id, + 'Content-Type': 'application/json', + 'Authorization': 'Bearer {}'.format(self.apikey), + } + + # error tracking (used for function return) + has_error = False + + # A Simple Email Payload Template + _payload = { + 'personalizations': [{ + # Placeholder + 'to': [{'email': None}], + }], + 'from': { + 'email': self.from_email, + }, + # A subject is a requirement, so if none is specified we must + # set a default with at least 1 character or SendGrid will deny + # our request + 'subject': title if title else self.default_empty_subject, + 'content': [{ + 'type': 'text/plain' + if self.notify_format == NotifyFormat.TEXT else 'text/html', + 'value': body, + }], + } + + if self.template: + _payload['template_id'] = self.template + + if self.template_data: + _payload['personalizations'][0]['dynamic_template_data'] = \ + {k: v for k, v in self.template_data.items()} + + targets = list(self.targets) + while len(targets) > 0: + target = targets.pop(0) + + # Create a copy of our template + payload = _payload.copy() + + # the cc, bcc, to field must be unique or SendMail will fail, the + # below code prepares this by ensuring the target isn't in the cc + # list or bcc list. It also makes sure the cc list does not contain + # any of the bcc entries + cc = (self.cc - self.bcc - set([target])) + bcc = (self.bcc - set([target])) + + # Set our target + payload['personalizations'][0]['to'][0]['email'] = target + + if len(cc): + payload['personalizations'][0]['cc'] = \ + [{'email': email} for email in cc] + + if len(bcc): + payload['personalizations'][0]['bcc'] = \ + [{'email': email} for email in bcc] + + self.logger.debug('SendGrid POST URL: %s (cert_verify=%r)' % ( + self.notify_url, self.verify_certificate, + )) + self.logger.debug('SendGrid Payload: %s' % str(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + try: + r = requests.post( + self.notify_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 = \ + NotifySendGrid.http_response_code_lookup( + r.status_code, SENDGRID_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send SendGrid notification to {}: ' + '{}{}error={}.'.format( + target, + 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 SendGrid notification to {}.'.format(target)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SendGrid ' + 'notification to {}.'.format(target)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + @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) + if not results: + # We're done early as we couldn't load the results + return results + + # Our URL looks like this: + # {schema}://{apikey}:{from_email}/{targets} + # + # which actually equates to: + # {schema}://{user}:{password}@{host}/{email1}/{email2}/etc.. + # ^ ^ ^ + # | | | + # apikey -from addr- + + if not results.get('user'): + # An API Key as not properly specified + return None + + if not results.get('password'): + # A From Email was not correctly specified + return None + + # Prepare our API Key + results['apikey'] = NotifySendGrid.unquote(results['user']) + + # Prepare our From Email Address + results['from_email'] = '{}@{}'.format( + NotifySendGrid.unquote(results['password']), + NotifySendGrid.unquote(results['host']), + ) + + # Acquire our targets + results['targets'] = NotifySendGrid.split_path(results['fullpath']) + + # The 'to' makes it easier to use yaml configuration + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'] += \ + NotifySendGrid.parse_list(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = \ + NotifySendGrid.parse_list(results['qsd']['cc']) + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = \ + NotifySendGrid.parse_list(results['qsd']['bcc']) + + # Handle Blind Carbon Copy Addresses + if 'template' in results['qsd'] and len(results['qsd']['template']): + results['template'] = \ + NotifySendGrid.unquote(results['qsd']['template']) + + # Add any template substitutions + results['template_data'] = results['qsd+'] + + return results |