Email primitives for Django projects.
harry is a set of independent modules that share one package. Each module is adoptable on its own — installing the package costs nothing until you wire a module into your settings, and no module requires another. The goal is that every project that installs harry sends email the same way.
| Module | What it does | Needs |
|---|---|---|
harry.email |
Transactional email via anymail, persisted to your database with delivery tracking | the harry[email] extra + a transactional ESP |
harry is not on PyPI; install it from git. In the consuming project's
pyproject.toml:
dependencies = [
"harry @ git+https://github.com/hkhanna/django-harry",
]Each module's third-party dependencies ship as an extra, so a project only
installs what it wires up: Email needs the email extra. Name what
you use in the requirement:
dependencies = [
"harry[email] @ git+https://github.com/hkhanna/django-harry",
]Transactional email through django-anymail, with every
message persisted as an EmailMessage record in your database and delivery
tracked via ESP webhooks.
Requires: the harry[email] extra (see Installation) and
a transactional ESP account. Nothing else from harry.
INSTALLED_APPS = [
# ...
"anymail",
"harry.email",
]harry uses anymail to send email through any transactional ESP. Configure your
provider in settings.py:
# Example using Mailgun
EMAIL_BACKEND = "anymail.backends.mailgun.EmailBackend"
ANYMAIL = {
"MAILGUN_API_KEY": env("MAILGUN_API_KEY"),
"MAILGUN_WEBHOOK_SIGNING_KEY": env("MAILGUN_WEBHOOK_SIGNING_KEY")
}See the anymail docs for the full list of supported providers and their settings.
harry pulls sender defaults and template context from a SITE_CONFIG dict in
settings. Values here are used as fallbacks when not provided per-message.
SITE_CONFIG = {
"name": "My App",
"default_from_name": "My App",
"default_from_email": "hello@myapp.com",
"company": "My App Inc.",
"company_address": "123 Main St",
"company_city_state_zip": "New York, NY 10001",
"contact_email": "support@myapp.com",
"logo_url": "https://myapp.com/logo.png",
"logo_url_link": "https://myapp.com",
}
MAX_SUBJECT_LENGTH = 78 # Subjects are truncated to this lengthEach email needs a template prefix that maps to template files in your Django template directories:
templates/
account/
welcome_subject.txt # Subject line (rendered as a template)
welcome_message.txt # Plain text body (required)
welcome_message.html # HTML body (optional)
Templates receive SITE_CONFIG values as context by default, plus any custom
context you pass. For example, welcome_message.txt:
Hi {{ to_name }},
Welcome to {{ site_name }}!
Thanks,
{{ company }}
{{ company_address }}
{{ company_city_state_zip }}
python manage.py migrateharry stores webhook events from your ESP so you can track whether emails were
delivered, opened, bounced, or marked as spam. Add anymail's webhook URLs to
your root urls.py:
from django.urls import include, path
urlpatterns = [
# ...
path("anymail/", include("anymail.urls")),
]Then configure a webhook secret in settings:
ANYMAIL = {
"MAILGUN_API_KEY": env("MAILGUN_API_KEY"),
"MAILGUN_WEBHOOK_SIGNING_KEY": env("MAILGUN_WEBHOOK_SIGNING_KEY")
"WEBHOOK_SECRET": env("ANYMAIL_WEBHOOK_SECRET"),
}Generate a webhook secret:
python -c "from django.utils.crypto import get_random_string; print(':'.join(get_random_string(16) for _ in range(2)))"Register the webhook URL with your ESP, using the secret as HTTP basic auth credentials:
https://<part1>:<part2>@yourdomain.com/anymail/mailgun/tracking/
The primary API is a set of service functions.
from harry.email.services import (
email_message_create,
email_message_queue,
)
email = email_message_create(
created_by=user,
to_name="Alice",
to_email="alice@example.com",
template_prefix="account/welcome",
template_context={"coupon_code": "HELLO10"},
)
email_message_queue(email_message=email)email_message_queue prepares the message (applies defaults, renders the
subject, validates) and sends it. It returns True if the email was sent, or
False if it was suppressed by cooldown (see below).
The subject is rendered from {template_prefix}_subject.txt by default. You
can also set it explicitly:
email = email_message_create(
to_name="Alice",
to_email="alice@example.com",
template_prefix="account/welcome",
subject="Welcome aboard!",
)email = email_message_create(
to_name="Alice",
to_email="alice@example.com",
template_prefix="account/welcome",
sender_name="Support Team",
sender_email="support@myapp.com",
reply_to_name="Support",
reply_to_email="support@myapp.com",
)If sender_name and sender_email are omitted, they fall back to
SITE_CONFIG["default_from_name"] and SITE_CONFIG["default_from_email"].
Attach files after preparing the message:
from harry.email.services import (
email_message_create,
email_message_prepare,
email_message_attach,
email_message_queue,
)
email = email_message_create(
to_name="Alice",
to_email="alice@example.com",
template_prefix="billing/invoice",
)
email_message_prepare(email_message=email)
# From a file object
with open("invoice.pdf", "rb") as f:
email_message_attach(
email_message=email,
file=f,
filename="invoice.pdf",
mimetype="application/pdf",
)
# From bytes
email_message_attach(
email_message=email,
file=b"Name,Amount\nAlice,100",
filename="data.csv",
mimetype="text/csv",
)
email_message_queue(email_message=email)Note: email_message_attach requires the message to be in READY status. Call
email_message_prepare first, then attach files, then call
email_message_queue (which skips re-preparing an already-READY message).
email_message_queue has built-in duplicate suppression. By default, it
prevents sending the same template to the same recipient by the same user more
than once within 180 seconds.
# Defaults: 180s cooldown, 1 allowed, scoped to user + template + recipient
email_message_queue(email_message=email)
# Custom cooldown: allow 3 of the same template to the same recipient in 60s
email_message_queue(
email_message=email,
cooldown_period=60,
cooldown_allowed=3,
scopes=["template_prefix", "to"],
)
# No cooldown scoping: suppress if ANY email was sent in the last 60s
email_message_queue(
email_message=email,
cooldown_period=60,
cooldown_allowed=1,
scopes=[],
)Available scopes: "created_by", "template_prefix", "to". Suppressed
emails are saved with status CANCELED.
from harry.email.services import email_message_duplicate
duplicate = email_message_duplicate(original=email)
# duplicate is a new EmailMessage in READY status with all attachments copied
email_message_queue(email_message=duplicate)All emails are persisted as Django model instances:
from harry.email.models import EmailMessage
# All emails to a recipient
EmailMessage.objects.filter(to_email="alice@example.com")
# Failed emails
EmailMessage.objects.filter(status="error")
# Bounced emails in the last 24 hours
from django.utils import timezone
from datetime import timedelta
EmailMessage.objects.filter(
status="bounced",
sent_at__gte=timezone.now() - timedelta(hours=24),
)Every EmailMessage moves through these statuses:
CANCELED
▲
│
NEW ──> READY ──┼──> PENDING ──> SUBMITTED ──> DELIVERED
│ │ │
▼ ▼ ├──> OPENED ──> CLICKED
ERROR ERROR ├──> BOUNCED
├──> REJECTED
├──> COMPLAINED
└──> UNSUBSCRIBED
Everything after SUBMITTED comes from ESP tracking webhooks. The ESP statuses
match Anymail's normalized event types one for one, so each webhook's
event_type is stored as-is. Unrecognized event types are stored as UNKNOWN.
| Status | Meaning |
|---|---|
NEW |
Created, not yet prepared |
READY |
Prepared with defaults applied, validated, ready to send |
PENDING |
Handed off to the email backend |
SUBMITTED |
Backend accepted the message and returned a message ID |
QUEUED |
ESP confirmed it has the message (Mailgun calls this "accepted") |
SENT |
ESP has handed the message to the recipient's mail server |
DEFERRED |
Soft failure, ESP will retry |
DELIVERED |
ESP confirmed delivery to recipient's mail server |
AUTORESPONDED |
Recipient's mail server sent an automatic reply |
OPENED |
Recipient opened the email (if tracking is enabled) |
CLICKED |
Recipient clicked a link in the email (if tracking is enabled) |
BOUNCED |
Delivery failed (hard or soft bounce) |
REJECTED |
ESP rejected the message before delivery |
FAILED |
ESP was unable to send the message |
COMPLAINED |
Recipient marked the email as spam |
UNSUBSCRIBED |
Recipient unsubscribed via email headers |
SUBSCRIBED |
Recipient re-subscribed |
UNKNOWN |
ESP reported an unrecognized event type |
CANCELED |
Suppressed by cooldown |
ERROR |
Something went wrong during preparation or sending |
This project uses uv for dependency management.
# Install dependencies (--all-extras so the email tests run)
uv sync --all-extrasuv run pytestFormat and lint code:
make formatRun type checking:
make mypyCreate new migrations:
make migrations