Source code for kerno.email
"""Extensible scheme for an app to send out email messages.
We provide only a well-structured API to build messages, such that
classes representing individual email messages are easy to change
and easy to test. And any backend capable of actually sending out
emails can be plugged at the end.
"""
from abc import ABCMeta, abstractmethod
from dataclasses import dataclass
from typing import List, Optional, Tuple, Union
from bag.email_validator import EmailValidator
from bag.reify import reify
from bs4 import BeautifulSoup
from kerno.typing import DictStr, TEmailAddress, TPersonsName
[docs]@dataclass(frozen=True)
class EmailAddress:
"""Represents an email address, optionally with the person's name."""
email_validator = EmailValidator()
email: TEmailAddress
name: TPersonsName
def __init__(
self, email: TEmailAddress, name: TPersonsName = TPersonsName("")
):
"""Validate *email* and instantiate."""
self.email_validator.validate_or_raise(email)
self.__dict__["email"] = email # avoid frozen dataclass error
self.__dict__["name"] = name # avoid frozen dataclass error
[docs] def to_mailer(self) -> Union[str, Tuple[str, str]]:
"""Return either email or a 2-tuple (name, email)."""
return (self.name, self.email) if self.name else self.email
def __str__(self) -> str:
return f'"{self.name}" <{self.email}>' if self.name else self.email
[docs]class Envelope:
"""Represents the envelope of an email message."""
def __init__( # noqa
self,
recipients: List[EmailAddress],
cc: Optional[List[EmailAddress]] = None,
bcc: Optional[List[EmailAddress]] = None,
reply_to: Optional[EmailAddress] = None,
sender: Optional[EmailAddress] = None,
):
assert recipients
self.recipients = recipients
self.cc = cc or []
self.bcc = bcc or []
self.reply_to = reply_to
self.sender = sender
[docs] def to_dict(self) -> DictStr:
"""Return a dict with Python primitive types within."""
return {
"recipients": [str(r) for r in self.recipients],
"cc": [str(r) for r in self.cc],
"bcc": [str(r) for r in self.bcc],
"reply_to": str(self.reply_to) if self.reply_to else None,
"sender": str(self.sender) if self.sender else None,
}
[docs]class EmailMessageBase(metaclass=ABCMeta):
"""Abstract base class to build an email message from templates.
The plain text version is automatically built from the HTML version.
Subclasses should declare certain static variables::
class ACertainEmailMessage(EmailMessageBase):
SUBJECT = 'Life or death matter in {app_name}!!!1'
HTML_TEMPLATE = 'path/to/template.jinja2'
"""
def __init__(self, adict: DictStr, envelope: Envelope):
""":param adict: dictionary for use in templates."""
self.adict = adict
self.envelope = envelope
@reify
def subject(self) -> str:
"""May be overridden in subclasses to decorate the subject line."""
return self.SUBJECT.format(**self.adict) # type: ignore
@reify
@abstractmethod
def html(self) -> str:
"""Must be overridden in subclasses to return the HTML version.
Usually based on HTML_TEMPLATE. Example implementation::
@reify
def rich(self):
return jinja2.get_template(
self.HTML_TEMPLATE).render(self.adict)
"""
raise NotImplementedError()
@reify
def plain(self) -> str:
"""Autogenerate the plain text version from the rich version."""
return BeautifulSoup(self.html, "html.parser").get_text()
[docs] def to_dict(self) -> DictStr:
"""Return a dict containing the computed parts of this message."""
assert self.subject
assert self.plain or self.html
return {
"envelope": self.envelope.to_dict(),
"subject": self.subject,
"html": self.html,
"plain": self.plain,
}