Sending Async Emails Without Workers

Tonight's challenge was to implement a contact form, that sent emails asynchronously, but didn't need a task queue or a second "worker" process. I managed to get there, but learned lots, which I'll briefly document here.

Before we start - why send emails asynchronously? This is an age-old problem, and is one of the typical use-cases for implementing background tasks/queues/workers/etc. Sending an email can anything from a few hundreds of milliseconds, to multiple seconds, or in the worst-case scenario it can cause time-outs if there are network issues. So ideally you want to hand off the sending of the email to another process, so that it can take as long as it needs to, without causing any page-load delays. Also, while the sending of the email is still pending/processing, we can tell the visitor a little white lie ðŸĪĨ and say "thanks, your email has been sent successfully! 👍"

With async support firmly baked into Django now, I was pretty sure I could get this working. Most of the async examples you find, typically use httpx to make API requests, which is a great use-case for async. So all we need to do is to make an API request to send the email, then return the template to the visitor while the API request is still processing.

If you're impatient, and want a TLDR, these are the high-level steps to get this working:

  • Find an email provider that offers an API to send emails. There are many of these, but I chose Resend for this experiment. They have a generous free tier, a well-documented API, and a great onboarding process.
  • Create an async function to send your email. Start easy in dev, with a small snippet and hard-coded values. Change this to be more robust and secure before you go to production.
  • Create an async view that calls the email sending API you created. I used the asyncio Python package to create a task for sending the email.
  • Make sure the view is all async! If you use a single sync function (e.g. render) it will cause the whole view to be sync and not async.
  • Switch to an async-capable server. I used Daphne because it integrates nicely with Django - you get runserver support, and whitenoise appeared to keep working - despite the internet telling me that it doesn't work with ASGI? ðŸĪ”

Here's the async function I created to send the email:

import httpx
from django.conf import settings


async def send_email_async(message: str, name: str, email: str) -> None:
    """Asynchronously send an email using Resend's API."""
    url = "https://api.resend.com/emails"

    headers = {
        "Authorization": f"Bearer {settings.RESEND_API_KEY}",
        "Content-Type": "application/json",
    }

    payload = {
        "from": settings.CONTACT_FORM_FROM,
        "to": settings.CONTACT_FORM_TO,
        "subject": "Contact Form Submission from stuartm.nz",
        "html": f"<ul><li>Name: {name}</li><li>Email: {email}</li><li>Message:<br>{message}</li></ul>",
        "text": f"Name: {name}\n\nEmail: {email}\n\nMessage:\n\n{message}\n\n",
    }

    async with httpx.AsyncClient() as client:
        await client.post(url, headers=headers, json=payload)

I didn't bother getting the response back, but I should really do that and check the sending was successful and log if it failed. Although Sentry will pick up failures anyway (and it did during my first deploy to production!) ðŸĪ•

And here's the view that calls the function:

import asyncio

from django.http import HttpRequest, HttpResponse
from django.template.response import TemplateResponse

from contact_form.forms import ContactForm
from contact_form.tasks import send_email_async

background_tasks = set()


async def contact_form(request: HttpRequest, contact_form_title: str = "Contact Form") -> HttpResponse:
    """Render the contact form template."""
    if request.method == "POST":
        form = ContactForm(request.POST)
        if form.is_valid():
            # Process the form data
            name = form.cleaned_data["name"]
            email = form.cleaned_data["email"]
            message = form.cleaned_data["message"]

            # Send the email - see RUF006
            task = asyncio.create_task(send_email_async(name=name, email=email, message=message))
            background_tasks.add(task)
            task.add_done_callback(background_tasks.discard)

            # Show a success message
            return TemplateResponse(request, "contact_form/success.html")
    else:
        form = ContactForm()

    return TemplateResponse(
        request,
        "contact_form/contact_form.html",
        {"form": form, "contact_form_title": contact_form_title},
    )

I can't say I fully and deeply understand the async code here, but you create a task (create_task) with asyncio and that does the magic in the background. The extra code and the note about RUF006 is that I use Astral's Ruff tool with most rules turned on - I find this helps me learn better coding practices - and one of these is "asyncio-dangling-task" (RUF006). This article points to an article by Will McGugan, "The Heisenbug lurking in your async code", who says:

"If you have ever used asyncio.create_task you may have created a bug for yourself that is challenging (read almost impossible) to reproduce. If it occurs, your code will likely fail in unpredictable ways."

Ouch! I'm not sure if my simple use-case warranted the change, but avoiding impossible-to-reproduce bugs sounds like a great idea. I'll revisit this in the future, and will actually read the docs to figure out what's going on. 😀

Next step is to install and configure daphne using pip or uv, and then adding the following two lines to your project settings:

INSTALLED_APPS = [
    "daphne",
    ... # existing apps

and:

ASGI_APPLICATION = "config.asgi.application"

(config is the name of my project.)

Then you can run the runserver command as you normally would and Daphne will serve your local project, and you can test it works.

Of course, there's a bunch of other stuff needed, e.g. the form and templates, but none of this is clever or requires changing for async purposes. I also wanted the form to be triggered by HTMX and display as a modal thanks to Picocss. You can see it working on this site by clicking the "@email" button in the navigation bar. Feel free to send me a message to say that you read all the way to the end of this post! 💊

I deployed all of this to production and nothing fell over, although it's far from ideal. Right now I'm serving the entire site with Daphne despite only having a single async view - not really efficient. I've read some forum posts about using Nginx to route ASGI requests to Daphne while routing WSGI requests to gunicorn. Not really sure how to do this, so that's a task for another day. ðŸ˜ĩ‍ðŸ’Ŧ

The code for this site is public and available on GitHub. Feel free to poke around and point out all the silly mistakes I've made! ðŸĪŠ