Browse Source

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.
pull/750/head
Andreas Motl 2 years ago committed by GitHub
parent
commit
e7255df1da
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      CONTRIBUTIONS.md
  2. 119
      apprise/plugins/NotifyEmail.py
  3. 1
      requirements.txt
  4. 32
      test/test_plugin_email.py

3
CONTRIBUTIONS.md

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

119
apprise/plugins/NotifyEmail.py

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

1
requirements.txt

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

32
test/test_plugin_email.py

@ -538,6 +538,38 @@ def test_plugin_email_smtplib_send_okay(mock_smtplib):
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')
def test_plugin_email_smtplib_internationalization(mock_smtp):
"""

Loading…
Cancel
Save