diff --git a/CONTRIBUTIONS.md b/CONTRIBUTIONS.md index 014e66ab..0fd3a2be 100644 --- a/CONTRIBUTIONS.md +++ b/CONTRIBUTIONS.md @@ -22,10 +22,11 @@ The contributors have been listed in chronological order: * Hitesh Sondhi * Mar 2019 - Added Flock Support -* Andreas Motl +* Andreas Motl * 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 diff --git a/apprise/plugins/NotifyEmail.py b/apprise/plugins/NotifyEmail.py index dd29a780..00b2de8a 100644 --- a/apprise/plugins/NotifyEmail.py +++ b/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() - # Mark our failure - has_error = True + if self.user and self.password: + # Apply Login credetials + self.logger.debug('Applying user credentials...') + socket.login(self.user, self.password) - finally: - # Gracefully terminate the connection with the server - if socket is not None: # pragma: no branch - socket.quit() + # 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 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 diff --git a/requirements.txt b/requirements.txt index eda9d456..5fa813d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ certifi # Application dependencies. +dataclasses; python_version<"3.7" requests requests-oauthlib click >= 5.0 diff --git a/test/test_plugin_email.py b/test/test_plugin_email.py index 2eb36891..259f74e5 100644 --- a/test/test_plugin_email.py +++ b/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): """