Email inbox for Django development
My current project sends emails. Not a very controversial statement that, as most web projects probably need to send the occasional email.
To make testing during the dev phase easy, I’ve configured the project to use the django.core.mail.backends.filebased.EmailBackend
backend, which just writes an email to a file in a directory you specify. This means I can just open up the file, read and visually verify the email, and click on any links (eg. email confirmation for registration link).
Setup of backend
settings.py
if DEBUG:
EMAIL_BACKEND = "django.core.mail.backends.filebased.EmailBackend"
EMAIL_FILE_PATH = BASE_DIR / "sent_emails"
else:
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
SENDGRID_API_KEY = os.getenv("SENDGRID_API_KEY")
EMAIL_HOST = "smtp.sendgrid.net"
EMAIL_HOST_USER = "apikey"
EMAIL_HOST_PASSWORD = SENDGRID_API_KEY
EMAIL_PORT = 587
EMAIL_USE_TLS = True
As you can see, when running in DEBUG
mode, all emails gert written to a file under the sent_emails
folder. A sample filename looks like this: 20210318-091634-140373308594112.log
, with the YYYYMMDD-HHMMSS
timestamp at the start, making it reasonably easy to find the latest emails.
If not in DEBUG
mode, I am using sendgrid, though you may have a different solution. Do not use the filebased.EmailBackend
solution in a production environment, it is intended for development use only.
⚠️ Do not use the
filebased.EmailBackend
solution in a production environment, it is intended for development use only.
Accessing the mailbox during development
Of course, you can just flip over to a file browser or your terminal and open or cat
the .log
files containing the email details. That works just fine. I found it a little irritating after a while though, so I build a basic inbox to display them to me within my project without having to change to another utility.
Display an Inbox link
My navbar is contained in its own template, and then included in the base.html
, so within my _navbar.html
template file, I included the following:
{% if debug %}
<li class="nav-item px-2">
<a class="nav-link" href="{% url 'test_inbox' %}">Inbox</a>
</li>
{% endif %}
(My navbar items are contained in a <ul>
, hence the list item element. Yours may look different or you may want it somewhere other than the navbar. It’s the link for the <a>
anchor element that is important here though).
Of course we need test_inbox
to link to something, so:
urls.py
if settings.DEBUG:
import debug_toolbar
from .views import Inbox, InboxMail
urlpatterns += [
path("__inbox/<str:filename>/", InboxMail.as_view(), name="test_inbox_mail"),
path("__inbox/", Inbox.as_view(), name="test_inbox"),
path("__debug__/", include(debug_toolbar.urls)),
]
I already had the if settings.DEBUG
in here, as I use Django Debug Toolbar. If you don’t use this extremely helpful tool, you should look into installing it in your development environment. It is extremely helpful.
💡 Install django-debug-toolbar to make debugging of your Django project much, much simpler!
To check the DEBUG
boolean you will first need to do from django.conf import settings
, which gives you access to everything in your settings.py
file.
ℹ️
from django.conf import settings
allows you access to everything in yoursettings.py
You’ll also note I have a link here for a second view that will show a specific mail. We’ll get to that in a bit. I’ve put the path at __inbox
just to indicate it’s a development only path.
Setup the Inbox view
So we’ve connected test_inbox
to the Inbox
view. Let’s define this view.
class Inbox(ListView):
"""
Testing util only - Displays list of mails in debug inbox
"""
template_name = "config/inbox.html"
context_object_name = "mail_list"
paginate_by = 10
def get_queryset(self):
import os
from django.conf import settings
path = settings.EMAIL_FILE_PATH
mail_list = os.listdir(path)
mail_list.sort(reverse=True)
return mail_list
Lets run through this to be clear on what’s happening:
Inbox
inherits fromListView
, for the simple reason that it will display a list of emails in theEMAIL_FILE_PATH
folder.- I have a template for the view, of course. I’ll show you that in a moment.
context_object_name
tells the template what the data list is called, so we can loop over it and display the data.- I’m paginating my inbox. You don’t have to, so feel free to leave this bit out.
Within get_queryset
:
- I import
os
andsettings
here because they’re not required for any of the production views in the sameviews.py
file, so importing them in a production run is unnecesary and will affect performance. This way, they only get imported in a dev environment when I actually access the inbox. os.listdir
gives me a list of all files in the directory defined insettings.EMAIL_FILE_PATH
. Assuming you have created a directory just to hold these emails (which you should), all that will be in here is emails your project has sent whileDEBUG is True
.- The
sort(reverse=True)
is to ensure mails are sorted in the order, and show the most recent at the top of the list. This just makes the inbox easier to use. - Finally, the view returns the list, which the template can access as
mail_list
(which we defined incontext_object_name
)
config/inbox.html
{% extends 'config/base.html' %}
{% block main_content %}
<br/>
<h1>Inbox</h1>
<br/>
{% include 'config/_pagination.html' %}
<table class="table table-striped table-hover">
<thead>
<tr>
<th scope="col">Email file</th>
</tr>
</thead>
<tbody>
{% for email in mail_list %}
<tr onclick="window.location='{% url "test_inbox_mail" filename=email %}'">
<td>{{ email }}</td>
</tr>
{% empty %}
<tr>
<td class="text-center">No messages found.</td>
</tr>
{% endfor %}
</tbody>
</table>
{% include 'config/_pagination.html' %}
{% endblock main_content %}
That’s it!
- The template extends my
base.py
, which also includes my navbar. - The include of pagination can be left out if you’re not using this. I will cover a simple way to paginate a list in a seperate post, but if you’re not sure how to do this, just leave it out, and take the
paginate_by
line out of the view. All this means is you will get a list of all files in the directory. - I’m putting my data into a table just to make it pretty (and using bootstrap format on my project). Style yours however you like.
- Note the
{% empty %}
option on the{% for %}
loop. This kicks in only if the data you give to the for loop is empty. In this case, this means there are no emails to read. {% for email in mail_list %}
loops over everything inmail_list
, which is the name we gave the data in the view (context_object_name
)- There is a tiny bit of javascript here just to link to the email itself. I have this so I can make the entire table row a link. If you had, eg. a list of text links you could just use an
<a>
element for this instead. - Finally,
{{ email }}
will give you the file name (sincemail_list
contained a list of all filenames in the email directory).
I may add a few bits of functionality to this at some point, but for the moment I will leave them as an exercise for the reader:
- A
Delete All
button that removes everything in the inbox. Useful when you have a lot of mails and just want to start again. - Individual delete buttons for each mail (so each mail has a little trashcan icon by it).
- A nicely formatted date/timestamp for each mail in the list.
To
information (we know thefrom
, but we may be testing mails to different users, so seeing this in the list could be useful).
Setup the Email view
You’ve seen from the urls.py
that I have a view called InboxMail
to view an individual email, and that each record in the Inbox
template links to this view, passing the filename. So let’s take a look at that:
class InboxMail(TemplateView):
"""
Testing util only - Displays specific mail from the inbox
"""
template_name = "config/inbox_email.html"
def get_context_data(self, **kwargs):
import os
from datetime import datetime
from django.conf import settings
context = super().get_context_data(**kwargs)
filename = context["filename"]
date_time = " ".join(os.path.splitext(filename)[0].split("-")[:2])
context["timestamp"] = datetime.strptime(date_time, "%Y%m%d %H%M%S")
filepath = settings.EMAIL_FILE_PATH / filename
with open(filepath, "r") as f:
context["email_content"] = f.read()
return context
Here, I’m inheriting from TemplateView
, so all the template is really expecting is context information, which is why I am overriding get_context_data
to do a few things:
- As with the
Inbox
view, any imports not also relevant for a production deployment are done within the view, for performance reasons. - The filename is parsed to extract the date and time, and format it as a more “human readable” string, which we pass to the template.
- The file is then opened and the contents read into another variable, which we also send to the template.
The template is then even simpler than the one for the inbox.
config/inbox_email.html
{% extends 'config/base.html' %}
{% block main_content %}
<div class="container">
<div class="card">
<div class="card-header font-weight-bold">
{{ filename }}<span class="text-muted">, {{ timestamp }}</span>
</div>
<div class="card-body">
<a href="{% url 'test_inbox' %}" class="btn btn-primary">Return to Inbox</a>
<br /><br />
{{ email_content|linebreaks }}
</div>
</div>
</div>
{% endblock main_content %}
- The email is displayed as a card, just to make it look neat.
- The card header has the human readable timestamp we passed in
- The card body displays the email text. The
linebreaks
templatetag preserves linebreaks as they are in the email itself, and makes it a lot more readable (try it without if you want to see the difference!) - Just before the email text, there is a button to take you back to the inbox view.
And that’s it!
I find this quite useful, as I can now complete a whole manual use case test within the same application while I’m developing, and don’t need to switch around between browser, file explorer, possibly notepad or some other text editor.
All the code needed is here. You may need to change some display classes, or leave off pagination, or add some extra stuff you want, but the basic implementation will work, so just copy paste and you should have it up and running in no time!