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.
We are going to create an EmailSender class that can:
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.gmail.com)587)For a reproducible project, save these dependencies to a requirements.txt file:
# requirements.txt pydantic[email] pydantic-settings jinja2
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"
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
EmailSender ClassThis class is designed to be robust and reusable. Here are the key design choices:
EmailSender class. This makes it a self-contained, portable component that you can easily reuse across different parts of your application.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.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.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>
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.")
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.
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.
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
A Step-by-Step Guide to Managing Production Secrets on Google Cloud.
Read More...


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
From Basic Auth to OAuth2 with Password Flow and JWT Tokens.
Read More...
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.