diff --git a/apprise/plugins/email.py b/apprise/plugins/email.py index 8651c08a8..742985e79 100644 --- a/apprise/plugins/email.py +++ b/apprise/plugins/email.py @@ -52,6 +52,8 @@ from ..conversion import convert_between from ..utils import is_ipaddr, is_email, parse_emails, is_hostname, parse_bool from ..locale import gettext_lazy as _ +from ..logger import logger +from ..exception import AppriseException try: import pgpy @@ -66,6 +68,14 @@ charset.add_charset('utf-8', charset.QP, charset.QP, 'utf-8') +class AppriseEmailException(AppriseException): + """ + Thrown when there is an error with the Email Attachment + """ + def __init__(self, message, error_code=601): + super().__init__(message, error_code=error_code) + + class WebBaseLogin: """ This class is just used in conjunction of the default emailers @@ -456,12 +466,6 @@ class NotifyEmail(NotifyBase): 'type': 'string', 'map_to': 'smtp_host', }, - 'pgp': { - 'name': _('PGP Encryption'), - 'type': 'bool', - 'map_to': 'use_pgp', - 'default': False, - }, 'mode': { 'name': _('Secure Mode'), 'type': 'choice:string', @@ -474,6 +478,12 @@ class NotifyEmail(NotifyBase): 'type': 'list:string', 'map_to': 'reply_to', }, + 'pgp': { + 'name': _('PGP Encryption'), + 'type': 'bool', + 'map_to': 'use_pgp', + 'default': False, + }, 'pgpkey': { 'name': _('PGP Public Key Path'), 'type': 'string', @@ -518,7 +528,9 @@ def __init__(self, smtp_host=None, from_addr=None, secure_mode=None, # For tracking our email -> name lookups self.names = {} - self.headers = {} + self.headers = { + 'X-Application': self.app_id, + } if headers: # Store our extra headers self.headers.update(headers) @@ -763,195 +775,14 @@ def NotifyEmailDefaults(self, secure_mode=None, port=None, **kwargs): break - def _get_charset(self, input_string): - """ - Get utf-8 charset if non ascii string only - - Encode an ascii string to utf-8 is bad for email deliverability - because some anti-spam gives a bad score for that - like SUBJ_EXCESS_QP flag on Rspamd - """ - if not input_string: - return None - return 'utf-8' if not all(ord(c) < 128 for c in input_string) else None - def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, **kwargs): - """ - Perform Email 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 - - elif self.use_pgp and not PGP_SUPPORT: - self.logger.warning('PGP Support unavailable') + logger.warning('There are no Email recipients to notify') return False - messages: t.List[EmailMessage] = [] - - # Create a copy of the targets list - emails = list(self.targets) - while len(emails): - # Get our email to notify - to_name, to_addr = emails.pop(0) - - # Strip target out of cc list if in To or Bcc - cc = (self.cc - self.bcc - set([to_addr])) - - # Strip target out of bcc list if in To - bcc = (self.bcc - set([to_addr])) - - # Strip target out of reply_to list if in To - reply_to = (self.reply_to - set([to_addr])) - - # Format our cc addresses to support the Name field - cc = [formataddr( - (self.names.get(addr, False), addr), charset='utf-8') - for addr in cc] - - # Format our bcc addresses to support the Name field - bcc = [formataddr( - (self.names.get(addr, False), addr), charset='utf-8') - for addr in bcc] - - if reply_to: - # Format our reply-to addresses to support the Name field - reply_to = [formataddr( - (self.names.get(addr, False), addr), charset='utf-8') - for addr in reply_to] - - self.logger.debug( - 'Email From: {}'.format( - formataddr(self.from_addr, charset='utf-8'))) - - self.logger.debug('Email To: {}'.format(to_addr)) - if cc: - self.logger.debug('Email Cc: {}'.format(', '.join(cc))) - if bcc: - self.logger.debug('Email Bcc: {}'.format(', '.join(bcc))) - if reply_to: - self.logger.debug( - 'Email Reply-To: {}'.format(', '.join(reply_to)) - ) - self.logger.debug('Login ID: {}'.format(self.user)) - self.logger.debug( - 'Delivery: {}:{}'.format(self.smtp_host, self.port)) - - # Prepare Email Message - if self.notify_format == NotifyFormat.HTML: - base = MIMEMultipart("alternative") - base.attach(MIMEText( - convert_between( - NotifyFormat.HTML, NotifyFormat.TEXT, body), - 'plain', 'utf-8') - ) - base.attach(MIMEText(body, 'html', 'utf-8')) - else: - base = MIMEText(body, 'plain', 'utf-8') - - if attach and self.attachment_support: - mixed = MIMEMultipart("mixed") - mixed.attach(base) - # Now store our attachments - for no, attachment in enumerate(attach, start=1): - if not attachment: - # We could not load the attachment; take an early - # exit since this isn't what the end user wanted - - # We could not access the attachment - self.logger.error( - 'Could not access attachment {}.'.format( - attachment.url(privacy=True))) - - return False - - self.logger.debug( - 'Preparing Email attachment {}'.format( - attachment.url(privacy=True))) - - with open(attachment.path, "rb") as abody: - app = MIMEApplication(abody.read()) - app.set_type(attachment.mimetype) - - # Prepare our attachment name - filename = attachment.name \ - if attachment.name else f'file{no:03}.dat' - - app.add_header( - 'Content-Disposition', - 'attachment; filename="{}"'.format( - Header(filename, 'utf-8')), - ) - mixed.attach(app) - base = mixed - - if self.use_pgp: - self.logger.debug("Securing email with PGP Encryption") - # Set our header information to include in the encryption - base['From'] = formataddr( - (None, self.from_addr[1]), charset='utf-8') - base['To'] = formataddr((None, to_addr), charset='utf-8') - base['Subject'] = Header(title, self._get_charset(title)) - - # Apply our encryption - encrypted_content = self.pgp_encrypt_message(base.as_string()) - if not encrypted_content: - self.logger.warning('Unable to PGP encrypt email') - # Unable to send notification - return False - - # prepare our messsage - base = MIMEMultipart( - "encrypted", protocol="application/pgp-encrypted") - - # Store Autocrypt header (DeltaChat Support) - base.add_header( - "Autocrypt", - "addr=%s; prefer-encrypt=mutual" % formataddr( - (False, to_addr), charset='utf-8')) - - # Set Encryption Info Part - enc_payload = MIMEText("Version: 1", "plain") - enc_payload.set_type("application/pgp-encrypted") - base.attach(enc_payload) - - enc_payload = MIMEBase("application", "octet-stream") - enc_payload.set_payload(encrypted_content) - base.attach(enc_payload) - - # Apply any provided custom headers - for k, v in self.headers.items(): - base[k] = Header(v, self._get_charset(v)) - - base['Subject'] = Header(title, self._get_charset(title)) - base['From'] = formataddr(self.from_addr, charset='utf-8') - base['To'] = formataddr((to_name, to_addr), charset='utf-8') - base['Message-ID'] = make_msgid(domain=self.smtp_host) - base['Date'] = \ - datetime.now(timezone.utc)\ - .strftime("%a, %d %b %Y %H:%M:%S +0000") - base['X-Application'] = self.app_id - - if cc: - base['Cc'] = ','.join(cc) - - if reply_to: - base['Reply-To'] = ','.join(reply_to) - - message = EmailMessage( - recipient=to_addr, - to_addrs=[to_addr] + list(cc) + list(bcc), - body=base.as_string()) - messages.append(message) - - return self.submit(messages) - - def submit(self, messages: t.List[EmailMessage]): - # error tracking (used for function return) has_error = False @@ -980,13 +811,21 @@ def submit(self, messages: t.List[EmailMessage]): self.logger.debug('Securing connection with STARTTLS...') socket.starttls() + self.logger.trace('Login ID: {}'.format(self.user)) if self.user and self.password: # Apply Login credetials self.logger.debug('Applying user credentials...') socket.login(self.user, self.password) - # Send the emails - for message in messages: + # Iterate over our email messages we can generate and then + # send them off. + for message in NotifyEmail.prepare_emails( + subject=title, body=body, notify_format=self.notify_format, + from_addr=self.from_addr, to=self.targets, + cc=self.cc, bcc=self.bcc, reply_to=self.reply_to, + smtp_host=self.smtp_host, + attach=attach, headers=self.headers, names=self.names, + pgp=self.use_pgp, pgp_path='TODO'): try: socket.sendmail( self.from_addr[1], @@ -1012,6 +851,12 @@ def submit(self, messages: t.List[EmailMessage]): # Mark as failure has_error = True + except AppriseEmailException as e: + self.logger.debug(f'Socket Exception: {e}') + + # Mark as failure + has_error = True + finally: # Gracefully terminate the connection with the server if socket is not None: @@ -1477,3 +1322,224 @@ def parse_url(url): for x, y in results['qsd+'].items()} return results + + @staticmethod + def _get_charset(input_string): + """ + Get utf-8 charset if non ascii string only + + Encode an ascii string to utf-8 is bad for email deliverability + because some anti-spam gives a bad score for that + like SUBJ_EXCESS_QP flag on Rspamd + """ + if not input_string: + return None + return 'utf-8' if not all(ord(c) < 128 for c in input_string) else None + + @staticmethod + def prepare_emails(subject, body, from_addr, to, + cc=None, bcc=None, reply_to=None, + # Providing an SMTP Host helps improve Email Message-ID + # and avoids getting flagged as spam + smtp_host=None, + notify_format=NotifyFormat.HTML, + attach=None, headers=None, names=None, + # Pretty Good Privacy Support + pgp=False, pgp_path=None): + """ + Generator for emails + from_addr: must be in format: (from_name, from_addr) + to: must be in the format: + [(to_name, to_addr), (to_name, to_addr)), ...] + cc: must be a set of email addresses + bcc: must be a set of email addresses + reply_to: must be either None, or an email address + smtp_host: This is used to generate the email's Message-ID. Set + this correctly to avoid getting flagged as Spam + notify_format: can be either 'text' or 'html' + attach: must be of class AppriseAttachment + headers: Optionally provide a dictionary of additional headers you + would like to include in the email payload + names: This is a dictionary of email addresses as keys and the + Names to associate with them when sending the email. + This is cross referenced for the cc and bcc lists + pgp: Encrypting the message using Pretty Good Privacy support + This requires that the pgp_path provided exists and + keys can be referenced here to perform the encryption + with. If a key isn't found, one will be generated. + + pgp support requires the 'PGPy' Python library to be + available. + pgp_path: The path file the PGP Keys can be found/generated in + if pgp is set to True + """ + + if not to: + # There is no one to email; we're done + logger.warning('There are no Email recipients to notify') + return + + elif pgp and not PGP_SUPPORT: + msg = 'PGP Support unavailable; install PGPy library' + logger.warning(msg) + raise AppriseEmailException(msg) + + if not names: + # Prepare a empty dictionary to prevent errors/warnings + names = {} + + if not smtp_host: + # Generate a host identifier (used for Message-ID Creation) + smtp_host = from_addr.split('@')[1] + logger.debug('SMTP Host: {smtp_host}') + + # Create a copy of the targets list + emails = list(to) + while len(emails): + # Get our email to notify + to_name, to_addr = emails.pop(0) + + # Strip target out of cc list if in To or Bcc + _cc = (cc - bcc - set([to_addr])) + + # Strip target out of bcc list if in To + _bcc = (bcc - set([to_addr])) + + # Strip target out of reply_to list if in To + _reply_to = (reply_to - set([to_addr])) + + # Format our cc addresses to support the Name field + _cc = [formataddr( + (names.get(addr, False), addr), charset='utf-8') + for addr in _cc] + + # Format our bcc addresses to support the Name field + _bcc = [formataddr( + (names.get(addr, False), addr), charset='utf-8') + for addr in _bcc] + + if reply_to: + # Format our reply-to addresses to support the Name field + reply_to = [formataddr( + (names.get(addr, False), addr), charset='utf-8') + for addr in reply_to] + + logger.debug( + 'Email From: {}'.format( + formataddr(from_addr, charset='utf-8'))) + + logger.debug('Email To: {}'.format(to_addr)) + if _cc: + logger.debug('Email Cc: {}'.format(', '.join(_cc))) + if _bcc: + logger.debug('Email Bcc: {}'.format(', '.join(_bcc))) + if _reply_to: + logger.debug( + 'Email Reply-To: {}'.format(', '.join(_reply_to)) + ) + + # Prepare Email Message + if notify_format == NotifyFormat.HTML: + base = MIMEMultipart("alternative") + base.attach(MIMEText( + convert_between( + NotifyFormat.HTML, NotifyFormat.TEXT, body), + 'plain', 'utf-8') + ) + base.attach(MIMEText(body, 'html', 'utf-8')) + else: + base = MIMEText(body, 'plain', 'utf-8') + + if attach: + mixed = MIMEMultipart("mixed") + mixed.attach(base) + # Now store our attachments + for no, attachment in enumerate(attach, start=1): + if not attachment: + # We could not load the attachment; take an early + # exit since this isn't what the end user wanted + + # We could not access the attachment + msg = 'Could not access attachment {}.'.format( + attachment.url(privacy=True)) + logger.error(msg) + raise AppriseEmailException(msg) + + logger.debug( + 'Preparing Email attachment {}'.format( + attachment.url(privacy=True))) + + with open(attachment.path, "rb") as abody: + app = MIMEApplication(abody.read()) + app.set_type(attachment.mimetype) + + # Prepare our attachment name + filename = attachment.name \ + if attachment.name else f'file{no:03}.dat' + + app.add_header( + 'Content-Disposition', + 'attachment; filename="{}"'.format( + Header(filename, 'utf-8')), + ) + mixed.attach(app) + base = mixed + + if pgp: + logger.debug("Securing Email with PGP Encryption") + # Set our header information to include in the encryption + base['From'] = formataddr( + (None, from_addr[1]), charset='utf-8') + base['To'] = formataddr((None, to_addr), charset='utf-8') + base['Subject'] = \ + Header(subject, NotifyEmail._get_charset(subject)) + + # # Apply our encryption + # encrypted_content = self.pgp_encrypt_message(base.as_string()) + # if not encrypted_content: + # self.logger.warning('Unable to PGP encrypt email') + # # Unable to send notification + # return False + + # # prepare our messsage + # base = MIMEMultipart( + # "encrypted", protocol="application/pgp-encrypted") + + # # Store Autocrypt header (DeltaChat Support) + # base.add_header( + # "Autocrypt", + # "addr=%s; prefer-encrypt=mutual" % formataddr( + # (False, to_addr), charset='utf-8')) + + # # Set Encryption Info Part + # enc_payload = MIMEText("Version: 1", "plain") + # enc_payload.set_type("application/pgp-encrypted") + # base.attach(enc_payload) + + # enc_payload = MIMEBase("application", "octet-stream") + # enc_payload.set_payload(encrypted_content) + # base.attach(enc_payload) + + # Apply any provided custom headers + for k, v in headers.items(): + base[k] = Header(v, NotifyEmail._get_charset(v)) + + base['Subject'] = \ + Header(subject, NotifyEmail._get_charset(subject)) + base['From'] = formataddr(from_addr, charset='utf-8') + base['To'] = formataddr((to_name, to_addr), charset='utf-8') + base['Message-ID'] = make_msgid(domain=smtp_host) + base['Date'] = \ + datetime.now(timezone.utc)\ + .strftime("%a, %d %b %Y %H:%M:%S +0000") + + if cc: + base['Cc'] = ','.join(_cc) + + if reply_to: + base['Reply-To'] = ','.join(_reply_to) + + yield EmailMessage( + recipient=to_addr, + to_addrs=[to_addr] + list(_cc) + list(_bcc), + body=base.as_string()) diff --git a/apprise/plugins/gmail.py b/apprise/plugins/gmail.py new file mode 100644 index 000000000..67d085dac --- /dev/null +++ b/apprise/plugins/gmail.py @@ -0,0 +1,663 @@ +# -*- coding: utf-8 -*- +# BSD 2-Clause License +# +# Apprise - Push Notification Library. +# Copyright (c) 2024, Chris Caron +# +# 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. +import requests +import base64 +import json +import time +from datetime import datetime +from datetime import timedelta +from .base import NotifyBase +from .. import exception +from ..url import PrivacyMode +from ..common import NotifyFormat +from ..common import NotifyType +from ..utils import is_email +from ..utils import parse_emails +from ..utils import validate_regex +from ..locale import gettext_lazy as _ +from ..common import PersistentStoreMode +from .email import NotifyEmail + + +class NotifyGMail(NotifyBase): + """ + A wrapper for GMail Notifications + """ + + # The default descriptive name associated with the Notification + service_name = 'GMail' + + # The services URL + service_url = 'https://mail.google.com/' + + # The default protocol + secure_protocol = 'gmail' + + # 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_gmail' + + # Google OAuth2 URLs + auth_url = "https://oauth2.googleapis.com/device/code" + token_url = "https://oauth2.googleapis.com/token" + send_url = "https://gmail.googleapis.com/gmail/v1/users/me/messages/send" + + # The maximum number of seconds we will wait for our token to be acquired + token_acquisition_timeout = 6.0 + + # Required Scope + scope = "https://www.googleapis.com/auth/gmail.send" + + # Support attachments + attachment_support = True + + # Our default is to no not use persistent storage beyond in-memory + # reference + storage_mode = PersistentStoreMode.AUTO + + # Default Notify Format + notify_format = NotifyFormat.HTML + + # Define object templates + templates = ( + # Send as user (only supported method) + '{schema}://{user}@{client_id}/{secret}/{targets}', + ) + + # Define our template tokens + template_tokens = dict(NotifyBase.template_tokens, **{ + 'user': { + 'name': _('Username'), + 'type': 'string', + 'required': True, + }, + 'client_id': { + 'name': _('Client ID'), + 'type': 'string', + 'required': True, + 'private': True, + 'regex': (r'^[a-z0-9-]+$', 'i'), + }, + 'secret': { + 'name': _('Client Secret'), + 'type': 'string', + 'private': True, + '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', + }, + 'oauth_id': { + 'alias_of': 'client_id', + }, + 'oauth_secret': { + 'alias_of': 'secret', + }, + 'from': { + 'name': _('From Email'), + 'type': 'string', + 'map_to': 'from_addr', + }, + 'pgp': { + 'name': _('PGP Encryption'), + 'type': 'bool', + 'map_to': 'use_pgp', + 'default': False, + }, + 'pgpkey': { + 'name': _('PGP Public Key Path'), + 'type': 'string', + 'private': True, + # By default persistent storage is referenced + 'default': '', + 'map_to': 'pgp_key', + }, + }) + + # Define any kwargs we're using + template_kwargs = { + 'headers': { + 'name': _('Email Header'), + 'prefix': '+', + }, + } + + def __init__(self, client_id, secret, targets=None, cc=None, bcc=None, + from_addr=None, headers=None, use_pgp=None, pgp_key=None, + *kwargs): + """ + Initialize GMail Object + """ + super().__init__(**kwargs) + + # Client Key (associated with generated OAuth2 Login) + if not self.user: + msg = 'An invalid GMail User ' \ + '({}) was specified.'.format(self.user) + self.logger.warning(msg) + raise TypeError(msg) + + # Client Key (associated with generated OAuth2 Login) + self.client_id = validate_regex( + client_id, *self.template_tokens['client_id']['regex']) + if not self.client_id: + msg = 'An invalid GMail Client OAuth2 ID ' \ + '({}) was specified.'.format(client_id) + self.logger.warning(msg) + raise TypeError(msg) + + # Client Secret (associated with generated OAuth2 Login) + self.secret = validate_regex(secret) + if not self.secret: + msg = 'An invalid GMail Client OAuth2 Secret ' \ + '({}) was specified.'.format(secret) + self.logger.warning(msg) + raise TypeError(msg) + + # For tracking our email -> name lookups + self.names = {} + + self.headers = { + 'X-Application': self.app_id, + } + if headers: + # Store our extra headers + self.headers.update(headers) + + # Acquire Carbon Copies + self.cc = set() + + # Acquire Blind Carbon Copies + self.bcc = set() + + # Parse our targets + self.targets = list() + + for recipient in parse_emails(targets): + # Validate recipients (to:) and drop bad ones: + result = is_email(recipient) + if result: + # Add our email to our target list + self.targets.append( + (result['name'] if result['name'] else False, + result['full_email'])) + continue + + self.logger.warning( + 'Dropped invalid To email ({}) specified.' + .format(recipient)) + + # 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), + ) + + # Our token is acquired upon a successful login + self.token = None + + # Presume that our token has expired 'now' + self.token_expiry = datetime.now() + + # Now we want to construct the To and From email + # addresses from the URL provided + self.from_addr = [False, ''] + + # pgp hash + self.pgp_public_keys = {} + + self.use_pgp = use_pgp if not None \ + else self.template_args['pgp']['default'] + + if from_addr: + result = is_email(from_addr) + if result: + self.from_addr = ( + result['name'] if result['name'] else False, + result['full_email']) + else: + # Only update the string but use the already detected info + self.from_addr[0] = from_addr + + else: # Default + self.from_addr[1] = f'{self.user}@gmail.com' + + result = is_email(self.from_addr[1]) + if not result: + # Parse Source domain based on from_addr + msg = 'Invalid ~From~ email specified: {}'.format( + '{} <{}>'.format(self.from_addr[0], self.from_addr[1]) + if self.from_addr[0] else '{}'.format(self.from_addr[1])) + self.logger.warning(msg) + raise TypeError(msg) + + # Store our lookup + self.names[self.from_addr[1]] = self.from_addr[0] + return + + def send(self, body, title='', notify_type=NotifyType.INFO, attach=None, + **kwargs): + """ + Perform GMail Notification + """ + + # error tracking (used for function return) + has_error = False + + 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 + + try: + for message in NotifyEmail.prepare_emails( + subject=title, body=body, notify_format=self.notify_format, + from_addr=self.from_addr, to=self.targets, + cc=self.cc, bcc=self.bcc, reply_to=self.reply_to, + smtp_host=self.smtp_host, + attach=attach, headers=self.headers, names=self.names, + pgp=self.use_pgp, pgp_path='TODO'): + + # Encode the message in base64 + payload = { + "raw": base64.urlsafe_b64encode( + message.as_bytes()).decode() + } + + # Perform upstream post + postokay, response = self._fetch( + url=self.send_url, payload=payload) + if not postokay: + has_error = True + + except exception.AppriseException as e: + self.logger.debug(f'Socket Exception: {e}') + + # Mark as failure + has_error = True + + return not has_error + + def authenticate(self): + """ + Logs into and acquires us an authentication token to work with + """ + + if self.token and self.token_expiry > datetime.now(): + # If we're already authenticated and our token is still valid + self.logger.debug( + 'Already authenticate with token {}'.format(self.token)) + return True + + # If we reach here, we've either expired, or we need to authenticate + # for the first time. + + # Prepare our payload + payload = { + "client_id": self.client_id, + "scope": self.scope, + } + + postokay, response = self._fetch( + url=self.auth_url, payload=payload, + content_type='application/x-www-form-urlencoded') + if not postokay: + return False + + # Reset our token + self.token = None + + # A device token is required to get our token + device_code = None + + try: + # Extract our time from our response and subtrace 10 seconds from + # it to give us some wiggle/grace people to re-authenticate if we + # need to + self.token_expiry = datetime.now() + \ + timedelta(seconds=int(response.get('expires_in')) - 10) + + except (ValueError, AttributeError, TypeError): + # ValueError: expires_in wasn't an integer + # TypeError: expires_in was None + # AttributeError: we could not extract anything from our response + # object. + return False + + # Go ahead and store our token if it's available + device_code = response.get('device_code') + + payload = { + "client_id": self.client_id, + "client_secret": self.secret, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + self.logger.debug( + 'Blocking until GMail token can be acquired ...') + + reference = datetime.now() + + while True: + postokay, response = self._fetch( + url=self.token_url, payload=payload) + + if postokay: + self.token = response.get("access_token") + break + + if response and response.get("error") == "authorization_pending": + # Our own throttle so we can abort eventually.... + elapsed = (datetime.now() - reference).total_seconds() + if elapsed >= self.token_acquisition_timeout: + self.logger.warning( + 'The GMail token could not be acquired') + break + + time.sleep(0.5) + continue + + # We failed + break + + # Return our success (if we were at all) + return True if self.token else False + + def _fetch(self, url, payload=None, headers=None, + content_type='application/json'): + """ + Wrapper to request object + + """ + + # Prepare our headers: + if not headers: + headers = { + 'User-Agent': self.app_id, + 'Content-Type': content_type, + } + + if self.token: + # Are we authenticated? + headers['Authorization'] = 'Bearer ' + self.token + + # Default content response object + content = {} + + # Some Debug Logging + self.logger.debug('GMail URL: {} (cert_verify={})'.format( + url, self.verify_certificate)) + self.logger.debug('GMail Payload: {}' .format(payload)) + + # Always call throttle before any remote server i/o is made + self.throttle() + + try: + r = requests.post( + url, + data=json.dumps(payload) + if content_type and content_type.endswith('/json') + else payload, + headers=headers, + verify=self.verify_certificate, + timeout=self.request_timeout, + ) + + if r.status_code not in ( + requests.codes.ok, requests.codes.created, + requests.codes.accepted): + + # We had a problem + status_str = \ + NotifyGMail.http_response_code_lookup(r.status_code) + + self.logger.warning( + 'Failed to send GMail to {}: ' + '{}error={}.'.format( + url, + ', ' if status_str else '', + r.status_code)) + + self.logger.debug( + 'Response Details:\r\n{}'.format(r.content)) + + # Mark our failure + return (False, content) + + try: + content = json.loads(r.content) + + except (AttributeError, TypeError, ValueError): + # ValueError = r.content is Unparsable + # TypeError = r.content is None + # AttributeError = r is None + content = {} + + except requests.RequestException as e: + self.logger.warning( + 'Exception received when sending GMail to {}: '. + format(url)) + self.logger.debug('Socket Exception: %s' % str(e)) + + # Mark our failure + return (False, content) + + return (True, content) + + @property + def url_identifier(self): + """ + Returns all of the identifiers that make this URL unique from + another simliar one. Targets or end points should never be identified + here. + """ + return (self.secure_protocol, self.user, self.client_id, self.secret) + + def url(self, privacy=False, *args, **kwargs): + """ + Returns the URL built dynamically based on specified arguments. + """ + + # Extend our parameters + params = self.url_parameters(privacy=privacy, *args, **kwargs) + + if self.cc: + # Handle our Carbon Copy Addresses + params['cc'] = ','.join( + ['{}{}'.format( + '' if not self.names.get(e) + else '{}:'.format(self.names[e]), e) for e in self.cc]) + + if self.bcc: + # Handle our Blind Carbon Copy Addresses + params['bcc'] = ','.join( + ['{}{}'.format( + '' if not self.names.get(e) + else '{}:'.format(self.names[e]), e) for e in self.bcc]) + + return '{schema}://{user}@{client_id}/{secret}' \ + '/{targets}/?{params}'.format( + schema=self.secure_protocol, + user=self.user, + client_id=self.pprint(self.client_id, privacy, safe=''), + secret=self.pprint( + self.secret, privacy, mode=PrivacyMode.Secret, + safe=''), + targets='/'.join( + [NotifyGMail.quote('{}{}'.format( + '' if not e[0] else '{}:'.format(e[0]), e[1]), + safe='@') for e in self.targets]), + params=NotifyGMail.urlencode(params)) + + def __len__(self): + """ + Returns the number of targets associated with this notification + """ + return len(self.targets) + + @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 + + # Now make a list of all our path entries + # We need to read each entry back one at a time in reverse order + # where each email found we mark as a target. Once we run out + # of targets, the presume the remainder of the entries are part + # of the secret key (since it can contain slashes in it) + entries = NotifyGMail.split_path(results['fullpath']) + + # Initialize our email + results['email'] = None + + # From Email + if 'from' in results['qsd'] and \ + len(results['qsd']['from']): + # Extract the sending account's information + results['source'] = \ + NotifyGMail.unquote(results['qsd']['from']) + + # OAuth2 ID + if 'oauth_id' in results['qsd'] and len(results['qsd']['oauth_id']): + # Extract the API Key from an argument + results['client_id'] = \ + NotifyGMail.unquote(results['qsd']['oauth_id']) + + elif entries: + # Get our client_id is the first entry on the path + results['client_id'] = NotifyGMail.unquote(entries.pop(0)) + + # + # Prepare our target listing + # + results['targets'] = list() + while entries: + # Pop the last entry + entry = NotifyGMail.unquote(entries.pop(-1)) + + if is_email(entry): + # Store our email and move on + results['targets'].append(entry) + continue + + # If we reach here, the entry we just popped is part of the secret + # key, so put it back + entries.append(NotifyGMail.quote(entry, safe='')) + + # We're done + break + + # OAuth2 Secret + if 'oauth_secret' in results['qsd'] and \ + len(results['qsd']['oauth_secret']): + # Extract the API Secret from an argument + results['secret'] = \ + NotifyGMail.unquote(results['qsd']['oauth_secret']) + + else: + # Assemble our secret key which is a combination of the host + # followed by all entries in the full path that follow up until + # the first email + results['secret'] = '/'.join( + [NotifyGMail.unquote(x) for x in entries]) + + # 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'] += \ + NotifyGMail.parse_list(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'] + + return results