Programming • Jan 28, 2026 • Cliff

Building A Django SaaS Application (Part 6)

Building a Django SaaS App

Or The Django SaaS Mega-Tutorial

From Scratch to Subscription-Ready (Part 6)


TL;DR

This tutorial adds production-ready email capabilities to your Django SaaS:

Following TDD principles, we'll write tests first, then implement each feature. By the end, your application will send professional transactional emails and you'll understand how to choose and configure email providers.

Estimated time: 45–60 minutes Prerequisites: Completed Tutorials 001–005 (project runs, all tests green)


Introduction: Why Transactional Emails Matter

Tutorials 001–005 built a functional Django SaaS with authentication, link shortening, testing, containerization, and production infrastructure. However, the application currently uses Django's locmem backend for password resets, which means emails exist only in memory during development and are never actually sent.

For a real SaaS application, you need:

  1. Welcome emails: Greet new users and guide their first steps
  2. Password resets: Send secure reset links via email (already built, needs real delivery)
  3. Admin notifications: Alert you when errors occur in production
  4. Future capabilities: Payment confirmations, activity digests, notifications

This tutorial implements a flexible email system that works in development and production.


Part 1: Understanding Email Backend Options

Django Email Backends

Django supports multiple email backends:

Backend Use Case Configuration Complexity
console Development (prints to terminal) None
locmem Testing (stores in memory) None
smtp Production (real email) Medium
filebased Debugging (saves to files) Low

For production, you'll use the SMTP backend with a provider.


Email Provider Comparison

Option 1: Gmail SMTP

Pros: - Free for low-volume sending (<100/day) - Familiar setup for solo founders - No additional service signup required

Cons: - Strict rate limits (100 emails/day per account) - Requires app-specific password (not regular password) - May flag automated sending as suspicious - Not designed for transactional email

Best for: Personal projects, early prototypes, low-volume applications

Configuration:

DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
DJANGO_EMAIL_HOST=smtp.gmail.com
DJANGO_EMAIL_PORT=587
DJANGO_EMAIL_HOST_USER=your-email@gmail.com
DJANGO_EMAIL_HOST_PASSWORD=your-app-password  # Not your regular password!
DJANGO_EMAIL_USE_TLS=True

Setup steps: 1. Enable 2-factor authentication on your Google account 2. Generate an "App Password" at myaccount.google.com/apppasswords 3. Use the 16-character app password in DJANGO_EMAIL_HOST_PASSWORD


Option 2: Mailchimp Transactional (Mandrill)

Pros: - Professional transactional email service - 500 free emails/month (then $0.20/1000) - Excellent deliverability - Analytics dashboard (opens, clicks, bounces) - Templates and API for advanced features

Cons: - Requires separate Mailchimp account - Mandrill has been folded into Mailchimp (branding confusion) - Overkill for very small projects

Best for: SaaS products expecting moderate to high email volume

Configuration:

DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
DJANGO_EMAIL_HOST=smtp.mandrillapp.com
DJANGO_EMAIL_PORT=587
DJANGO_EMAIL_HOST_USER=your-mailchimp-username
DJANGO_EMAIL_HOST_PASSWORD=your-mandrill-api-key
DJANGO_EMAIL_USE_TLS=True

Setup steps: 1. Sign up for Mailchimp 2. Enable Transactional Email in your account settings 3. Generate an API key in the Transactional section 4. Use API key as DJANGO_EMAIL_HOST_PASSWORD


Option 3: SendGrid, Amazon SES, Postmark

Other popular options:

Decision criteria: - Volume: How many emails per day? - Budget: Free tier sufficient or need paid? - Deliverability: How critical is inbox placement? - Analytics: Do you need open/click tracking? - Integration: Existing relationship with provider?

Recommendation for this tutorial: - Development: Use console backend (see emails in terminal) - Production (< 100 emails/day): Gmail SMTP - Production (> 100 emails/day): Mailchimp Transactional or SendGrid


Part 2: Email Configuration (TDD)

Step 1: Write Email Configuration Tests (Red)

Create core/tests_email.py and add configuration tests:

import logging
from unittest.mock import patch

from django.conf import settings
from django.core import mail
from django.test import TestCase, override_settings

from core.email import send_templated_email

logger = logging.getLogger(__name__)


@override_settings(EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend")
class EmailConfigurationTests(TestCase):
    """Tests for email configuration settings."""

    def test_default_from_email_is_configured(self):
        """Test that DEFAULT_FROM_EMAIL is configured."""
        from django.conf import settings

        self.assertTrue(hasattr(settings, "DEFAULT_FROM_EMAIL"))
        self.assertIsInstance(settings.DEFAULT_FROM_EMAIL, str)
        self.assertGreater(len(settings.DEFAULT_FROM_EMAIL), 0)

    def test_server_email_is_configured(self):
        """Test that SERVER_EMAIL is configured."""
        from django.conf import settings

        self.assertTrue(hasattr(settings, "SERVER_EMAIL"))
        self.assertIsInstance(settings.SERVER_EMAIL, str)

    def test_email_backend_is_configured(self):
        """Test that EMAIL_BACKEND is configured."""
        from django.conf import settings

        self.assertTrue(hasattr(settings, "EMAIL_BACKEND"))
        self.assertIn("EmailBackend", settings.EMAIL_BACKEND)

    def test_email_host_is_configured(self):
        """Test that EMAIL_HOST is configured."""
        from django.conf import settings

        self.assertTrue(hasattr(settings, "EMAIL_HOST"))
        self.assertIsInstance(settings.EMAIL_HOST, str)

    def test_email_port_is_configured(self):
        """Test that EMAIL_PORT is configured."""
        from django.conf import settings

        self.assertTrue(hasattr(settings, "EMAIL_PORT"))
        self.assertIsInstance(settings.EMAIL_PORT, int)

    def test_email_timeout_is_configured(self):
        """Test that EMAIL_TIMEOUT is configured."""
        from django.conf import settings

        self.assertTrue(hasattr(settings, "EMAIL_TIMEOUT"))
        self.assertIsInstance(settings.EMAIL_TIMEOUT, int)
        self.assertGreater(settings.EMAIL_TIMEOUT, 0)

Run tests (Red phase):

python manage.py test core.tests_email.EmailConfigurationTests

Step 2: Configure Email Settings (Green)

Edit config/settings.py to add comprehensive email configuration:

# Email configuration
EMAIL_BACKEND = env(
    "DJANGO_EMAIL_BACKEND",
    default=(
        "django.core.mail.backends.console.EmailBackend"
        if DEBUG
        else "django.core.mail.backends.smtp.EmailBackend"
    ),
)
EMAIL_HOST = env("DJANGO_EMAIL_HOST", default="localhost")
EMAIL_PORT = env.int("DJANGO_EMAIL_PORT", default=1025 if DEBUG else 587)
EMAIL_HOST_USER = env("DJANGO_EMAIL_HOST_USER", default="")
EMAIL_HOST_PASSWORD = env("DJANGO_EMAIL_HOST_PASSWORD", default="")
EMAIL_USE_SSL = env.bool("DJANGO_EMAIL_USE_SSL", default=False)
EMAIL_USE_TLS = env.bool("DJANGO_EMAIL_USE_TLS", default=not EMAIL_USE_SSL)
EMAIL_SUBJECT_PREFIX = env("DJANGO_EMAIL_SUBJECT_PREFIX", default="")

EMAIL_TIMEOUT = env.int("DJANGO_EMAIL_TIMEOUT", default=10)
DEFAULT_FROM_EMAIL = env("DJANGO_DEFAULT_FROM_EMAIL", default="noreply@example.com")
SERVER_EMAIL = env("DJANGO_SERVER_EMAIL", default=DEFAULT_FROM_EMAIL)
EMAIL_REPLY_TO = env.list("DJANGO_EMAIL_REPLY_TO", default=[])

# Admin email notifications for errors (500s)
ADMINS = [
    (name.strip(), email.strip())
    for name, email in [
        tuple(admin.split(":"))
        for admin in env.list("DJANGO_ADMINS", default=[])
    ]
]
MANAGERS = ADMINS

Configuration explained:


Step 3: Update Environment Configuration

Edit .env.example to document email settings:

# Email configuration
DJANGO_EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend
DJANGO_DEFAULT_FROM_EMAIL="Your SaaS <noreply@example.com>"
DJANGO_SERVER_EMAIL=alerts@example.com
DJANGO_EMAIL_HOST=smtp.gmail.com
DJANGO_EMAIL_PORT=587
DJANGO_EMAIL_HOST_USER=your-email@example.com
DJANGO_EMAIL_HOST_PASSWORD=your-app-password
DJANGO_EMAIL_USE_TLS=True
DJANGO_EMAIL_USE_SSL=False
DJANGO_EMAIL_TIMEOUT=10
DJANGO_EMAIL_REPLY_TO=support@example.com
DJANGO_EMAIL_SUBJECT_PREFIX="[Django SaaS] "

# Admin error notifications (emails sent on 500 errors when DEBUG=False)
# Format: "Name:email@example.com,Another Name:another@example.com"
DJANGO_ADMINS="Admin:admin@example.com"

# Gmail quickstart (requires an app password; username/password login will fail)
# DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
# DJANGO_EMAIL_HOST=smtp.gmail.com
# DJANGO_EMAIL_PORT=587
# DJANGO_EMAIL_HOST_USER=your-email@gmail.com
# DJANGO_EMAIL_HOST_PASSWORD=your-app-password
# DJANGO_EMAIL_USE_TLS=True

# Mailchimp Transactional (Mandrill) via SMTP
# DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
# DJANGO_EMAIL_HOST=smtp.mandrillapp.com
# DJANGO_EMAIL_PORT=587
# DJANGO_EMAIL_HOST_USER=your-mailchimp-username
# DJANGO_EMAIL_HOST_PASSWORD=your-mailchimp-api-key
# DJANGO_EMAIL_USE_TLS=True

Verify tests pass:

python manage.py test core.tests_email.EmailConfigurationTests

Part 3: Email Utility and Templates (TDD)

Step 1: Write Email Utility Tests (Red)

Add utility tests to core/tests_email.py:

@override_settings(
    EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
    DEFAULT_FROM_EMAIL="test@example.com",
    EMAIL_REPLY_TO=["support@example.com"],
)
class EmailUtilsTests(TestCase):
    """Tests for the email utility functions."""

    def test_send_templated_email_text_only(self):
        """Test sending email renders both text and HTML templates."""
        send_templated_email(
            subject="Test Subject",
            template_base="emails/welcome_email",
            context={"user": type("User", (), {"username": "testuser"})()},
            to=["recipient@example.com"],
        )

        self.assertEqual(len(mail.outbox), 1)
        email = mail.outbox[0]

        self.assertEqual(email.subject, "Test Subject")
        self.assertEqual(email.from_email, "test@example.com")
        self.assertEqual(email.to, ["recipient@example.com"])
        self.assertEqual(email.reply_to, ["support@example.com"])

        # Verify body contains text content
        self.assertIn("testuser", email.body)
        self.assertIn("Welcome to Django SaaS", email.body)

        # Verify HTML alternative is attached
        self.assertEqual(len(email.alternatives), 1)
        html_content, mime_type = email.alternatives[0]
        self.assertEqual(mime_type, "text/html")
        self.assertIn("testuser", html_content)
        self.assertIn("<h2>", html_content)

    def test_send_templated_email_multiple_recipients(self):
        """Test sending email to multiple recipients."""
        send_templated_email(
            subject="Test Multiple",
            template_base="emails/welcome_email",
            context={"user": type("User", (), {"username": "multi"})()},
            to=["one@example.com", "two@example.com", "three@example.com"],
        )

        self.assertEqual(len(mail.outbox), 1)
        email = mail.outbox[0]
        self.assertEqual(len(email.to), 3)
        self.assertIn("one@example.com", email.to)
        self.assertIn("two@example.com", email.to)
        self.assertIn("three@example.com", email.to)

    @override_settings(EMAIL_REPLY_TO=[])
    def test_send_templated_email_no_reply_to(self):
        """Test sending email when EMAIL_REPLY_TO is not configured."""
        send_templated_email(
            subject="Test No Reply",
            template_base="emails/welcome_email",
            context={"user": type("User", (), {"username": "noreply"})()},
            to=["test@example.com"],
        )

        self.assertEqual(len(mail.outbox), 1)
        email = mail.outbox[0]
        self.assertEqual(email.reply_to, [])

    def test_send_templated_email_logs_info(self):
        """Test that email sending logs appropriate information."""
        with self.assertLogs("core.email", level="INFO") as cm:
            send_templated_email(
                subject="Log Test",
                template_base="emails/welcome_email",
                context={"user": type("User", (), {"username": "logger"})()},
                to=["log@example.com"],
            )

        self.assertTrue(any("Email sent" in message for message in cm.output))

Run tests (Red phase):

python manage.py test core.tests_email.EmailUtilsTests

Step 2: Create Email Utility (Green)

Create core/email.py:

import logging
from typing import Mapping, Sequence

from django.conf import settings
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string

logger = logging.getLogger(__name__)


def send_templated_email(
    *,
    subject: str,
    template_base: str,
    context: Mapping[str, object],
    to: Sequence[str],
):
    """Render text/HTML email templates and send the message."""
    text_body = render_to_string(f"{template_base}.txt", context)
    html_body = render_to_string(f"{template_base}.html", context)

    message = EmailMultiAlternatives(
        subject=subject,
        body=text_body,
        from_email=settings.DEFAULT_FROM_EMAIL,
        to=list(to),
        reply_to=getattr(settings, "EMAIL_REPLY_TO", []),
    )
    message.attach_alternative(html_body, "text/html")

    sent_count = message.send(fail_silently=False)
    logger.info(
        "Email sent",
        extra={"subject": subject, "to": list(to), "sent": sent_count},
    )
    return message

Why this design?


Step 3: Create Email Templates (Green)

Create templates/emails/welcome_email.txt:

Hi {{ user.username }},

Welcome to Django SaaS! We're glad you're here.

You can log in at {{ login_url }} and start creating and sharing links.

If you have any questions, just reply to this email and we will help.

Thanks,
The Django SaaS Team

Create templates/emails/welcome_email.html:

<div style="font-family: Arial, sans-serif; line-height: 1.6;">
  <h2>Welcome to Django SaaS, {{ user.username }}!</h2>
  <p>We're glad you're here.</p>
  <p>
    You can <a href="{{ login_url }}">log in</a> to start creating and sharing links.
  </p>
  <p>If you have any questions, just reply to this email and we will help.</p>
  <p>Thanks,<br />The Django SaaS Team</p>
</div>

Email template best practices:

Verify tests pass:

python manage.py test core.tests_email.EmailUtilsTests

Part 4: Welcome Email on Registration (TDD)

Step 1: Write Welcome Email Test (Red)

Add test to accounts/tests.py:

@override_settings(
    EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
    DEFAULT_FROM_EMAIL="product@example.com",
)
class RegistrationEmailTests(TestCase):
    def setUp(self):
        self.client = Client()

    def test_welcome_email_sent_on_registration(self):
        response = self.client.post(
            reverse("register"),
            {
                "username": "welcomeuser",
                "email": "welcome@example.com",
                "password1": "securepassword123",
                "password2": "securepassword123",
            },
        )

        self.assertRedirects(response, reverse("login"))
        self.assertEqual(len(mail.outbox), 1)

        email = mail.outbox[0]
        self.assertEqual(email.subject, "Welcome to Django SaaS")
        self.assertEqual(email.from_email, "product@example.com")
        self.assertIn("welcomeuser", email.body)
        self.assertGreaterEqual(len(email.alternatives), 1)
        self.assertEqual(email.alternatives[0][1], "text/html")

Run test (Red phase):

python manage.py test accounts.tests.RegistrationEmailTests

Step 2: Send Welcome Email (Green)

Edit accounts/views.py to import and use the email utility:

from django.urls import reverse, reverse_lazy
from core.email import send_templated_email

class RegisterView(CreateView):
    model = CustomUser
    form_class = CustomUserCreationForm
    template_name = "accounts/register.html"
    success_url = reverse_lazy("login")

    def form_valid(self, form):
        response = super().form_valid(form)
        logger.info(
            "New user registered",
            extra={"user_id": self.object.id},
        )
        # Use an absolute URL for emails and avoid breaking registration on failures
        try:
            login_url = self.request.build_absolute_uri(reverse("login"))
            send_templated_email(
                subject="Welcome to Django SaaS",
                template_base="emails/welcome_email",
                context={"user": self.object, "login_url": login_url},
                to=[self.object.email],
            )
        except Exception as e:
            logger.error(f"Failed to send welcome email: {e}", exc_info=True)
        messages.success(self.request, "Account created successfully! Please log in.")
        return response

Verify test passes:

python manage.py test accounts.tests.RegistrationEmailTests

Verify all accounts tests still pass:

python manage.py test accounts

Part 5: Test Email Command

Create Management Command

Create core/management/commands/test_email.py:

import logging

from django.conf import settings
from django.core.management.base import BaseCommand

from core.email import send_templated_email

logger = logging.getLogger(__name__)


class Command(BaseCommand):
    help = "Send a test email to verify email configuration"

    def add_arguments(self, parser):
        parser.add_argument(
            "recipient",
            type=str,
            help="Email address to send the test email to",
        )

    def handle(self, *args, **options):
        recipient = options["recipient"]

        self.stdout.write(f"Sending test email to {recipient}...")
        self.stdout.write(f"Using backend: {settings.EMAIL_BACKEND}")
        self.stdout.write(f"SMTP host: {settings.EMAIL_HOST}:{settings.EMAIL_PORT}")
        self.stdout.write(f"From: {settings.DEFAULT_FROM_EMAIL}")

        try:
            # Create a mock user object for the template
            mock_user = type("User", (), {"username": "Test User"})()

            send_templated_email(
                subject="Test Email from Django SaaS",
                template_base="emails/welcome_email",
                context={
                    "user": mock_user,
                    "login_url": "http://localhost:8000/accounts/login/",
                },
                to=[recipient],
            )

            self.stdout.write(
                self.style.SUCCESS(f"✓ Test email sent successfully to {recipient}")
            )
            self.stdout.write("\nCheck your inbox (and spam folder).")

        except Exception as e:
            self.stdout.write(
                self.style.ERROR(f"✗ Failed to send test email: {str(e)}")
            )
            logger.error(f"Test email failed: {e}", exc_info=True)
            raise

Test the Command

# With console backend (default in development)
python manage.py test_email your-email@example.com

You should see the email printed to your terminal.

To test with real SMTP:

  1. Update your .env file with real credentials:
DJANGO_EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
DJANGO_EMAIL_HOST=smtp.gmail.com
DJANGO_EMAIL_PORT=587
DJANGO_EMAIL_HOST_USER=your-email@gmail.com
DJANGO_EMAIL_HOST_PASSWORD=your-app-password
DJANGO_EMAIL_USE_TLS=True
DJANGO_DEFAULT_FROM_EMAIL="Django SaaS <noreply@example.com>"
  1. Run the command again:
python manage.py test_email your-email@example.com
  1. Check your inbox (and spam folder)!

Part 6: Verification and Best Practices

Run Full Test Suite

python manage.py test

You should see 89+ tests passing, including new email tests.


Email Best Practices

1. SPF, DKIM, and DMARC

For production, configure these DNS records to improve deliverability:

Most email providers (Mailchimp, SendGrid, etc.) provide these records in their setup documentation.

2. From Address

Use a real domain you control:

DEFAULT_FROM_EMAIL = "Django SaaS <noreply@yourdomain.com>"

Avoid: - @gmail.com addresses (looks unprofessional) - noreply@ if you actually want replies (use support@ instead)

3. Reply-To Header

Always provide a way for users to respond:

DJANGO_EMAIL_REPLY_TO=support@yourdomain.com

4. Error Handling

In production, wrap email sending in try/except to avoid breaking user flows:

try:
    send_templated_email(...)
except Exception as e:
    logger.error(f"Failed to send welcome email: {e}")
    # Don't prevent registration from completing

5. Rate Limiting

Be mindful of provider limits:

For high volume, upgrade to a paid plan.


Summary: What We Built

Feature Purpose Tests Added
Email configuration Environment-based SMTP setup 6
Email utility Template-based email sending 4
Welcome emails Greet new registrations 1
Test command Verify configuration N/A

Key Files Created/Modified

Production Readiness Achieved

Your Django SaaS now has:

Flexible email backend supporting development and production ✅ SMTP configuration for Gmail, Mailchimp, SendGrid, and others ✅ Welcome emails sent on registration ✅ HTML and text formats for maximum compatibility ✅ Admin notifications configured for error reporting ✅ Test command to verify setup ✅ 89+ tests all passing


Next Steps

  1. Choose a provider: Gmail for small projects, Mailchimp/SendGrid for production
  2. Configure DNS: Set up SPF, DKIM, and DMARC records
  3. Test thoroughly: Send test emails to multiple providers (Gmail, Outlook, etc.)
  4. Monitor deliverability: Watch for bounces and spam complaints
  5. Add more templates: Password reset confirmation, link creation notifications, etc.

Future tutorials will cover:


Troubleshooting

Email not sending (Gmail)

Problem: SMTPAuthenticationError

Solution: You must use an App Password, not your regular Gmail password: 1. Enable 2-factor authentication on your Google account 2. Visit myaccount.google.com/apppasswords 3. Generate a new app password 4. Use the 16-character password in DJANGO_EMAIL_HOST_PASSWORD


Emails going to spam

Problem: Welcome emails landing in spam folder

Solutions: 1. Configure SPF, DKIM, and DMARC records 2. Use a professional From address with your own domain 3. Avoid spam trigger words ("free", "click here", excessive caps) 4. Include an unsubscribe link (required for marketing, good practice for transactional) 5. Send from a warmed-up IP address (established sender reputation)


Timeout errors

Problem: SMTPServerDisconnected or timeout errors

Solutions: 1. Verify EMAIL_HOST and EMAIL_PORT are correct 2. Check if your hosting provider blocks outbound SMTP (ports 25, 587, 465) 3. Increase EMAIL_TIMEOUT setting 4. Try a different email provider


References


Published January 13, 2026 By Cliff

We build software the same way we write about it: Robust. Tested. Correct.

At McIndi Solutions, we specialize in mission-critical modernization and high-security platforms for healthcare and finance. Whether you need a fractional CTO to guide your architecture or a senior engineering team to unblock a complex automation challenge, we are available for advisory and hands-on engagements.

Email us at sales@mcindi.com to discuss your project.