This article will teach you how to send HTML Emails with Python, FastAPI, PyMongo, MongoDB, Jinja2, and Docker. Also, you will learn how to use Jinja2 to generate different HTML templates.
API with Python, PyMongo, MongoDB, and FastAPI series:
- RESTful API with Python & FastAPI: Access and Refresh Tokens
- RESTful API with Python & FastAPI: Send HTML Emails
- CRUD RESTful API Server with Python, FastAPI, and MongoDB
Related Articles:
- Build Golang gRPC Server and Client: SignUp User & Verify Email
- CRUD API with Node.js and PostgreSQL: Send HTML Emails
- API with Golang + MongoDB: Send HTML Emails with Gomail
- API with Node.js + PostgreSQL + TypeORM: Send Emails
Prerequisites
Before you start, you should:
- Have a basic understanding of Python
- Have Python 3.6+ installed on your computer
- Have Docker and Docker-compose installed
Send HTML Emails with Jinja2 & FastAPI Example
When a user registers for an account, FastAPI will validate the credentials and send a verification email to the provided email address.
This authentication step is needed to ensure that users provide valid email addresses.
After the FastAPI server receives the request and validates the user’s credentials, an HTML email verification template is generated and sent to the provided email.
The user receives the notification and opens the email sent by the FastAPI server.
When the user clicks the “Verify Your Account” button, the user is taken to the email verification page where the verification code is pre-filled in the text input field.
The user clicks on the “VERIFY EMAIL” button and the frontend app makes a GET request with the verification code in the URL parameters to the FastAPI server.
The FastAPI server extracts and validates the verification code in the URL parameters before updating the user’s credentials to be valid in the database.
The FastAPI server returns a success message to the frontend app assuming the verification code was valid.
Creating the SMTP Provider Account
To avoid sending the development emails to real email addresses, we will use Mailtrap to capture them.
However, before you deploy the application into production, you need to replace the SMTP credentials in the .env
file. You can easily get real SMTP credentials from an SMTP service provider (SMTP.com, SendGrid, Mailgun) to send real emails to your customers.
Follow these steps to register an account with Mailtrap. You can skip this section if you already have a Mailtrap account.
Step 1: Navigate to Mailtrap and provide your credentials to create a new account.
Step 2: Once the account has been created, log into it and click on the “Add Inbox” button. Input the inbox name and click on the “Save” button.
Next, click on the gear icon on the newly-created inbox to display the credentials page.
Next, click on the “Show Credentials” dropdown link on the Settings page to display the SMTP credentials.
Edit the Environment Variables File
Open the .env
file and add the SMTP credentials.
.env
EMAIL_HOST=smtp.mailtrap.io
EMAIL_PORT=587
EMAIL_USERNAME=4aeca0c9318dd2
EMAIL_PASSWORD=a987a0e0eac00d
EMAIL_FROM=admin@admin.com
After copying and pasting the SMTP credentials, you should end up with something like this:
.env
MONGO_INITDB_ROOT_USERNAME=admin
MONGO_INITDB_ROOT_PASSWORD=password123
MONGO_INITDB_DATABASE=fastapi
DATABASE_URL=mongodb://admin:password123@localhost:6000/fastapi?authSource=admin
ACCESS_TOKEN_EXPIRES_IN=15
REFRESH_TOKEN_EXPIRES_IN=60
JWT_ALGORITHM=RS256
CLIENT_ORIGIN=http://localhost:3000
EMAIL_HOST=smtp.mailtrap.io
EMAIL_PORT=587
EMAIL_USERNAME=90cf952fb44469
EMAIL_PASSWORD=0524531956c552
EMAIL_FROM=admin@admin.com
JWT_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlCT2dJQkFBSkJBSSs3QnZUS0FWdHVQYzEzbEFkVk94TlVmcWxzMm1SVmlQWlJyVFpjd3l4RVhVRGpNaFZuCi9KVHRsd3h2a281T0pBQ1k3dVE0T09wODdiM3NOU3ZNd2xNQ0F3RUFBUUpBYm5LaENOQ0dOSFZGaHJPQ0RCU0IKdmZ2ckRWUzVpZXAwd2h2SGlBUEdjeWV6bjd0U2RweUZ0NEU0QTNXT3VQOXhqenNjTFZyb1pzRmVMUWlqT1JhUwp3UUloQU84MWl2b21iVGhjRkltTFZPbU16Vk52TGxWTW02WE5iS3B4bGh4TlpUTmhBaUVBbWRISlpGM3haWFE0Cm15QnNCeEhLQ3JqOTF6bVFxU0E4bHUvT1ZNTDNSak1DSVFEbDJxOUdtN0lMbS85b0EyaCtXdnZabGxZUlJPR3oKT21lV2lEclR5MUxaUVFJZ2ZGYUlaUWxMU0tkWjJvdXF4MHdwOWVEejBEWklLVzVWaSt6czdMZHRDdUVDSUVGYwo3d21VZ3pPblpzbnU1clBsTDJjZldLTGhFbWwrUVFzOCtkMFBGdXlnCi0tLS0tRU5EIFJTQSBQUklWQVRFIEtFWS0tLS0t
JWT_PUBLIC_KEY=LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUZ3d0RRWUpLb1pJaHZjTkFRRUJCUUFEU3dBd1NBSkJBSSs3QnZUS0FWdHVQYzEzbEFkVk94TlVmcWxzMm1SVgppUFpSclRaY3d5eEVYVURqTWhWbi9KVHRsd3h2a281T0pBQ1k3dVE0T09wODdiM3NOU3ZNd2xNQ0F3RUFBUT09Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQ==
Validating the Environment Variables with Pydantic
To load the SMTP credentials we added to the .env
file, we need to add the variables to the app/config.py
for the Pydantic package to load and validate them.
app/config.py
from pydantic import BaseSettings, EmailStr
class Settings(BaseSettings):
DATABASE_URL: str
MONGO_INITDB_DATABASE: str
JWT_PUBLIC_KEY: str
JWT_PRIVATE_KEY: str
REFRESH_TOKEN_EXPIRES_IN: int
ACCESS_TOKEN_EXPIRES_IN: int
JWT_ALGORITHM: str
CLIENT_ORIGIN: str
EMAIL_HOST: str
EMAIL_PORT: int
EMAIL_USERNAME: str
EMAIL_PASSWORD: str
EMAIL_FROM: EmailStr
class Config:
env_file = './.env'
settings = Settings()
Creating the HTML Email Templates in FastAPI
There are different template engines in Python (Cheetah, Mako, Chameleon, Diazo, Juno, and more) but we are going to use Jinja2 as our templating engine since it is a self-contained open-source project.
Install Jinja2 with this command:
pip install Jinja2
Next, create a app/templates
folder to hold all the HTML templates.
With that out of the way, let’s create a app/templates/_styles.html
file to contain the CSS styles needed by the HTML templates.
app/templates/_styles.html
<style>
/* -------------------------------------
GLOBAL RESETS
------------------------------------- */
/*All the styling goes here*/
img {
border: none;
-ms-interpolation-mode: bicubic;
max-width: 100%;
}
body {
background-color: #f6f6f6;
font-family: sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 14px;
line-height: 1.4;
margin: 0;
padding: 0;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
table {
border-collapse: separate;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
width: 100%;
}
table td {
font-family: sans-serif;
font-size: 14px;
vertical-align: top;
}
/* -------------------------------------
BODY & CONTAINER
------------------------------------- */
.body {
background-color: #f6f6f6;
width: 100%;
}
/* Set a max-width, and make it display as block so it will automatically stretch to that width, but will also shrink down on a phone or something */
.container {
display: block;
margin: 0 auto !important;
/* makes it centered */
max-width: 580px;
padding: 10px;
width: 580px;
}
/* This should also be a block element, so that it will fill 100% of the .container */
.content {
box-sizing: border-box;
display: block;
margin: 0 auto;
max-width: 580px;
padding: 10px;
}
/* -------------------------------------
HEADER, FOOTER, MAIN
------------------------------------- */
.main {
background: #ffffff;
border-radius: 3px;
width: 100%;
}
.wrapper {
box-sizing: border-box;
padding: 20px;
}
.content-block {
padding-bottom: 10px;
padding-top: 10px;
}
.footer {
clear: both;
margin-top: 10px;
text-align: center;
width: 100%;
}
.footer td,
.footer p,
.footer span,
.footer a {
color: #999999;
font-size: 12px;
text-align: center;
}
/* -------------------------------------
TYPOGRAPHY
------------------------------------- */
h1,
h2,
h3,
h4 {
color: #000000;
font-family: sans-serif;
font-weight: 400;
line-height: 1.4;
margin: 0;
margin-bottom: 30px;
}
h1 {
font-size: 35px;
font-weight: 300;
text-align: center;
text-transform: capitalize;
}
p,
ul,
ol {
font-family: sans-serif;
font-size: 14px;
font-weight: normal;
margin: 0;
margin-bottom: 15px;
}
p li,
ul li,
ol li {
list-style-position: inside;
margin-left: 5px;
}
a {
color: #3498db;
text-decoration: underline;
}
/* -------------------------------------
BUTTONS
------------------------------------- */
.btn {
box-sizing: border-box;
width: 100%;
}
.btn > tbody > tr > td {
padding-bottom: 15px;
}
.btn table {
width: auto;
}
.btn table td {
background-color: #ffffff;
border-radius: 5px;
text-align: center;
}
.btn a {
background-color: #ffffff;
border: solid 1px #3498db;
border-radius: 5px;
box-sizing: border-box;
color: #3498db;
cursor: pointer;
display: inline-block;
font-size: 14px;
font-weight: bold;
margin: 0;
padding: 12px 25px;
text-decoration: none;
text-transform: capitalize;
}
.btn-primary table td {
background-color: #3498db;
}
.btn-primary a {
background-color: #3498db;
border-color: #3498db;
color: #ffffff;
}
/* -------------------------------------
OTHER STYLES THAT MIGHT BE USEFUL
------------------------------------- */
.last {
margin-bottom: 0;
}
.first {
margin-top: 0;
}
.align-center {
text-align: center;
}
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.clear {
clear: both;
}
.mt0 {
margin-top: 0;
}
.mb0 {
margin-bottom: 0;
}
.preheader {
color: transparent;
display: none;
height: 0;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
mso-hide: all;
visibility: hidden;
width: 0;
}
.powered-by a {
text-decoration: none;
}
hr {
border: 0;
border-bottom: 1px solid #f6f6f6;
margin: 20px 0;
}
/* -------------------------------------
RESPONSIVE AND MOBILE FRIENDLY STYLES
------------------------------------- */
@media only screen and (max-width: 620px) {
table.body h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table.body p,
table.body ul,
table.body ol,
table.body td,
table.body span,
table.body a {
font-size: 16px !important;
}
table.body .wrapper,
table.body .article {
padding: 10px !important;
}
table.body .content {
padding: 0 !important;
}
table.body .container {
padding: 0 !important;
width: 100% !important;
}
table.body .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table.body .btn table {
width: 100% !important;
}
table.body .btn a {
width: 100% !important;
}
table.body .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #34495e !important;
}
.btn-primary a:hover {
background-color: #34495e !important;
border-color: #34495e !important;
}
}
</style>
Now let’s create the app/templates/base.html
that other templates can extend. With this approach, we can easily generate HTML templates like:
- the Email verification template
- the password reset template
- the welcome template
To ensure that all the HTML templates have the CSS styles defined in _styles.html
file, we need to add it to the base.html
template using the include
tag provided by Jinja2.
Also, we will use the {{ }}
syntax to define placeholders for the variables we will be passing to the templates.
With Jinja2, we use {% block %}
tags to define HTML skeletons that will be overridden by child templates.
app/templates/base.html
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>{{subject}}</title>
{% include '_styles.html' %}
</head>
<body>
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
class="body"
>
<tr>
<td> </td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" class="main">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
>
<tr>
<td>{% block content %} {% endblock %}</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
</table>
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td> </td>
</tr>
</table>
</body>
</html>
Since we have the base.html
template defined, let’s create the child template verification.html
to extend the base template.
In the child template, we need to extend the base template using the {% extends %}
tag provided by Jinja2 and write the content between {% block content %}...{% endblock %}
.
app/templates/verification.html
{% extends 'base.html' %} {% block content %}
<p>Hi {{first_name}},</p>
<p>
Thanks for creating an account with us. Please verify your email address by
clicking the button below.
</p>
<table
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
class="btn btn-primary"
>
<tbody>
<tr>
<td align="left">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
<tbody>
<tr>
<td>
<a href="{{url}}" target="_blank">Verify email address</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<p>Good luck! Hope it works.</p>
{% endblock %}
Creating the SMTP Email Sender
By default, Python comes with a built-in smtplib
module for sending SMTP emails but we are going to use the fastapi-mail module since it has support for Jinja2.
Install the fastapi-mail package with this command:
pip install fastapi-mail
Next, create a app/email.py
file and add the following imports:
app/email.py
from typing import List
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig
from pydantic import EmailStr, BaseModel
from .config import settings
from jinja2 import Environment, select_autoescape, PackageLoader
Jinja2 provides a couple of methods that we can use to load the HTML templates from a folder or a directory in the file system.
The PackageLoader
method is used to load HTML templates from a folder located in a Python package.
To turn the directory containing the templates folder into a Python package, you will need to create an empty __init__.py
file in it.
Alternatively, Jinja2 provides the FileSystemLoader()
method that we can use to load the templates from a directory in the file system.
app/email.py
# imports (..)
env = Environment(
loader=PackageLoader('app', 'templates'),
autoescape=select_autoescape(['html', 'xml'])
)
Now let’s create a utility class that will allow us to chain multiple methods to send different emails at once.
app/email.py
from typing import List
from fastapi_mail import FastMail, MessageSchema, ConnectionConfig
from pydantic import EmailStr, BaseModel
from .config import settings
from jinja2 import Environment, select_autoescape, PackageLoader
env = Environment(
loader=PackageLoader('app', 'templates'),
autoescape=select_autoescape(['html', 'xml'])
)
class EmailSchema(BaseModel):
email: List[EmailStr]
class Email:
def __init__(self, user: dict, url: str, email: List[EmailStr]):
self.name = user['name']
self.sender = 'Codevo <admin@admin.com>'
self.email = email
self.url = url
pass
async def sendMail(self, subject, template):
# Define the config
conf = ConnectionConfig(
MAIL_USERNAME=settings.EMAIL_USERNAME,
MAIL_PASSWORD=settings.EMAIL_PASSWORD,
MAIL_FROM=settings.EMAIL_FROM,
MAIL_PORT=settings.EMAIL_PORT,
MAIL_SERVER=settings.EMAIL_HOST,
MAIL_STARTTLS=False,
MAIL_SSL_TLS=False,
USE_CREDENTIALS=True,
VALIDATE_CERTS=True
)
# Generate the HTML template base on the template name
template = env.get_template(f'{template}.html')
html = template.render(
url=self.url,
first_name=self.name,
subject=subject
)
# Define the message options
message = MessageSchema(
subject=subject,
recipients=self.email,
body=html,
subtype="html"
)
# Send the email
fm = FastMail(conf)
await fm.send_message(message)
async def sendVerificationCode(self):
await self.sendMail('Your verification code (Valid for 10min)', 'verification')
Sending the HTML Emails in FastAPI
To begin, let’s generate a random 10-byte string that we will use as the verification code and hash it using the sha256
algorithm.
After hashing the string, we will save it in the database and send the unhashed one to the user’s email.
Now let’s construct the URL and evoke the sendVerificationCode()
method we defined in the Email
class to send the email.
try:
token = randbytes(10)
hashedCode = hashlib.sha256()
hashedCode.update(token)
verification_code = hashedCode.hexdigest()
User.find_one_and_update({"_id": result.inserted_id}, {
"$set": {"verification_code": verification_code, "updated_at": datetime.utcnow()}})
url = f"{request.url.scheme}://{request.client.host}:{request.url.port}/api/auth/verifyemail/{token.hex()}"
await Email(userEntity(new_user), url, [EmailStr(payload.email)]).sendVerificationCode()
except Exception as error:
User.find_one_and_update({"_id": result.inserted_id}, {
"$set": {"verification_code": None, "updated_at": datetime.utcnow()}})
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='There was an error sending email')
Update the SignUp Controller
Now let’s update the create_user()
operation function to send the verification email immediately after the user has been successfully added to the database.
app/routers/auth.py
@router.post('/register', status_code=status.HTTP_201_CREATED)
async def create_user(payload: schemas.CreateUserSchema, request: Request):
# Check if user already exist
user = User.find_one({'email': payload.email.lower()})
if user:
raise HTTPException(status_code=status.HTTP_409_CONFLICT,
detail='Account already exist')
# Compare password and passwordConfirm
if payload.password != payload.passwordConfirm:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail='Passwords do not match')
# Hash the password
payload.password = utils.hash_password(payload.password)
del payload.passwordConfirm
payload.role = 'user'
payload.verified = False
payload.email = payload.email.lower()
payload.created_at = datetime.utcnow()
payload.updated_at = payload.created_at
result = User.insert_one(payload.dict())
new_user = User.find_one({'_id': result.inserted_id})
try:
token = randbytes(10)
hashedCode = hashlib.sha256()
hashedCode.update(token)
verification_code = hashedCode.hexdigest()
User.find_one_and_update({"_id": result.inserted_id}, {
"$set": {"verification_code": verification_code, "updated_at": datetime.utcnow()}})
url = f"{request.url.scheme}://{request.client.host}:{request.url.port}/api/auth/verifyemail/{token.hex()}"
await Email(userEntity(new_user), url, [EmailStr(payload.email)]).sendVerificationCode()
except Exception as error:
User.find_one_and_update({"_id": result.inserted_id}, {
"$set": {"verification_code": None, "updated_at": datetime.utcnow()}})
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail='There was an error sending email')
return {'status': 'success', 'message': 'Verification token successfully sent to your email'}
Create a Handler to Validate the Verification Code
Finally, let’s create a router to validate the verification code and update the user’s credentials to be valid in the database.
@router.get('/verifyemail/{token}')
def verify_me(token: str):
hashedCode = hashlib.sha256()
hashedCode.update(bytes.fromhex(token))
verification_code = hashedCode.hexdigest()
result = User.find_one_and_update({"verification_code": verification_code}, {
"$set": {"verification_code": None, "verified": True, "updated_at": datetime.utcnow()}}, new=True)
if not result:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail='Invalid verification code or account already verified')
return {
"status": "success",
"message": "Account verified successfully"
}
Conclusion
With this Jinja2, FastAPI, PyMongo, MongoDB, FastApi-MAIL, and Docker-compose example, you’ve learned how to send HTML Emails in Python.
Check out the source codes:
Thank you for putting so much effort into this.
Quick question, it is not clear from the article construction, in what file should the code for the “try” block go into?
Apologies, I see you are constructing the code, explaining it, and then inserting it into the next code block.