From 1cef49e19818a575566a3e5d56aae53b29097497 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 7 Apr 2024 14:09:32 -0400 Subject: [PATCH] Emails - parse `host` from user login if not specified (#1095) --- apprise/plugins/NotifyEmail.py | 40 +++++-- test/test_plugin_email.py | 197 ++++++++++++++++++++++++++++++++- 2 files changed, 225 insertions(+), 12 deletions(-) diff --git a/apprise/plugins/NotifyEmail.py b/apprise/plugins/NotifyEmail.py index e3ecde3f..80f88bf6 100644 --- a/apprise/plugins/NotifyEmail.py +++ b/apprise/plugins/NotifyEmail.py @@ -45,7 +45,7 @@ from .NotifyBase import NotifyBase from ..URLBase import PrivacyMode from ..common import NotifyFormat, NotifyType from ..conversion import convert_between -from ..utils import is_email, parse_emails +from ..utils import is_email, parse_emails, is_hostname from ..AppriseLocale import gettext_lazy as _ from ..logger import logger @@ -566,12 +566,20 @@ class NotifyEmail(NotifyBase): # Apply any defaults based on certain known configurations self.NotifyEmailDefaults(secure_mode=secure_mode, **kwargs) - if self.user and self.host: - # Prepare the bases of our email - self.from_addr = [self.app_id, '{}@{}'.format( - re.split(r'[\s@]+', self.user)[0], - self.host, - )] + if self.user: + if self.host: + # Prepare the bases of our email + self.from_addr = [self.app_id, '{}@{}'.format( + re.split(r'[\s@]+', self.user)[0], + self.host, + )] + + else: + result = is_email(self.user) + if result: + # Prepare the bases of our email and include domain + self.host = result['domain'] + self.from_addr = [self.app_id, self.user] if from_addr: result = is_email(from_addr) @@ -1037,11 +1045,25 @@ class NotifyEmail(NotifyBase): us to re-instantiate this object. """ - results = NotifyBase.parse_url(url) + results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results + # Prepare our target lists + results['targets'] = [] + + if not is_hostname(results['host'], ipv4=False, ipv6=False, + underscore=False): + + if is_email(NotifyEmail.unquote(results['host'])): + # Don't lose defined email addresses + results['targets'].append(NotifyEmail.unquote(results['host'])) + + # Detect if we have a valid hostname or not; be sure to reset it's + # value if invalid; we'll attempt to figure this out later on + results['host'] = '' + # The From address is a must; either through the use of templates # from= entry and/or merging the user and hostname together, this # must be calculated or parse_url will fail. @@ -1052,7 +1074,7 @@ class NotifyEmail(NotifyBase): # Get our potential email targets; if none our found we'll just # add one to ourselves - results['targets'] = NotifyEmail.split_path(results['fullpath']) + results['targets'] += NotifyEmail.split_path(results['fullpath']) # Attempt to detect 'to' email address if 'to' in results['qsd'] and len(results['qsd']['to']): diff --git a/test/test_plugin_email.py b/test/test_plugin_email.py index f1ddd87e..f34bad59 100644 --- a/test/test_plugin_email.py +++ b/test/test_plugin_email.py @@ -27,6 +27,7 @@ # POSSIBILITY OF SUCH DAMAGE. import logging +import pytest import os import re from unittest import mock @@ -56,13 +57,13 @@ TEST_URLS = ( # NotifyEmail ################################## ('mailto://', { - 'instance': None, + 'instance': TypeError, }), ('mailtos://', { - 'instance': None, + 'instance': TypeError, }), ('mailto://:@/', { - 'instance': None + 'instance': TypeError, }), # No Username ('mailtos://:pass@nuxref.com:567', { @@ -441,9 +442,12 @@ def test_plugin_email(mock_smtp, mock_smtpssl): except Exception as e: # Handle our exception if instance is None: + print('%s generated %s' % (url, str(e))) raise if not isinstance(e, instance): + print('%s Exception (expected %s); got %s' % ( + url, str(instance), str(e))) raise @@ -1815,3 +1819,190 @@ def test_plugin_email_variables_1087(): assert email.smtp_host == 'smtp.alt.lan' assert email.targets == [(False, 'alteriks@alt.lan')] assert email.password == 'abcd' + + +@mock.patch('smtplib.SMTP_SSL') +@mock.patch('smtplib.SMTP') +def test_plugin_host_detection_from_source_email(mock_smtp, mock_smtp_ssl): + """ + NotifyEmail() Discord Issue reporting that the following did not work: + mailtos://?smtp=mobile.charter.net&pass=password&user=name@spectrum.net + + """ + + response = mock.Mock() + mock_smtp_ssl.return_value = response + mock_smtp.return_value = response + + results = NotifyEmail.parse_url( + 'mailtos://spectrum.net?smtp=mobile.charter.net' + '&pass=password&user=name@spectrum.net') + + assert isinstance(results, dict) + assert 'name@spectrum.net' == results['user'] + assert 'spectrum.net' == results['host'] + assert 'mobile.charter.net' == results['smtp_host'] + assert 'password' == results['password'] + + obj = Apprise.instantiate(results, suppress_exceptions=False) + assert isinstance(obj, NotifyEmail) is True + + assert len(obj.targets) == 1 + assert (False, 'name@spectrum.net') in obj.targets + assert obj.from_addr[0] == obj.app_id + assert obj.from_addr[1] == 'name@spectrum.net' + assert obj.password == 'password' + assert obj.user == 'name@spectrum.net' + assert obj.secure is True + assert obj.port == 587 + assert obj.smtp_host == 'mobile.charter.net' + + assert mock_smtp.call_count == 0 + assert mock_smtp_ssl.call_count == 0 + assert obj.notify('body', 'title') is True + + assert mock_smtp.call_count == 1 + assert mock_smtp_ssl.call_count == 0 + assert response.starttls.call_count == 1 + assert response.login.call_count == 1 + assert response.sendmail.call_count == 1 + # Store our Sent Arguments + # Syntax is: + # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) + # [0] [1] [2] + _from = response.sendmail.call_args[0][0] + _to = response.sendmail.call_args[0][1] + _msg = response.sendmail.call_args[0][2] + assert _from == 'name@spectrum.net' + assert isinstance(_to, list) + assert len(_to) == 1 + assert _to[0] == 'name@spectrum.net' + assert _msg.split('\n')[-3] == 'body' + + # + # Now let's do a shortened version of the same URL where the host isn't + # specified but is parseable from he user login + # + mock_smtp.reset_mock() + mock_smtp_ssl.reset_mock() + response.reset_mock() + + results = NotifyEmail.parse_url( + 'mailtos://?smtp=mobile.charter.net' + '&pass=password&user=name@spectrum.net') + + assert isinstance(results, dict) + assert 'name@spectrum.net' == results['user'] + assert '' == results['host'] # No hostname defined; it's detected later + assert 'mobile.charter.net' == results['smtp_host'] + assert 'password' == results['password'] + + obj = Apprise.instantiate(results, suppress_exceptions=False) + assert isinstance(obj, NotifyEmail) is True + + assert len(obj.targets) == 1 + assert (False, 'name@spectrum.net') in obj.targets + assert obj.from_addr[0] == obj.app_id + assert obj.from_addr[1] == 'name@spectrum.net' + assert obj.password == 'password' + assert obj.user == 'name@spectrum.net' + assert obj.secure is True + assert obj.port == 587 + assert obj.smtp_host == 'mobile.charter.net' + + assert mock_smtp.call_count == 0 + assert mock_smtp_ssl.call_count == 0 + assert obj.notify('body', 'title') is True + + assert mock_smtp.call_count == 1 + assert mock_smtp_ssl.call_count == 0 + assert response.starttls.call_count == 1 + assert response.login.call_count == 1 + assert response.sendmail.call_count == 1 + # Store our Sent Arguments + # Syntax is: + # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) + # [0] [1] [2] + _from = response.sendmail.call_args[0][0] + _to = response.sendmail.call_args[0][1] + _msg = response.sendmail.call_args[0][2] + assert _from == 'name@spectrum.net' + assert isinstance(_to, list) + assert len(_to) == 1 + assert _to[0] == 'name@spectrum.net' + assert _msg.split('\n')[-3] == 'body' + + # + # Now let's do a shortened version of the same URL where the host isn't + # specified but is parseable from he user login + # + mock_smtp.reset_mock() + mock_smtp_ssl.reset_mock() + response.reset_mock() + + results = NotifyEmail.parse_url( + 'mailtos://?smtp=mobile.charter.net' + '&pass=password&user=userid-without-domain') + + assert isinstance(results, dict) + assert 'userid-without-domain' == results['user'] + assert '' == results['host'] # No hostname defined + assert 'mobile.charter.net' == results['smtp_host'] + assert 'password' == results['password'] + + with pytest.raises(TypeError): + # We will fail + Apprise.instantiate(results, suppress_exceptions=False) + + # + # Now support target emails in place of the hostname + # + + mock_smtp.reset_mock() + mock_smtp_ssl.reset_mock() + response.reset_mock() + + results = NotifyEmail.parse_url( + 'mailtos://John Doe?smtp=mobile.charter.net' + '&pass=password&user=name@spectrum.net') + + assert isinstance(results, dict) + assert 'name@spectrum.net' == results['user'] + assert '' == results['host'] # No hostname defined; it's detected later + assert 'mobile.charter.net' == results['smtp_host'] + assert 'password' == results['password'] + + obj = Apprise.instantiate(results, suppress_exceptions=False) + assert isinstance(obj, NotifyEmail) is True + + assert len(obj.targets) == 1 + assert ('John Doe', 'john@yahoo.ca') in obj.targets + assert obj.from_addr[0] == obj.app_id + assert obj.from_addr[1] == 'name@spectrum.net' + assert obj.password == 'password' + assert obj.user == 'name@spectrum.net' + assert obj.secure is True + assert obj.port == 587 + assert obj.smtp_host == 'mobile.charter.net' + + assert mock_smtp.call_count == 0 + assert mock_smtp_ssl.call_count == 0 + assert obj.notify('body', 'title') is True + + assert mock_smtp.call_count == 1 + assert mock_smtp_ssl.call_count == 0 + assert response.starttls.call_count == 1 + assert response.login.call_count == 1 + assert response.sendmail.call_count == 1 + # Store our Sent Arguments + # Syntax is: + # sendmail(from_addr, to_addrs, msg, mail_options=(), rcpt_options=()) + # [0] [1] [2] + _from = response.sendmail.call_args[0][0] + _to = response.sendmail.call_args[0][1] + _msg = response.sendmail.call_args[0][2] + assert _from == 'name@spectrum.net' + assert isinstance(_to, list) + assert len(_to) == 1 + assert _to[0] == 'john@yahoo.ca' + assert _msg.split('\n')[-3] == 'body'