Skip to content

Email

When a user signs up we should send them a confirmation email including a link they can click to prove they've received it and hence have access to the email. We'll also want to send emails to users who have forgotten their password and you'll likely want to send emails to your users for other needs.

Rendering the email content

Most email clients support HTML emails1, however not all our users will have a HTML supporting client. For this reason I like to send out multipart emails with plain text and HTML parts2. Rather than writing HTML and plain emails I prefer a single markdown email that is rendered to HTML and plain text. Which we can do using the markdown library, as installed with poetry,

Run this command in backend/

poetry add markdown
poetry add --dev types-Markdown

we will then need to add the the following to backend/src/backend/lib/markdown.py as the markdown library does not natively support plain text,

from typing import Any, Tuple

from markdown import Markdown

def to_plain_string(element: Any) -> str:
    result = ""
    if element.tag == "a":
        result = f"{element.text} ({element.attrib['href']})\n"
    elif element.text:
        result = element.text

    for node in element:
        result += to_plain_string(node)

    if element.tail:
        result += element.tail
    return result

class PlainMarkdown(Markdown):
    def __init__(self, **kwargs: Any) -> None:
        self.output_formats["plain"] = to_plain_string  # type: ignore
        kwargs["output_format"] = "plain"
        super().__init__(**kwargs)
        self.stripTopLevelTags = False

def render_markdown(md_text: str) -> Tuple[str, str]:
    """Returns plain and html renderings of the *md_text*"""
    return PlainMarkdown().convert(md_text), Markdown().convert(md_text)

Sending the email

It is possible to send emails directly I prefer to use a service like Postmark. This is to ensure that our emails are sent reliably, from a setup that helps ensure a low spam score - this is a production app after all 😄.

In development and testing I prefer not to send emails, but rather just log them out. I find this makes development easier and quicker (no checking any email inboxes). To do so lets add a logging mail client to backend/src/backend/lib/mail_client.py,

import logging

from backend.lib.markdown import render_markdown

log = logging.getLogger(__name__)

class LoggingMailClient:
    async def send(self, email: str, subject: str, body: str, tag: str) -> None:
        plain, html = render_markdown(body)
        log.info("Sending %s to %s\n%s", tag, email, plain)

We'll also need to configure the logging, which we can do with a basic setup by adding the following to backend/src/backend/run.py,

import logging

def create_app() -> Quart:
    logging.basicConfig(level=logging.INFO)
    ...

In production we'll need to send the email via a HTTP request to Postmark. We can do this using the async compatible httpx which is installable via poetry,

Run this command in backend/

poetry add httpx

allowing the following to be added to backend/src/backend/lib/mail_client.py to send emails via postmark,

from typing import cast

import httpx

class PostmarkError(Exception):
    def __init__(self, error_code: int, message: str) -> None:
        self.error_code = error_code
        self.message = message

class PostmarkMailClient:
    def __init__(self, token: str) -> None:
        self._default_from = "Tozo <help@tozo.dev>"
        self._token = token

    async def send(self, email: str, subject: str, body: str, tag: str) -> None:
        plain, html = render_markdown(body)
        async with httpx.AsyncClient() as client:
            response = await client.post(
                "https://api.postmarkapp.com/email",
                json={
                    "From": self._default_from,
                    "To": email,
                    "Subject": subject,
                    "Tag": tag,
                    "HtmlBody": html,
                    "TextBody": plain,
                },
                headers={"X-Postmark-Server-Token": self._token},
            )
        data = cast(dict, response.json())
        if response.status_code != 200:
            raise PostmarkError(data["ErrorCode"], data["Message"])

finally we can add the following to backend/src/backend/run.py to create the mail client and assign it to the app,

from backend.lib.mail_client import LoggingMailClient, PostmarkMailClient
...

def create_app() -> Quart:
    ...

    if "POSTMARK_TOKEN" in os.environ:
        app.mail_client = PostmarkMailClient(os.environ["POSTMARK_TOKEN"])
    else:
        app.mail_client = LoggingMailClient()

  1. With restrictions and caveats, see caniemail to check if the feature you'd like to use is supported. 

  2. I'd also like to support multilingual parts, but most clients do not support this.