Source code for heimdallsword.dispatcher.client

# -*- coding: utf-8 -*-
#
# Copyright (c) 2022 rwprimitives
# Author: eldiablo <avsarria@gmail.com>
#

"""
Client Module
=============

This module is a thread-safe email client.
"""

# standard modules
import poplib
import smtplib
import socket
import threading
import time
from datetime import datetime
from email import parser
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import make_msgid

# internal modules
from heimdallsword.data.config import Config
from heimdallsword.models.recipient import DeliveryState


[docs]class EmailClient: """ The :py:class:`heimdallsword.dispatcher.client.EmailClient` class serves as an email client used to manage an SMTP connection and send emails to any given recipients. This is a thread-safe class. :param sender: a reference to a :py:class:`heimdallsword.models.sender.Sender` object that contains the email address and password to establish a connection with it's SMTP server which will be used to send emails to any recipient :type: :py:obj:`heimdallsword.models.sender.Sender` :param metrics_delay: the number of seconds to wait between sending an email and checking the sender inbox for bounced emails :type: int """ def __init__(self, sender, metrics_delay=Config.DEFAULT_METRICS_DELAY): self._lock = threading.Lock() self._connection_state = False self._smtp_session = None self.sender = sender self.metrics_delay = metrics_delay def _establish_smtp_connection(self): """ Internal method that attempts to establish a connection between the sender and it's SMTP server, and saves the SMTP session for sending emails. This method is thread-safe. **CAUTION:** Several exceptions are thrown when attempting to establish connection with an SMTP server. The :py:class:`smtplib.SMTP` class throws the following exceptions: +---------------------------------+-------------------------------------------------------------+ | socket.gaierror | thrown when there is no network connection | +---------------------------------+-------------------------------------------------------------+ | TimeoutError | thrown when it took too long to establish a connection | +---------------------------------+-------------------------------------------------------------+ | ConnectionRefusedError | thrown when the SMTP server denied connection | +---------------------------------+-------------------------------------------------------------+ | RuntimeError | thrown by starttls(). | | | | | | *starttls() throws this exception when no SSL support | | | is included in this Python version | +---------------------------------+-------------------------------------------------------------+ | ValueError | thrown by starttls(). | | | | | | *starttls() throws this exception when context and | | | keyfile arguments are mutually exclusive. Also, when | | | context and certfile arguments are mutually exclusive | +---------------------------------+-------------------------------------------------------------+ | smtplib.SMTPHeloError | thrown by login() and starttls() when the server didn't | | | reply properly to the helo greeting | +---------------------------------+-------------------------------------------------------------+ | smtplib.SMTPAuthenticationError | thrown by login() when the server didn't accept the | | | username/password combination | +---------------------------------+-------------------------------------------------------------+ | smtplib.SMTPNotSupportedError | thrown by starttls() and login(). | | | | | | *starttls() thrown this exception when the SMTP server | | | doesn't support STARTTLS extension | | | *login() throws this exception when the AUTH command | | | is not supported by the server | +---------------------------------+-------------------------------------------------------------+ | smtplib.SMTPException | thrown by login() when no suitable authentication | | | method was found | +---------------------------------+-------------------------------------------------------------+ | smtplib.SMTPResponseException | thrown by starttls() due to the following errors RFC 3207: | | | | | | *501: syntax error (no parameters allowed) | | | *454: TLS not available due to temporary reason | +---------------------------------+-------------------------------------------------------------+ | smtplib.SMTPConnectError | thrown by SMTP constructor when it fails to connect to | | | the SMTP server | +---------------------------------+-------------------------------------------------------------+ | smtplib.SMTPServerDisconnected | thrown by login() when the connection to the SMTP server | | |is closed unexpectedly | +---------------------------------+-------------------------------------------------------------+ """ # Terminate any existing connection self.terminate_session() smtp_session = smtplib.SMTP(host=self.sender.get_smtp_url(), port=self.sender.get_smtp_port()) smtp_session.set_debuglevel(0) smtp_session.ehlo() smtp_session.starttls() smtp_session.login(self.sender.get_email(), self.sender.get_password()) self.acquire_lock() self._smtp_session = smtp_session self._connection_state = True self.release_lock()
[docs] def acquire_lock(self): """ Acquires the module-level I/O thread lock. """ if self._lock: self._lock.acquire()
[docs] def release_lock(self): """ Releases the module-level I/O thread lock. """ if self._lock: self._lock.release()
[docs] def get_lock(self): """ Get a reference to the module-level I/O thread lock. :returns: A reference to the lock :rtype: :py:obj:`threading.Lock` """ return self._lock
[docs] def test_connection(self): """ Attempts to establish a connection between the sender and it's SMTP server. This method is thread-safe. :returns: True on successful connection, False otherwise :rtype: bool """ try: self._establish_smtp_connection() except Exception: return False else: self.terminate_session() return True
[docs] def terminate_session(self): """ Closes an existing SMTP connection. This method is thread-safe. **NOTE:** This method will catch :py:class:`smtplib.SMTPServerDisconnected` exception that is thrown when calling :py:meth:`SMTP.quit()` and it is ignored as it has no effect to the process if the session couldn't be terminated. """ with self.get_lock(): if self._smtp_session: try: self._connection_state = False self._smtp_session.quit() except smtplib.SMTPServerDisconnected: # # NOTE: # It is frown upon to simply pass when catching an exception, # however, if this exception is thrown then there is nothing # more that can be done and we must treat this connection as # dead. This could happen if internet connection dropped, or # the connection timed out. Regardless, a new seesion neets # to be reestalished. # pass finally: self._smtp_session = None
[docs] def is_connection_active(self): """ Checks to see if the connection with the SMTP server is still alive by sending an SMTP 'noop' command which doesn't do anything. This method is thread-safe. :returns: True on successful connection, False otherwise :rtype: bool """ with self.get_lock(): status = -1 if self._smtp_session: status = self._smtp_session.noop()[0] return True if status == 250 else False
[docs] def send(self, recipient): """ Attempts to send an email to a given `recipient`. This method rethrows any exception thrown by the :py:mod:`smtplib.SMTP` module. This method is thread-safe. When an exception is caught, any error codes generated by the SMTP server are stored in the :py:attr:`heimdallsword.models.recipient.Recipient.delivery_error_code` attribute, error messages are stored in the :py:attr:`heimdallsword.models.recipient.Recipient.delivery_error_message` attribute, and the :py:attr:`heimdallsword.models.recipient.Recipient.delivery_state` attribute is set with a constant type from :py:class:`heimdallsword.models.recipient.DeliveryState` class. This method returns :py:const:`heimdallsword.models.recipient.DeliveryState.SUCCESSFUL_DELIVERY`, otherwise it returns any of the other type of failed constants to describe as much as possible why it failed to send the email. :param recipient: the recipient object :type: :py:obj:`heimdallsword.models.recipient.Recipient` :returns: :py:const:`heimdallsword.models.recipient.DeliveryState.SUCCESSFUL_DELIVERY` on successful delivery of an email :rtype: :py:obj:`heimdallsword.models.recipient.DeliveryState` """ exception = None status = DeliveryState.NOT_DELIVERED timestamp = datetime.now().timestamp() if not recipient: raise IOError("No recipient was provided") if not self._connection_state: try: self._establish_smtp_connection() except Exception as e: self.release_lock() recipient.set_delivery_error_code(0) recipient.set_delivery_error_message(str(e)) recipient.set_delivery_state(DeliveryState.DISCONNECTED) recipient.set_sent_timestamp(timestamp) raise e msg_id = make_msgid(domain=recipient.get_email_domain()) message = recipient.get_message() msg = MIMEMultipart() msg["From"] = self.sender.get_email() msg["To"] = recipient.get_email() msg["Subject"] = message.get_subject() msg.add_header("Message-ID", msg_id) msg.attach(MIMEText(message.get_body(), message.get_content_type())) # Store the message id value generated to track email delivery status if no # exception is thrown recipient.set_msg_id(msg_id) # Update the timestamp again to reflect an accurate time of when the email was sent timestamp = datetime.now().timestamp() try: # Record the epoch time right before the email is sent recipient.set_sending_timestamp(timestamp) self.acquire_lock() failed_recipients = self._smtp_session.send_message(msg) # Record the epoch time when the email was sent recipient.set_sent_timestamp(timestamp) if len(failed_recipients) > 0: # # NOTE: # Since we are only sending one recipient, we should only expect a # dictionary of size one which would contain the recipient email address # and the error code with message. In the future, if we decide to support # multiple recipients, then we need to iterate through the dictionary # of failed recipients. For now, this is fine. # recipient.set_delivery_error_code(failed_recipients[recipient.get_email()][0]) recipient.set_delivery_error_message(failed_recipients[recipient.get_email()][1]) recipient.set_delivery_state(DeliveryState.FAILED_DELIVERY) else: recipient.set_delivery_state(DeliveryState.SUCCESSFUL_DELIVERY) # Wait up to whatever time is set for the metrics delay. This is needed # in order to give it time for the server to report an email bounced time.sleep(self.metrics_delay) # Check if there are any emails that bounced by searching for the msg_id self._get_bounced_emails(recipient) # If the delivery state changed to anything but SUCCESSFUL_DELIVERY, then # generate an exception and pass the error message retrieved if recipient.get_delivery_state(): exception = Exception(recipient.get_delivery_error_message()) except smtplib.SMTPSenderRefused as e: # # NOTE: # This exception is thrown when the server didn't accept the sender email. # This exception inherits from the base class SMTPResponseException # exception = e recipient.set_delivery_error_code(e.smtp_code) recipient.set_delivery_error_message(e.smtp_error.decode("utf-8")) recipient.set_delivery_state(DeliveryState.SENDER_REJECTED) except smtplib.SMTPRecipientsRefused as e: # # NOTE: # This exception is thrown when recipient address rejected; User unknown. # exception = e recipient.set_delivery_error_code(550) recipient.set_delivery_error_message(str(e)) recipient.set_delivery_state(DeliveryState.RECIPIENT_REJECTED) except smtplib.SMTPResponseException as e: # # NOTE: # SMTPResponseException is the base class for the following exceptions: # SMTPHeloError # SMTPSenderRefused (shouldn't be captured here as it's captured above) # SMTPDataError # # Using the base class allows us to catch other exceptions that inherit # from the base class making it simpler to just get the SMTP error code # and message. # exception = e recipient.set_delivery_error_code(e.smtp_code) recipient.set_delivery_error_message(e.smtp_error.decode("utf-8")) recipient.set_delivery_state(DeliveryState.FAILED_DELIVERY) except smtplib.SMTPNotSupportedError as e: # # NOTE: # This exception is thrown when the mail_options parameter includes 'SMTPUTF8' # but the SMTPUTF8 extension is not supported by the server. # exception = e recipient.set_delivery_error_code(0) recipient.set_delivery_error_message(str(e)) recipient.set_delivery_state(DeliveryState.INVALID_FORMAT) except ValueError as e: # # NOTE: # This exception is thrown when the message has more than one 'Resent-' # header block. # exception = e recipient.set_delivery_error_code(0) recipient.set_delivery_error_message(str(e)) recipient.set_delivery_state(DeliveryState.INVALID_FORMAT) except smtplib.SMTPServerDisconnected as e: # # NOTE: # This exception is thrown when the SMTP connection is lost. This could # happen for several reasons. # exception = e recipient.set_delivery_error_code(0) recipient.set_delivery_error_message(str(e)) recipient.set_delivery_state(DeliveryState.DISCONNECTED) self._connection_state = False finally: # Release the lock self.release_lock() # Release message resources del msg # If an exception is caught above when attempting to send an email, re-throw # the except at this point because we needed time to release resources as well as # capture information about the exception and so on. This informaiton is stored # in the recipient. if recipient.get_delivery_state() is not DeliveryState.SUCCESSFUL_DELIVERY and \ recipient.get_delivery_state() is not DeliveryState.NOT_DELIVERED: raise exception status = recipient.get_delivery_state() return status
def _close_pop3_connection(self, mailbox): """ Internal method used to close an existing POP3 connection. **NOTE:** This method will catch any type of exception that may be thrown when calling :py:meth:`POP3.quit()` and it is ignored as it has no effect to the process if the session couldn't be terminated. :param mailbox: a :py:obj:`poplib.POP3_SSL` object with an active session """ if mailbox: try: mailbox.quit() except Exception: # # NOTE: # If there isn't an established connection, quit() will thrown # an AttributeError exception because there isn't an active socket # connection and hence no `sendall` method is available. # Another possible outcome is an `error_proto` exception is called # if it fails to get or parse a response. # pass def _establish_pop3_connection(self): """ Internal method used for establishing a POP3 SSL connection. **CAUTION:** Several exceptions are thrown when attempting to establish a POP3 connection with an SMTP server. The :py:class:`poplib.POP3_SSL` class throws the following exceptions: poplib.error_proto - thrown when it fails to authenticate or when it fails to get a response from the server. socket.timeout - thrown when it took too long to establish a connection. :returns: the POP3 mailbox if connection is successful, None otherwise :rtype: :py:obj:`poplib.POP3_SSL` """ mailbox = None try: # Establish a socket connection with the SMTP server to access emails mailbox = poplib.POP3_SSL(self.sender.get_smtp_url(), self.sender.get_pop3_port(), timeout=30) # Send the user email address. Even if the email is incorrect mailbox.user(self.sender.get_email()) mailbox.pass_(self.sender.get_password()) except poplib.error_proto: # # NOTE: # This exception is thrown when it failed to authenticate or when it failed to get # a response from the server # self._close_pop3_connection(mailbox) mailbox = None except socket.timeout: # # NOTE: # The server isn't responding or there is too much latency # self._close_pop3_connection(mailbox) mailbox = None return mailbox def _get_bounced_emails(self, recipient): """ Internal method used for establishing a POP3 SSL connection and read through all the emails looking for any possible email that matches the `msg_id` previously assigned to it. If a positive match is found, extract any information that contains information regarding the failed delivery attempt and store in the given `recipient`. :param recipient: the recipient containing the `msg_id` assigned :type: :py:obj:`heimdallsword.models.recipient.Recipient` """ if not recipient: raise IOError("No recipient was provided to check for bounced emails") # It seems that MAXLINE is set too low by default. So we # will increase the value to make sure poplib doesn't throw # an error_proto("-ERR EOF") exception by _getline() function # in poplib.py poplib._MAXLINE = 20480 mailbox = self._establish_pop3_connection() if mailbox: # Get all the messages from the server messages = [mailbox.retr(i) for i in range(1, len(mailbox.list()[1]) + 1)] # Concatenate message pieces. All pieces are byte strings messages = [b"\n".join(msg[1]) for msg in messages] # Parse message into an email object messages = [parser.Parser().parsestr(msg.decode(encoding='UTF-8')) for msg in messages] for msg in messages: if "Undelivered" in msg["subject"]: msg_id = None error_code = 0 error_message = "N/A" for part in msg.walk(): if part.get_content_type() == "multipart/mixed": if part["Message-ID"] is not None: msg_id = part["Message-ID"] if part.get_content_type() == "text/plain" or \ part.get_content_type() == "text/html": if part["Diagnostic-Code"] is not None: error_message = part["Diagnostic-Code"] elif part["Status"] is not None: error_code = part["Status"] # Bail as soon as the bounced email is found and collect info as to why it bounced if recipient.get_msg_id() == msg_id: recipient.set_delivery_error_code(error_code) recipient.set_delivery_error_message(error_message) recipient.set_delivery_state(DeliveryState.FAILED_DELIVERY) break