mirror of https://github.com/caronc/apprise
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
parent
b64e1b7ce0
commit
e7255df1da
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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):
|
||||||
"""
|
"""
|
||||||
|
|
Loading…
Reference in New Issue