Improve efficiency of NotifyEmail plugin (#679)

When addressing multiple recipients, use the same session to the SMTP
server as designated with the Apprise URL. In this way, subsequent full
roundtrips will be saved.

As many SMTP servers are employing connection rate limiting, as well as
connection accept delays, this will considerably improve both robustness
and performance.
This commit is contained in:
Andreas Motl 2022-11-03 11:26:46 -07:00 committed by GitHub
parent b64e1b7ce0
commit e7255df1da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 109 additions and 46 deletions

View File

@ -22,10 +22,11 @@ The contributors have been listed in chronological order:
* Hitesh Sondhi <hitesh@cropsly.com> * Hitesh Sondhi <hitesh@cropsly.com>
* Mar 2019 - Added Flock Support * Mar 2019 - Added Flock Support
* Andreas Motl <andreas@getkotori.org> * Andreas Motl <andreas.motl@panodata.org>
* Mar 2020 - Fix XMPP Support * Mar 2020 - Fix XMPP Support
* Oct 2022 - Drop support for Python 2 * Oct 2022 - Drop support for Python 2
* Oct 2022 - Add support for Python 3.11 * Oct 2022 - Add support for Python 3.11
* Oct 2022 - Improve efficiency of NotifyEmail
* Joey Espinosa <@particledecay> * Joey Espinosa <@particledecay>
* Apr 3rd 2022 - Added Ntfy Support * Apr 3rd 2022 - Added Ntfy Support

View File

@ -23,8 +23,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE. # THE SOFTWARE.
import dataclasses
import re import re
import smtplib import smtplib
import typing as t
from email.mime.text import MIMEText from email.mime.text import MIMEText
from email.mime.application import MIMEApplication from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart from email.mime.multipart import MIMEMultipart
@ -282,6 +284,13 @@ EMAIL_TEMPLATES = (
) )
@dataclasses.dataclass
class EmailMessage:
recipient: str
to_addrs: t.List[str]
body: str
class NotifyEmail(NotifyBase): class NotifyEmail(NotifyBase):
""" """
A wrapper to Email Notifications A wrapper to Email Notifications
@ -659,15 +668,14 @@ class NotifyEmail(NotifyBase):
# Initialize our default from name # Initialize our default from name
from_name = self.from_name if self.from_name else self.app_desc from_name = self.from_name if self.from_name else self.app_desc
# error tracking (used for function return)
has_error = False
if not self.targets: if not self.targets:
# There is no one to email; we're done # There is no one to email; we're done
self.logger.warning( self.logger.warning(
'There are no Email recipients to notify') 'There are no Email recipients to notify')
return False return False
messages: t.List[EmailMessage] = []
# Create a copy of the targets list # Create a copy of the targets list
emails = list(self.targets) emails = list(self.targets)
while len(emails): while len(emails):
@ -778,58 +786,79 @@ class NotifyEmail(NotifyBase):
if reply_to: if reply_to:
base['Reply-To'] = ','.join(reply_to) base['Reply-To'] = ','.join(reply_to)
# bind the socket variable to the current namespace message = EmailMessage(
socket = None recipient=to_addr,
to_addrs=[to_addr] + list(cc) + list(bcc),
body=base.as_string())
messages.append(message)
# Always call throttle before any remote server i/o is made return self.submit(messages)
self.throttle()
try: def submit(self, messages: t.List[EmailMessage]):
self.logger.debug('Connecting to remote SMTP server...')
socket_func = smtplib.SMTP
if self.secure and self.secure_mode == SecureMailMode.SSL:
self.logger.debug('Securing connection with SSL...')
socket_func = smtplib.SMTP_SSL
socket = socket_func( # error tracking (used for function return)
self.smtp_host, has_error = False
self.port,
None,
timeout=self.socket_connect_timeout,
)
if self.secure and self.secure_mode == SecureMailMode.STARTTLS: # bind the socket variable to the current namespace
# Handle Secure Connections socket = None
self.logger.debug('Securing connection with STARTTLS...')
socket.starttls()
if self.user and self.password: # Always call throttle before any remote server i/o is made
# Apply Login credetials self.throttle()
self.logger.debug('Applying user credentials...')
socket.login(self.user, self.password)
# Send the email try:
socket.sendmail( self.logger.debug('Connecting to remote SMTP server...')
self.from_addr, socket_func = smtplib.SMTP
[to_addr] + list(cc) + list(bcc), if self.secure and self.secure_mode == SecureMailMode.SSL:
base.as_string()) self.logger.debug('Securing connection with SSL...')
socket_func = smtplib.SMTP_SSL
self.logger.info( socket = socket_func(
'Sent Email notification to "{}".'.format(to_addr)) self.smtp_host,
self.port,
None,
timeout=self.socket_connect_timeout,
)
except (SocketError, smtplib.SMTPException, RuntimeError) as e: if self.secure and self.secure_mode == SecureMailMode.STARTTLS:
self.logger.warning( # Handle Secure Connections
'A Connection error occurred sending Email ' self.logger.debug('Securing connection with STARTTLS...')
'notification to {}.'.format(self.smtp_host)) socket.starttls()
self.logger.debug('Socket Exception: %s' % str(e))
# Mark our failure if self.user and self.password:
has_error = True # Apply Login credetials
self.logger.debug('Applying user credentials...')
socket.login(self.user, self.password)
finally: # Send the emails
# Gracefully terminate the connection with the server for message in messages:
if socket is not None: # pragma: no branch try:
socket.quit() socket.sendmail(
self.from_addr,
message.to_addrs,
message.body)
self.logger.info(
f'Sent Email notification to "{message.recipient}".')
except (SocketError, smtplib.SMTPException, RuntimeError) as e:
self.logger.warning(
f'Sending email to "{message.recipient}" failed. '
f'Reason: {e}')
# Mark as failure
has_error = True
except (SocketError, smtplib.SMTPException, RuntimeError) as e:
self.logger.warning(
f'Connection error while submitting email to {self.smtp_host}.'
f' Reason: {e}')
# Mark as failure
has_error = True
finally:
# Gracefully terminate the connection with the server
if socket is not None: # pragma: no branch
socket.quit()
return not has_error return not has_error

View File

@ -2,6 +2,7 @@
certifi certifi
# Application dependencies. # Application dependencies.
dataclasses; python_version<"3.7"
requests requests
requests-oauthlib requests-oauthlib
click >= 5.0 click >= 5.0

View File

@ -538,6 +538,38 @@ def test_plugin_email_smtplib_send_okay(mock_smtplib):
AttachBase.max_file_size = max_file_size AttachBase.max_file_size = max_file_size
@mock.patch('smtplib.SMTP')
def test_plugin_email_smtplib_send_multiple_recipients(mock_smtplib):
"""
Verify that NotifyEmail() will use a single SMTP session for submitting
multiple emails.
"""
# Defaults to HTML
obj = Apprise.instantiate(
'mailto://user:pass@mail.example.org?'
'to=foo@example.net,bar@example.com&'
'cc=baz@example.org&bcc=qux@example.org', suppress_exceptions=False)
assert isinstance(obj, NotifyEmail)
assert obj.notify(
body='body', title='test', notify_type=NotifyType.INFO) is True
assert mock_smtplib.mock_calls == [
mock.call('mail.example.org', 25, None, timeout=15),
mock.call().login('user', 'pass'),
mock.call().sendmail(
'user@mail.example.org',
['foo@example.net', 'baz@example.org', 'qux@example.org'],
mock.ANY),
mock.call().sendmail(
'user@mail.example.org',
['bar@example.com', 'baz@example.org', 'qux@example.org'],
mock.ANY),
mock.call().quit(),
]
@mock.patch('smtplib.SMTP') @mock.patch('smtplib.SMTP')
def test_plugin_email_smtplib_internationalization(mock_smtp): def test_plugin_email_smtplib_internationalization(mock_smtp):
""" """