Home

Blog

Home

Blog

Python & FastAPI: Building a Production-Ready Email Service

10 min read
Diagram illustrating a Python email service using FastAPI and a background task queue with ARQ and Redis for production reliability.

Sending emails is a fundamental feature for most web applications. Whether it's for user registration, notifications, or sending receipts, you need a reliable way to do it. This guide will walk you through building a clean, reusable email service in Python.

We'll use Pydantic for configuration, Jinja2 for templating, and wrap it all in a Python class that's easy to use and test.

What We'll Build

We are going to create an EmailSender class that can:

  • Connect to any SMTP server using Python's built-in smtplib.
  • Render dynamic HTML templates with Jinja2.
  • Send emails with attachments (like generated PDFs).
  • Handle configuration safely using Pydantic Settings.
  • Be easily integrated into a FastAPI application.

Project Setup and Installation

Before we begin, make sure you have the necessary packages installed. It's a best practice to manage your dependencies in a virtual environment.

Prerequisites: To send emails, you will need access to an SMTP server. This could be from a service like Gmail, Outlook, or a dedicated transactional email provider. You will need the following credentials:

  • SMTP Host (e.g., smtp.gmail.com)
  • SMTP Port (e.g., 587)
  • A username and password for authentication.

For a reproducible project, save these dependencies to a requirements.txt file:

# requirements.txt
pydantic[email]
pydantic-settings
jinja2

Step 1: Setting Up the Configuration

First, let's handle our settings. Hardcoding credentials in your code is a bad idea. A better approach is to use environment variables and load them into a Pydantic model. This gives you validation and type-hinting for free.

For a deep dive into managing configurations, check out my guide on centralizing FastAPI configuration with Pydantic Settings.

Let's create a config.py file.

# filepath: app/config.py
from pydantic import BaseModel
from pydantic_settings import BaseSettings


class EmailConfigSettings(BaseSettings):
    """
    Pydantic model for SMTP server configuration.
    Reads settings from environment variables.
    """
    SMTP_HOST: str
    SMTP_PORT: int = 587
    SMTP_USER: str
    SMTP_PASSWORD: str
    EMAILS_FROM_EMAIL: str
    EMAILS_FROM_NAME: str = "My Application"

    class Config:
        env_file = ".env"


# Instantiate the settings
email_settings = EmailConfigSettings()

This model will automatically load values from a .env file in your project root.

# .env
SMTP_HOST="smtp.example.com"
SMTP_PORT=587
SMTP_USER="your-email@example.com"
SMTP_PASSWORD="your-super-secret-password"
EMAILS_FROM_EMAIL="no-reply@example.com"
EMAILS_FROM_NAME="My Awesome App"

Step 2: Creating the Email Sender Class

Let's create an email_sender.py file. This class will encapsulate all the logic for connecting to the SMTP server and sending the message.

# filepath: app/email_sender.py
import logging
import smtplib
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr
from pathlib import Path
from typing import Optional

from jinja2 import Template

from .config import EmailConfigSettings

# Set up basic logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class EmailSender:
    def __init__(self, config: EmailConfigSettings):
        self.config = config

    def send_email(
        self,
        email_to: str,
        subject: str,
        html_template: str,
        template_body: dict,
        attachment: Optional[bytes] = None,
        attachment_filename: Optional[str] = None,
        email_cc: Optional[str] = None,
    ) -> bool:
        """
        Sends an email with HTML content and an optional attachment.

        Returns:
            bool: True if the email was sent successfully, False otherwise.
        """
        if not self.config.EMAILS_FROM_EMAIL:
            logger.error("Sender email (EMAILS_FROM_EMAIL) is not configured.")
            return False

        # 1. Render the HTML template
        template = Template(html_template)
        html_content = template.render(**template_body)

        # 2. Create the email message
        msg = MIMEMultipart()
        msg["From"] = formataddr((self.config.EMAILS_FROM_NAME, self.config.EMAILS_FROM_EMAIL))
        msg["To"] = email_to.replace(";", ",")
        if email_cc:
            msg["Cc"] = email_cc.replace(";", ",")
        msg["Subject"] = subject
        msg.attach(MIMEText(html_content, "html"))

        # 3. Add attachment if provided
        if attachment and attachment_filename:
            part = MIMEApplication(attachment, Name=attachment_filename)
            part["Content-Disposition"] = f'attachment; filename="{attachment_filename}"'
            msg.attach(part)

        # 4. Send the email
        try:
            # Using a context manager for the SMTP connection
            with smtplib.SMTP(self.config.SMTP_HOST, self.config.SMTP_PORT) as server:
                server.starttls()
                server.login(self.config.SMTP_USER, self.config.SMTP_PASSWORD)
                server.send_message(msg)
            logger.info(f"Email sent successfully to {email_to}")
            return True
        except (smtplib.SMTPException, ConnectionRefusedError, socket.gaierror) as e:
            # The `socket.gaierror` is included to catch DNS resolution errors,
            # which can happen if the SMTP_HOST is incorrect or unreachable.
            # See Python docs for more info: https://docs.python.org/3/library/socket.html
            logger.critical(f"Failed to send email to {email_to}. Error: {e}")
            return False

Dissecting the EmailSender Class

This class is designed to be robust and reusable. Here are the key design choices:

  • Encapsulation: The configuration and sending logic are contained within the EmailSender class. This makes it a self-contained, portable component that you can easily reuse across different parts of your application.
  • Robust Connection Handling: The with smtplib.SMTP(...) as server: block uses a context manager. This is a Python best practice that ensures the connection to the SMTP server is always closed properly, even if errors occur during the process. It is cleaner and safer than manually managing the connection with try...finally.
  • Pragmatic Error Handling: The try...except block catches smtplib.SMTPException and common network errors like ConnectionRefusedError. This prevents the application from crashing if the SMTP server is unavailable and allows the method to return a clear False status. For a production system, you might want more granular logging, but this approach is robust for most use cases.

Step 3: Creating an HTML Template

Your emails shouldn't be plain text. Let's create a simple HTML template using Jinja2. Create a templates folder and add a file named welcome_email.html.

<!-- filepath: templates/welcome_email.html -->
<!DOCTYPE html>
<html>
<head>
    <title>Welcome!</title>
</head>
<body>
    <h1>Welcome to {{ project_name }}, {{ username }}!</h1>
    <p>We're so glad to have you.</p>
</body>
</html>

Step 4: Putting It All Together

Now, let's use our new class to send a welcome email.

# filepath: main.py
from pathlib import Path
from app.config import email_settings
from app.email_sender import EmailSender

# 1. Instantiate the sender with our settings
email_sender = EmailSender(config=email_settings)

# 2. Read the HTML template from the file
template_path = Path("templates/welcome_email.html")
html_template = template_path.read_text()

# 3. Define the content for the template
template_body = {
    "project_name": "My Awesome App",
    "username": "David",
}

# 4. Send the email
success = email_sender.send_email(
    email_to="test-recipient@example.com",
    subject="Welcome Aboard!",
    html_template=html_template,
    template_body=template_body,
)

if success:
    print("Email sent!")
else:
    print("Failed to send email.")

Step 5: Integration Testing with Pytest

How do you know your email code works without actually sending emails every time you run your tests? For real-world sending, an integration test is perfect.

Your approach using pytest and caplog is solid. It confirms that your function can execute without hitting a critical error, which implies the connection and sending process was likely successful.

Here's how you can write an integration test for our new class.

# filepath: tests/test_email_integration.py
import logging
import pytest
from app.config import email_settings
from app.email_sender import EmailSender

@pytest.mark.integration
def test_send_email_integration(caplog):
    """
    Integration Test: Sends a real email using the configured SMTP server.
    Asserts that no critical errors are logged.
    """
    # Arrange
    email_sender = EmailSender(config=email_settings)
    recipient = "hello@davidmuraya.com"
    subject = "Integration Test Email"
    html_template = "<h1>Test</h1><p>This is a test email.</p>"
    template_body = {}

    # Act
    with caplog.at_level(logging.CRITICAL):
        success = email_sender.send_email(
            email_to=recipient,
            subject=subject,
            html_template=html_template,
            template_body=template_body,
        )

    # Assert
    assert success is True
    # Check that no CRITICAL level logs were generated
    assert not any(record.levelno == logging.CRITICAL for record in caplog.records)

To run only your integration tests, you can use pytest -m integration.

Frequently Asked Questions (FAQ)

1. How do I send emails in the background in FastAPI? In a web application, you should never make a user wait for an email to send.

For simple use cases, FastAPI's built-in BackgroundTasks is sufficient. However, for production applications, I highly recommend using a dedicated job queue like ARQ. This allows you to implement robust retry logic for failed emails (e.g., if the SMTP server blips) and keeps your web server responsive.

I have written a complete guide on managing background tasks with ARQ, but here is a quick example of how to set up an email worker:

# filepath: app/worker.py
from arq.connections import RedisSettings
from app.config import email_settings
from app.email_sender import EmailSender

# Initialize the sender once when the worker starts
email_sender = EmailSender(config=email_settings)

async def send_welcome_email_task(ctx, email_to: str, username: str):
    """
    An ARQ task that sends a welcome email in the background.
    """
    # In a real app, you would use the Jinja2 environment to load the template
    # by name, not by hardcoding the string.
    email_sender.send_email(
        email_to=email_to,
        subject="Welcome to the Platform",
        template_name="welcome_email.html",
        template_body={"username": username, "project_name": "My App"}
    )

# The WorkerSettings class is how ARQ discovers tasks and configures the worker.
class WorkerSettings:
    """
    Defines the ARQ worker's configuration.
    """
    # The 'functions' list tells the worker which tasks it can execute.
    functions = [send_welcome_email_task]
    # Connection settings for Redis.
    redis_settings = RedisSettings.from_dsn("redis://localhost:6379")

You can then enqueue this job from your FastAPI route. The arq_pool.enqueue_job call uses the function name as the first argument.

@app.post("/register")
async def register_user(user_data: UserCreate, arq_pool: ArqRedis = Depends(get_arq_pool)):
    # ... save user to database ...

    # Enqueue the email task
    await arq_pool.enqueue_job("send_welcome_email_task", email_to=user_data.email, username=user_data.name)

    return {"message": "User registered successfully!"}

2. Can I use Gmail or Outlook 365 as my SMTP server? Yes, but it requires specific configuration.

For Gmail, you will likely need to create an App Password instead of using your regular login credentials.

For Outlook 365 (especially if managed through providers like GoDaddy), SMTP Authentication is often disabled by default. You must explicitly enable "Authenticated SMTP" for the specific user in the Microsoft 365 Admin Center (under Active Users > Mail > Manage email apps). Without this, your connection attempts will fail with authentication errors.

For production, use a dedicated transactional email service like SendGrid, Mailgun, or Amazon SES.

3. How do I handle multiple recipients? The To and Cc fields can accept a comma-separated string of email addresses, like "user1@example.com,user2@example.com". Our code already handles replacing semicolons with commas for convenience.

4. Is this code secure? The code uses STARTTLS to encrypt the connection to the SMTP server, which is standard practice. The main security concern is managing your SMTP_PASSWORD. Never commit it to version control. Using environment variables and a .env file (which should be in your .gitignore) is a good first step.

For production environments, especially on Google Cloud, you should use a dedicated secret management system. I have written a detailed guide on securing FastAPI environment variables with Google Secret Manager.

Share This Article

About the Author

David Muraya is a Solutions Architect specializing in Python, FastAPI, and Cloud Infrastructure. He is passionate about building scalable, production-ready applications and sharing his knowledge with the developer community. You can connect with him on LinkedIn.

Related Blog Posts

Enjoyed this blog post? Check out these related posts!

Secure FastAPI Environment Variables on Cloud Run with Secret Manager

Secure FastAPI Environment Variables on Cloud Run with Secret Manager

A Step-by-Step Guide to Managing Production Secrets on Google Cloud.

Read More...

Function Calling in Google Gemma3

Function Calling in Google Gemma3

Understanding Function Calling in Google Gemma3

Read More...

Reusable Model Fields in SQLModel with Mixins

Reusable Model Fields in SQLModel with Mixins

A Guide to Creating DRY Database Models with Timestamps and Base Models.

Read More...

A Guide to Authentication in FastAPI with JWT

A Guide to Authentication in FastAPI with JWT

From Basic Auth to OAuth2 with Password Flow and JWT Tokens.

Read More...

On this page

Back to Blogs

Contact Me

Have a project in mind? Send me an email at hello@davidmuraya.com and let's bring your ideas to life. I am always available for exciting discussions.

© 2025 David Muraya. All rights reserved.