diff options
Diffstat (limited to 'libs/apprise/plugins/NotifySMTP2Go.py')
-rw-r--r-- | libs/apprise/plugins/NotifySMTP2Go.py | 584 |
1 files changed, 584 insertions, 0 deletions
diff --git a/libs/apprise/plugins/NotifySMTP2Go.py b/libs/apprise/plugins/NotifySMTP2Go.py new file mode 100644 index 000000000..341ad8a62 --- /dev/null +++ b/libs/apprise/plugins/NotifySMTP2Go.py @@ -0,0 +1,584 @@ +# -*- coding: utf-8 -*- +# +# Copyright (C) 2021 Chris Caron <[email protected]> +# 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://smtp2go.com (free accounts available) +# +# From your dashboard, you can generate an API Key if you haven't already +# at https://app.smtp2go.com/settings/apikeys/ + +# The API Key from here which will look something like: +# api-60F0DD0AB5BA11ABA421F23C91C88EF4 +# +# Knowing this, you can buid your smtp2go url as follows: +# smtp2go://{user}@{domain}/{apikey} +# smtp2go://{user}@{domain}/{apikey}/{email} +# +# You can email as many addresses as you want as: +# smtp2go://{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 base64 +import requests +from json import dumps +from email.utils import formataddr +from .NotifyBase import NotifyBase +from ..common import NotifyType +from ..common import NotifyFormat +from ..utils import parse_emails +from ..utils import parse_bool +from ..utils import is_email +from ..utils import validate_regex +from ..AppriseLocale import gettext_lazy as _ + +SMTP2GO_HTTP_ERROR_MAP = { + 429: 'To many requests.', +} + + +class NotifySMTP2Go(NotifyBase): + """ + A wrapper for SMTP2Go Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'SMTP2Go' + + # The services URL + service_url = 'https://www.smtp2go.com/' + + # All notification requests are secure + secure_protocol = 'smtp2go' + + # SMTP2Go 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_smtp2go' + + # Notify URL + notify_url = 'https://api.smtp2go.com/v3/email/send' + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # The maximum amount of emails that can reside within a single + # batch transfer + default_batch_size = 100 + + # Define object templates + templates = ( + '{schema}://{user}@{host}:{apikey}/', + '{schema}://{user}@{host}:{apikey}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('User Name'), + 'type': 'string', + 'required': True, + }, + 'host': { + 'name': _('Domain'), + 'type': 'string', + 'required': True, + }, + 'apikey': { + 'name': _('API Key'), + 'type': 'string', + 'private': True, + 'required': True, + }, + 'targets': { + 'name': _('Target Emails'), + 'type': 'list:string', + }, + }) + + # Define our template arguments + template_args = dict(NotifyBase.template_args, **{ + 'name': { + 'name': _('From Name'), + 'type': 'string', + 'map_to': 'from_name', + }, + 'to': { + 'alias_of': 'targets', + }, + 'cc': { + 'name': _('Carbon Copy'), + 'type': 'list:string', + }, + 'bcc': { + 'name': _('Blind Carbon Copy'), + 'type': 'list:string', + }, + 'batch': { + 'name': _('Batch Mode'), + 'type': 'bool', + 'default': False, + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('Email Header'), + 'prefix': '+', + }, + } + + def __init__(self, apikey, targets, cc=None, bcc=None, from_name=None, + headers=None, batch=False, **kwargs): + """ + Initialize SMTP2Go Object + """ + super(NotifySMTP2Go, self).__init__(**kwargs) + + # API Key (associated with project) + self.apikey = validate_regex(apikey) + if not self.apikey: + msg = 'An invalid SMTP2Go API Key ' \ + '({}) was specified.'.format(apikey) + self.logger.warning(msg) + raise TypeError(msg) + + # Validate our username + if not self.user: + msg = 'No SMTP2Go username was specified.' + self.logger.warning(msg) + raise TypeError(msg) + + # Acquire Email 'To' + self.targets = list() + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # For tracking our email -> name lookups + self.names = {} + + self.headers = {} + if headers: + # Store our extra headers + self.headers.update(headers) + + # Prepare Batch Mode Flag + self.batch = batch + + # 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) + + if targets: + # Validate recipients (to:) and drop bad ones: + 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'])) + continue + + self.logger.warning( + 'Dropped invalid To email ' + '({}) specified.'.format(recipient), + ) + + else: + # If our target email list is empty we want to add ourselves to it + self.targets.append( + (self.from_name if self.from_name else False, self.from_addr)) + + # Validate recipients (cc:) and drop bad ones: + for recipient in parse_emails(cc): + email = is_email(recipient) + if email: + self.cc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + # Validate recipients (bcc:) and drop bad ones: + for recipient in parse_emails(bcc): + email = is_email(recipient) + if email: + self.bcc.add(email['full_email']) + + # Index our name (if one exists) + self.names[email['full_email']] = \ + email['name'] if email['name'] else False + continue + + self.logger.warning( + 'Dropped invalid Blind Carbon Copy email ' + '({}) specified.'.format(recipient), + ) + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform SMTP2Go Notification + """ + + if not self.targets: + # There is no one to email; we're done + self.logger.warning( + 'There are no Email recipients to notify') + return False + + # error tracking (used for function return) + has_error = False + + # Send in batches if identified to do so + batch_size = 1 if not self.batch else self.default_batch_size + + # Prepare our headers + headers = { + 'User-Agent': self.app_id, + 'Accept': 'application/json', + 'Content-Type': 'application/json', + } + + # Track our potential attachments + attachments = [] + + if attach: + for idx, attachment in enumerate(attach): + # Perform some simple error checking + if not attachment: + # We could not access the attachment + self.logger.error( + 'Could not access attachment {}.'.format( + attachment.url(privacy=True))) + return False + + try: + with open(attachment.path, 'rb') as f: + # Output must be in a DataURL format (that's what + # PushSafer calls it): + attachments.append({ + 'filename': attachment.name, + 'fileblob': base64.b64encode(f.read()) + .decode('utf-8'), + 'mimetype': attachment.mimetype, + }) + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading {}.'.format( + attachment.name if attachment else 'attachment')) + self.logger.debug('I/O Exception: %s' % str(e)) + return False + + try: + sender = formataddr( + (self.from_name if self.from_name else False, + self.from_addr), charset='utf-8') + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + sender = formataddr( + (self.from_name if self.from_name else False, + self.from_addr)) + + # Prepare our payload + payload = { + # API Key + 'api_key': self.apikey, + + # Base payload options + 'sender': sender, + 'subject': title, + + # our To array + 'to': [], + } + + if attachments: + payload['attachments'] = attachments + + if self.notify_format == NotifyFormat.HTML: + payload['html_body'] = body + + else: + payload['text_body'] = body + + # Create a copy of the targets list + emails = list(self.targets) + + for index in range(0, len(emails), batch_size): + # Initialize our cc list + cc = (self.cc - self.bcc) + + # Initialize our bcc list + bcc = set(self.bcc) + + # Initialize our to list + to = list() + + for to_addr in self.targets[index:index + batch_size]: + # Strip target out of cc list if in To + cc = (cc - set([to_addr[1]])) + + # Strip target out of bcc list if in To + bcc = (bcc - set([to_addr[1]])) + + try: + # Prepare our to + to.append(formataddr(to_addr, charset='utf-8')) + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + + # Prepare our to + to.append(formataddr(to_addr)) + + # Prepare our To + payload['to'] = to + + if cc: + try: + # Format our cc addresses to support the Name field + payload['cc'] = [formataddr( + (self.names.get(addr, False), addr), charset='utf-8') + for addr in cc] + + except TypeError: + # Python v2.x Support (no charset keyword) + # Format our cc addresses to support the Name field + payload['cc'] = [formataddr( # pragma: no branch + (self.names.get(addr, False), addr)) + for addr in cc] + + # Format our bcc addresses to support the Name field + if bcc: + # set our bcc variable (convert to list first so it's + # JSON serializable) + payload['bcc'] = list(bcc) + + # Store our header entries if defined into the payload + # in their payload + if self.headers: + payload['custom_headers'] = \ + [{'header': k, 'value': v} + for k, v in self.headers.items()] + + # Some Debug Logging + self.logger.debug('SMTP2Go POST URL: {} (cert_verify={})'.format( + self.notify_url, self.verify_certificate)) + self.logger.debug('SMTP2Go Payload: {}' .format(payload)) + + # For logging output of success and errors; we get a head count + # of our outbound details: + verbose_dest = ', '.join( + [x[1] for x in self.targets[index:index + batch_size]]) \ + if len(self.targets[index:index + batch_size]) <= 3 \ + else '{} recipients'.format( + len(self.targets[index:index + batch_size])) + + # 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 != requests.codes.ok: + # We had a problem + status_str = \ + NotifyBase.http_response_code_lookup( + r.status_code, SMTP2GO_HTTP_ERROR_MAP) + + self.logger.warning( + 'Failed to send SMTP2Go notification to {}: ' + '{}{}error={}.'.format( + verbose_dest, + 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 SMTP2Go notification to {}.'.format( + verbose_dest)) + + except requests.RequestException as e: + self.logger.warning( + 'A Connection error occurred sending SMTP2Go:%s ' % ( + verbose_dest) + 'notification.' + ) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + except (OSError, IOError) as e: + self.logger.warning( + 'An I/O error occurred while reading attachments') + self.logger.debug('I/O Exception: %s' % str(e)) + + # Mark our failure + has_error = True + continue + + return not has_error + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Define any URL parameters + params = { + 'batch': 'yes' if self.batch else 'no', + } + + # Append our headers into our parameters + params.update({'+{}'.format(k): v for k, v in self.headers.items()}) + + # Extend our parameters + params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) + + if self.from_name is not None: + # from_name specified; pass it back on the url + params['name'] = self.from_name + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not e not in self.names + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join(self.bcc) + + # 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][1] == self.from_addr) + + return '{schema}://{user}@{host}/{apikey}/{targets}?{params}'.format( + schema=self.secure_protocol, + host=self.host, + user=NotifySMTP2Go.quote(self.user, safe=''), + apikey=self.pprint(self.apikey, privacy, safe=''), + targets='' if not has_targets else '/'.join( + [NotifySMTP2Go.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='') for e in self.targets]), + params=NotifySMTP2Go.urlencode(params)) + + @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, verify_host=False) + 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'] = NotifySMTP2Go.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'] = \ + NotifySMTP2Go.unquote(results['qsd']['name']) + + # Handle 'to' email address + if 'to' in results['qsd'] and len(results['qsd']['to']): + results['targets'].append(results['qsd']['to']) + + # Handle Carbon Copy Addresses + if 'cc' in results['qsd'] and len(results['qsd']['cc']): + results['cc'] = results['qsd']['cc'] + + # Handle Blind Carbon Copy Addresses + if 'bcc' in results['qsd'] and len(results['qsd']['bcc']): + results['bcc'] = results['qsd']['bcc'] + + # Add our Meta Headers that the user can provide with their outbound + # emails + results['headers'] = {NotifyBase.unquote(x): NotifyBase.unquote(y) + for x, y in results['qsd+'].items()} + + # Get Batch Mode Flag + results['batch'] = \ + parse_bool(results['qsd'].get( + 'batch', NotifySMTP2Go.template_args['batch']['default'])) + + return results |