Email Tracking¶
Track delivery status and bounces for every email you send through Lettermint. Tracking is optional and is switched on by adding lettermint_django to INSTALLED_APPS. Without it, the backend only sends.
How it works¶
- When the backend sends an email, the
message_idreturned by the Lettermint API is stored as anLmEmailMessage. - Lettermint calls your webhook endpoint on every status change (delivered, bounced, failed, ...).
- The endpoint verifies the signature, stores an
LmEmailEvent, updates the message status and emits a Django signal.
Webhooks are processed synchronously and idempotently. A delivery that Lettermint retries with the same event id is acknowledged but not stored twice.
Setup¶
1. Install the app and run migrations¶
import os
INSTALLED_APPS = [
# ...
"lettermint_django",
]
EMAIL_BACKEND = "lettermint_django.LettermintEmailBackend"
LETTERMINT_API_KEY = os.getenv("LETTERMINT_API_KEY")
LETTERMINT_WEBHOOK_SECRET = os.getenv("LETTERMINT_WEBHOOK_SECRET")
python manage.py migrate lettermint_django
2. Add the webhook URL¶
# urls.py
from django.urls import include, path
urlpatterns = [
# ...
path("", include("lettermint_django.urls")),
]
This exposes POST /lettermint/message-events/ (URL name lm-message-events). The view is CSRF-exempt and only accepts POST.
The path is the setting LETTERMINT_WEBHOOK_PATH, so include the URLs at the root and change the setting to move the endpoint:
LETTERMINT_WEBHOOK_PATH = os.getenv("LETTERMINT_WEBHOOK_PATH", "lmnt/events/") # -> POST /lmnt/events/
Register exactly that path in Lettermint, including the trailing slash. reverse("lm-message-events") returns it.
3. Create the webhook in Lettermint¶
In the Lettermint dashboard open Webhooks and add one webhook pointing at https://your-domain.example/lettermint/message-events/. One webhook is enough for everything this Django project sends, single or bulk: events are matched on message_id, so the webhook's scope (team, project or route) only needs to cover the routes you send through. For bounce and delivery tracking select at least:
message.deliveredmessage.soft_bouncedmessage.hard_bouncedmessage.failed
Add message.opened and message.clicked if not_opened() should mean anything. Copy the signing secret into LETTERMINT_WEBHOOK_SECRET; it holds one secret, so use one webhook per Django project rather than several pointing at the same URL. The dashboard's test button sends a webhook.test event, which the endpoint acknowledges without storing anything.
What is stored, what is acknowledged
Every outbound message.* event with a message_id is stored as an LmEmailEvent, including events this version does not act on (spam complaints, unsubscribes). Only the events listed under what happens per event with a status change the message status. message.inbound and the suppression.* events are answered with 200 but not stored: inbound mail is not supported, and a message.inbound payload carries the complete incoming message. Leave those events unticked for this webhook.
Models¶
Import from lettermint_django.models.
LmEmailMessage¶
One row per email sent through the backend.
| Field | Description |
|---|---|
message_id |
Lettermint message id (unique) |
from_email, to, cc, bcc |
Sender and recipient lists as passed to Django |
subject |
Subject line |
route |
Lettermint route used, if any |
tag |
Lettermint tag from the X-Lettermint-Tag header, e.g. a campaign name |
bulk_id |
Id of the bulk send this message was part of; empty for single sends |
status |
Current status, see what happens per event (starts as pending, or scheduled for a scheduled send) |
status_changed_at |
Timestamp of the event that set the current status |
created_at |
When the message was sent |
Message bodies and attachments are not stored.
LmEmailEvent¶
One row per webhook event. A message with several recipients gets one event per recipient.
| Field | Description |
|---|---|
event_id |
Lettermint event id (unique, used for de-duplication) |
email_message |
Related LmEmailMessage, or None for messages not sent through this app |
message_id |
Lettermint message id |
event |
Event type, e.g. message.hard_bounced |
recipient |
Recipient address this event is about |
reason |
Human-readable reason: the SMTP response for bounces, reason for failures |
reason_code |
Enhanced SMTP status code for bounces (e.g. 5.1.1), reason_code for failures |
data |
Raw data object of the webhook payload |
occurred_at |
Event timestamp from Lettermint |
What happens per event¶
Every delivery is verified and answered with 200. This is what happens after that, per event as the Lettermint dashboard groups them:
| Event | Stored as LmEmailEvent |
LmEmailMessage.status becomes |
Signal |
|---|---|---|---|
message.created |
yes | queued |
lm_email_event |
message.sent |
yes | processed |
lm_email_event |
message.delivered |
yes | delivered |
+ lm_email_delivered |
message.auto_replied |
yes | unchanged | lm_email_event |
message.hard_bounced |
yes | hard_bounced |
+ lm_email_bounced |
message.soft_bounced |
yes | soft_bounced |
+ lm_email_bounced |
message.failed |
yes | failed |
+ lm_email_failed |
message.suppressed |
yes | suppressed |
lm_email_event |
message.policy_rejected |
yes | policy_rejected |
lm_email_event |
message.scheduled |
yes | scheduled |
lm_email_event |
message.rescheduled |
yes | scheduled |
lm_email_event |
message.released |
yes | queued |
lm_email_event |
message.canceled |
yes | canceled |
lm_email_event |
message.opened |
yes | opened |
lm_email_event |
message.clicked |
yes | clicked |
lm_email_event |
message.unsubscribed |
yes | unsubscribed |
lm_email_event |
message.spam_complaint |
yes | spam_complaint |
lm_email_event |
message.inbound |
no | none | |
suppression.added, suppression.removed |
no | none | |
webhook.test |
no | none |
The status follows the most recent event by occurred_at, so an out-of-order retry of an older event never overwrites a newer status. For a message with several recipients the status reflects the latest event across all of them; use the events for per-recipient detail.
Events Lettermint adds in the future are handled by the same rules, without code changes: an event with a data.message_id is stored, with its raw data, and leaves the status unchanged until this package maps it; an event without one is acknowledged and ignored. Unknown fields are kept in data, unknown value types are stored as text, and values longer than a column are truncated rather than rejected. The only deliveries that get a 5xx are ones the database could not store, which is exactly when a retry from Lettermint helps.
Querying¶
from lettermint_django.models import LmEmailEvent, LmEmailMessage
LmEmailMessage.objects.get_status("msg_...") # "delivered", "hard_bounced", ... or None
LmEmailMessage.objects.delivered() # delivered, opened or clicked
LmEmailMessage.objects.bounced() # soft and hard bounces
LmEmailMessage.objects.failed()
LmEmailMessage.objects.not_delivered() # bounced, failed, suppressed, or still in transit
LmEmailMessage.objects.not_opened() # delivered without a registered open or click
LmEmailMessage.objects.tagged("launch-2026") # everything sent with that tag
LmEmailMessage.objects.from_bulk(bulk_id) # everything one send_bulk call sent
message = LmEmailMessage.objects.get(message_id="msg_...")
message.bounced # True for soft/hard bounces
message.events.all() # newest first
LmEmailEvent.objects.for_recipient("user@example.com")
LmEmailEvent.objects.bounces()
LmEmailEvent.objects.from_bulk(bulk_id)
Signals¶
Import from lettermint_django.signals. All signals pass sender=LmEmailEvent and the keyword arguments event (the stored LmEmailEvent) and email_message (the matching LmEmailMessage, or None).
| Signal | Sent for |
|---|---|
lm_email_event |
Every stored message.* event |
lm_email_delivered |
message.delivered |
lm_email_bounced |
message.soft_bounced, message.hard_bounced |
lm_email_failed |
message.failed |
from django.dispatch import receiver
from lettermint_django.signals import lm_email_bounced
@receiver(lm_email_bounced)
def handle_bounce(sender, event, email_message, **kwargs):
if event.event == "message.hard_bounced":
mark_address_invalid(event.recipient, reason=event.reason_code)
Receivers run synchronously inside the webhook request. Exceptions raised by a receiver are logged and swallowed, so a broken receiver never causes Lettermint to retry the delivery. Keep receivers fast; offload slow work to a task queue.
Admin¶
When django.contrib.admin is installed, both models appear under Lettermint as read-only lists. Messages show their events inline.
Security¶
The endpoint is CSRF-exempt because every delivery is signed. Before anything is read from the body or written to the database, a delivery must pass the checks the Lettermint SDK performs:
X-Lettermint-SignatureandX-Lettermint-Deliveryheaders must be present.- The delivery timestamp must be within five minutes of now, which blocks replaying a captured request.
- The HMAC-SHA256 signature over the timestamp and the raw body must match
LETTERMINT_WEBHOOK_SECRET, compared in constant time.
Anything else gets a 400 and one warning line in the lettermint_django log. A valid delivery that arrives twice within the window is a no-op because of the unique event id. Without LETTERMINT_WEBHOOK_SECRET the view raises ImproperlyConfigured on every request: it fails closed and never accepts an unsigned delivery.
What stays with you: keep the secret out of the repository, regenerate it in the Lettermint dashboard if it leaks, serve the endpoint over HTTPS (Lettermint requires it), and add rate limiting at your proxy if log noise from bots bothers you.
The system check lettermint_django.W002 warns at manage.py check and runserver time when the webhook is served but the secret is empty.
Behaviour details¶
- Sending never depends on tracking. If storing the sent message fails, the error is logged and the email still counts as sent.
- Unknown messages are kept. Events for messages that were not sent through this app are stored with
email_message=None. - Response codes. The endpoint returns
400for an invalid signature, stale timestamp or malformed JSON, and200otherwise. A database error results in a5xx, after which Lettermint retries the delivery (12 attempts over about 14 hours). - Logging. Rejected deliveries and tracking failures are logged to the
lettermint_djangologger.