4
\$\begingroup\$

Some time ago, I implemented an emailing library to simplify sending emails for several web applications and daemons. As usual, I am interested in improving my code.

"""Library for e-mailing."""

from __future__ import annotations
from configparser import ConfigParser, SectionProxy
from email.charset import Charset, QP
from email.mime.multipart import MIMEMultipart
from email.mime.nonmultipart import MIMENonMultipart
from email.mime.text import MIMEText
from email.utils import formatdate
from functools import cache
from logging import getLogger
from smtplib import SMTPException, SMTP
from typing import Iterable, Optional
from warnings import warn


__all__ = ['EMail', 'Mailer']


LOGGER = getLogger('emaillib')


class MIMEQPText(MIMENonMultipart):
    """A quoted-printable encoded text."""

    def __init__(self, payload: str, subtype: str = 'plain',
                 charset: str = 'utf-8'):
        super().__init__('text', subtype, charset=charset)
        self.set_payload(payload, charset=get_qp_charset(charset))


class EMail(MIMEMultipart):
    """Email data for Mailer."""

    def __init__(self, subject: str, sender: str, recipient: str, *,
                 plain: str = None, html: str = None, charset: str = 'utf-8',
                 quoted_printable: bool = False):
        """Creates a new EMail."""
        super().__init__(subtype='alternative')
        self['Subject'] = subject
        self['From'] = sender
        self['To'] = recipient
        self['Date'] = formatdate(localtime=True, usegmt=True)
        text_type = MIMEQPText if quoted_printable else MIMEText

        if plain is not None:
            self.attach(text_type(plain, 'plain', charset))

        if html is not None:
            self.attach(text_type(html, 'html', charset))

    def __str__(self):
        """Converts the EMail to a string."""
        return self.as_string()

    @property
    def subject(self):
        """Returns the Email's subject."""
        return self['Subject']

    @property
    def sender(self):
        """Returns the Email's sender."""
        return self['From']

    @property
    def recipient(self):
        """Returns the Email's recipient."""
        return self['To']


class Mailer:
    """A simple SMTP mailer."""

    def __init__(
            self,
            smtp_server: str,
            smtp_port: int,
            login_name: str,
            passwd: str,
            *,
            ssl: Optional[bool] = None,
            tls: Optional[bool] = None
    ):
        """Initializes the email with basic content."""
        self.smtp_server = smtp_server
        self.smtp_port = smtp_port
        self.login_name = login_name
        self._passwd = passwd

        if ssl is not None:
            warn('Option "ssl" is deprecated. Use "tls" instead.',
                 DeprecationWarning)

        self.ssl = ssl
        self.tls = tls

    def __call__(self, emails: Iterable[EMail]):
        """Alias to self.send()."""
        return self.send(emails)

    def __str__(self):
        return f'{self.login_name}:*****@{self.smtp_server}:{self.smtp_port}'

    @classmethod
    def from_section(cls, section: SectionProxy) -> Mailer:
        """Returns a new mailer instance from the provided config section."""

        if (smtp_server := section.get(
                'smtp_server', section.get('host')
        )) is None:
            raise ValueError('No SMTP server specified.')

        if (port := section.getint(
                'smtp_port', section.getint('port')
        )) is None:
            raise ValueError('No SMTP port specified.')

        if (login_name := section.get(
                'login_name', section.get('user')
        )) is None:
            raise ValueError('No login nane specified.')

        if (passwd := section.get('passwd', section.get('password'))) is None:
            raise ValueError('No password specified.')

        return cls(
            smtp_server, port, login_name, passwd,
            ssl=section.getboolean('ssl'), tls=section.getboolean('tls')
        )

    @classmethod
    def from_config(cls, config: ConfigParser) -> Mailer:
        """Returns a new mailer instance from the provided config."""
        return cls.from_section(config['email'])

    def _start_tls(self, smtp: SMTP) -> bool:
        """Start TLS connection."""
        try:
            smtp.starttls()
        except (SMTPException, RuntimeError, ValueError) as error:
            LOGGER.error('Error during STARTTLS: %s', error)

            # If TLS was explicitly requested, re-raise
            # the exception and fail.
            if self.ssl or self.tls:
                raise

            # If TLS was not explicitly requested, return False
            # to make the caller issue a warning.
            return False

        return True

    def _start_tls_if_requested(self, smtp: SMTP) -> bool:
        """Start a TLS connection if requested."""
        if self.ssl or self.tls or self.ssl is None or self.tls is None:
            return self._start_tls(smtp)

        return False

    def _login(self, smtp: SMTP) -> bool:
        """Attempt to log in at the server."""
        try:
            smtp.ehlo()
            smtp.login(self.login_name, self._passwd)
        except SMTPException as error:
            LOGGER.error(str(error))
            return False

        return True

    def send(self, emails: Iterable[EMail]) -> bool:
        """Sends emails."""
        with SMTP(host=self.smtp_server, port=self.smtp_port) as smtp:
            if not self._start_tls_if_requested(smtp):
                LOGGER.warning('Connecting without SSL/TLS encryption.')

            if not self._login(smtp):
                return False

            return send_emails(smtp, emails)


def send_email(smtp: SMTP, email: EMail) -> bool:
    """Sends an email via the given SMTP connection."""

    try:
        smtp.send_message(email)
    except SMTPException as error:
        LOGGER.warning('Could not send email: %s', email)
        LOGGER.error(str(error))
        return False

    return True


def send_emails(smtp: SMTP, emails: Iterable[EMail]) -> bool:
    """Sends emails via the given SMTP connection."""

    return all({send_email(smtp, email) for email in emails})


@cache
def get_qp_charset(charset: str) -> Charset:
    """Returns a quoted printable charset."""

    qp_charset = Charset(charset)
    qp_charset.body_encoding = QP
    return qp_charset
\$\endgroup\$

2 Answers 2

4
\$\begingroup\$

I noted the use of the := operator aka walrus. That means your code requires Python >= 3.8 so consider adding a guard to make sure this requirement is satisfied.

Since you are aiming for recent versions of Python you could as well "upgrade" to a data class that will make your code more concise. Thus you could declare your class EMail like this:

from dataclasses import dataclass

@dataclass
class EMail(MIMEMultipart):
    subject: str
    sender: str
    recipient: str
    plain: str
    html: str
    charset: str = 'utf-8'
    quoted_printable: bool = False

in lieu if an __init__ method and you can use self.variable straight away without manual assignment.

One additional benefit is that a __repr__() method is already implemented for you.

The @property decorator can still be used casually:

@property
def subject(self) -> str:
    return self.subject

Then you may want to use __post_init__ for additional initialization work eg:

def __post_init__(self):
    text_type = MIMEQPText if quoted_printable else MIMEText

    if plain is not None:
        self.attach(text_type(plain, 'plain', charset))

    if html is not None:
        self.attach(text_type(html, 'html', charset))

The downside of the data class is when you need positional or named arguments, so ask yourself if you need them or not. See this discussion for possible strategies plus this section of the docs.

But note that the dataclass decorator has a kw_only argument:

If true (the default value is False), then all fields will be marked as keyword-only

This is a matter of personal preference but this code:

def _login(self, smtp: SMTP) -> bool:
    """Attempt to log in at the server."""
    try:
        smtp.ehlo()
        smtp.login(self.login_name, self._passwd)
    except SMTPException as error:
        LOGGER.error(str(error))
        return False

    return True

could be written as:

def _login(self, smtp: SMTP) -> bool:
    """Attempt to log in at the server."""
    try:
        smtp.ehlo()
        smtp.login(self.login_name, self._passwd)
    except SMTPException as error:
        LOGGER.error(str(error))
        return False
    else:
        return True

(I'm always wary of indentation in Python)

Small typo on line 146:

raise ValueError('No login nane specified.')

SMTP login can be made optional as it's not always required, for example on a corporate LAN where local IP addresses are trusted (eg Postfix my_networks) or from some ISPs.

\$\endgroup\$
1
5
\$\begingroup\$

It looks like your email code was written for an older Python version. The email module in the standard library was overhauled in Python 3.6 (really already in 3.3, but made official in 3.6) to be more logical, versatile, and succinct; new code should target the (no longer very) new EmailMessage API. Probably throw away this code and start over with modern code from the Python email examples documentation.

Honestly, I don't see what your encapsulated EMail class offers above what the EmailMessage object already provides. In particular, Python now transparently decides whether or not something needs to be encoded as quoted-printable.

Here is a quick and dirty refactoring to use the modern policy-based EmailMessage API.

"""Library for e-mailing."""

from __future__ import annotations
from configparser import ConfigParser, SectionProxy
from email.message import EmailMessage
from email.utils import formatdate
from logging import getLogger
from smtplib import SMTPException, SMTP
from typing import Iterable, Optional
from warnings import warn


__all__ = ['EMail', 'Mailer']


LOGGER = getLogger('emaillib')


class EMail(EmailMessage):
    """Email data for Mailer."""

    def __init__(self, subject: str, sender: str, recipient: str, *,
                 plain: str = None, html: str = None):
        """Creates a new EMail."""
        super().__init__()
        self['Subject'] = subject
        self['From'] = sender
        self['To'] = recipient
        self['Date'] = formatdate(localtime=True, usegmt=True)

        if plain is not None:
            self.set_content(plain, subtype='plain'))

        if html is not None:
            if plain is None:
                self.set_content(html, subtype='html')
            else:
                self.add_alternative(html, subtype='html')

    def __str__(self):
        """Converts the EMail to a string."""
        return self.as_string()

    @property
    def subject(self):
        """Returns the Email's subject."""
        return self['Subject']

    @property
    def sender(self):
        """Returns the Email's sender."""
        return self['From']

    @property
    def recipient(self):
        """Returns the Email's recipient."""
        return self['To']


class Mailer:
    """A simple SMTP mailer."""

    def __init__(
            self,
            smtp_server: str,
            smtp_port: int,
            login_name: str,
            passwd: str,
            *,
            ssl: Optional[bool] = None,
            tls: Optional[bool] = None
    ):
        """Initializes the email with basic content."""
        self.smtp_server = smtp_server
        self.smtp_port = smtp_port
        self.login_name = login_name
        self._passwd = passwd

        if ssl is not None:
            warn('Option "ssl" is deprecated. Use "tls" instead.',
                 DeprecationWarning)

        self.ssl = ssl
        self.tls = tls

    def __call__(self, emails: Iterable[EMail]):
        """Alias to self.send()."""
        return self.send(emails)

    def __str__(self):
        return f'{self.login_name}:*****@{self.smtp_server}:{self.smtp_port}'

    @classmethod
    def from_section(cls, section: SectionProxy) -> Mailer:
        """Returns a new mailer instance from the provided config section."""

        if (smtp_server := section.get(
                'smtp_server', section.get('host')
        )) is None:
            raise ValueError('No SMTP server specified.')

        if (port := section.getint(
                'smtp_port', section.getint('port')
        )) is None:
            raise ValueError('No SMTP port specified.')

        if (login_name := section.get(
                'login_name', section.get('user')
        )) is None:
            raise ValueError('No login nane specified.')

        if (passwd := section.get('passwd', section.get('password'))) is None:
            raise ValueError('No password specified.')

        return cls(
            smtp_server, port, login_name, passwd,
            ssl=section.getboolean('ssl'), tls=section.getboolean('tls')
        )

    @classmethod
    def from_config(cls, config: ConfigParser) -> Mailer:
        """Returns a new mailer instance from the provided config."""
        return cls.from_section(config['email'])

    def _start_tls(self, smtp: SMTP) -> bool:
        """Start TLS connection."""
        try:
            smtp.starttls()
        except (SMTPException, RuntimeError, ValueError) as error:
            LOGGER.error('Error during STARTTLS: %s', error)

            # If TLS was explicitly requested, re-raise
            # the exception and fail.
            if self.ssl or self.tls:
                raise

            # If TLS was not explicitly requested, return False
            # to make the caller issue a warning.
            return False

        return True

    def _start_tls_if_requested(self, smtp: SMTP) -> bool:
        """Start a TLS connection if requested."""
        if self.ssl or self.tls or self.ssl is None or self.tls is None:
            return self._start_tls(smtp)

        return False

    def _login(self, smtp: SMTP) -> bool:
        """Attempt to log in at the server."""
        try:
            smtp.ehlo()
            smtp.login(self.login_name, self._passwd)
        except SMTPException as error:
            LOGGER.error(str(error))
            return False

        return True

    def send(self, emails: Iterable[EMail]) -> bool:
        """Sends emails."""
        with SMTP(host=self.smtp_server, port=self.smtp_port) as smtp:
            if not self._start_tls_if_requested(smtp):
                LOGGER.warning('Connecting without SSL/TLS encryption.')

            if not self._login(smtp):
                return False

            return send_emails(smtp, emails)


def send_email(smtp: SMTP, email: EMail) -> bool:
    """Sends an email via the given SMTP connection."""

    try:
        smtp.send_message(email)
    except SMTPException as error:
        LOGGER.warning('Could not send email: %s', email)
        LOGGER.error(str(error))
        return False

    return True


def send_emails(smtp: SMTP, emails: Iterable[EMail]) -> bool:
    """Sends emails via the given SMTP connection."""

    return all({send_email(smtp, email) for email in emails})

You'll notice that EMail no longer accepts the quoted_printable parameter. I also removed get_qp_charset and the MIMEQPText class. Also, the charset keyword argument is gone. Exposing these internals and requiring knowledge about their significance was one of the problems of the old compat32 API; this should now no longer be necessary.

I'm not particularly sold on the @property methods to return fields for which there is already a dictionary-like API, but I didn't remove them.

Your solution doesn't provide any support for SMTP_SSL servers. Whilst initially unencrypted port 587 with STARTTLS and password login after that is arguably best current practice for public SMTP submissions, there is still a sizable legacy of legacy port 465 servers which require TLS at handshake time.

I don't have a strong opinion about the configparser parts as such. If anything, I have a strong negative opinion about the .ini format itself; but if it works for you, more power to you.

Attempting to starttls even when this wasn't requested is odd. Shouldn't the attempt be made only if the user requested it? The current implementation basically makes it impossible to work without TLS if the server supports it. Pethaps that can be seen as a security feature, but then at the very least the code should log a warning if it activates TLS when the user might be trying to run without it.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.