Skip to content

Design & Architecture

This document outlines the design decisions and conventions for lettermint-django, particularly around the email tracking features planned for v0.3.0+.

Naming Conventions

Since lettermint-django is a package, naming conflicts must be carefully avoided. The following conventions apply:

Models

Convention: Use Lm prefix for all tracking models.

from lettermint_django.models import LmEmailMessage, LmEmailEvent

Rationale: - Models have direct namespace collision risk with user code - Lm aligns with Lettermint's own naming (e.g., lm_... API tokens) - Makes origin explicit: LmEmailMessage clearly indicates "Lettermint email message" - Database tables: lettermint_django_lmemailmessage and lettermint_django_lmemailevent (Django's default <app_label>_<modelname> naming)

Examples: - LmEmailMessage: Sent email record - LmEmailEvent: Email status event (delivered, bounced, opened, clicked)

Signals

Convention: Use lm_ prefix for all Django signals.

from lettermint_django.signals import lm_email_delivered, lm_email_bounced

lm_email_delivered.connect(my_handler)

Rationale: - Signals are module-level, so naming conflicts are possible - Prefix makes sender intent clear: "This is a Lettermint signal" - Easier to distinguish in large projects with multiple signal emitters

Examples: - lm_email_event: Emitted for every stored message.* event - lm_email_delivered: Emitted when email delivery confirmed - lm_email_bounced: Emitted when a soft or hard bounce is detected - lm_email_failed: Emitted when Lettermint reports a failed message - lm_email_opened: Emitted when email opened (v0.4.0+) - lm_email_clicked: Emitted when link clicked (v0.4.0+)

Manager Methods & Functions

Convention: No prefix (namespace via model or module import).

# Via model manager (already scoped)
LmEmailMessage.objects.get_status(message_id)
LmEmailEvent.objects.for_recipient("user@example.com")

# Via module import (imports are explicit)
from lettermint_django.tracking import record_event

Rationale: - Models already provide namespace: LmEmailMessage.objects.* - Functions imported explicitly: from lettermint_django.utils import ... - Adding prefixes (lm_get_status) would be redundant and verbose - Standard Django pattern (e.g., User.objects.filter(), not User.objects.django_filter())

URLs & View Names

Convention: Use lettermint- or lm- prefix for URL names.

# urls.py
urlpatterns = [
    path("webhooks/lm-message-events/", webhook_handler, name="lm-message-events"),
]

Rationale: - URL names are project-global and can collide - Prefix prevents conflicts with user-defined URL names


Architecture Decisions

Webhook Processing (v0.3.0)

Decision: Synchronous webhook handling, no background tasks.

Implementation: 1. Django view receives webhook POST 2. Verify signature and timestamp with the SDK's lettermint.Webhook (sync) 3. get_or_create the LmEmailEvent on the event id and update the message status, in one transaction (idempotent) 4. Emit lm_email_event plus the event-specific signal via send_robust (sync) 5. Return 200 OK to Lettermint

Rationale: - Simplicity: no Celery/RQ setup required - Real-time DB updates: events visible immediately - Low latency: Lettermint webhook delivery guaranteed within seconds - Webhook processing is lightweight (single DB insert + signal) - If the endpoint fails, Lettermint retries the delivery (12 attempts over about 14 hours)

Trade-offs: - ⚠️ If webhook handler is slow, webhook may timeout - ⚠️ Database must be reliable (no fallback if insert fails)

Mitigation: - Keep webhook handler lightweight (avoid external API calls) - Use database transactions for atomicity - Signal receiver exceptions are logged and never propagated (send_robust), so user code cannot trigger retries - Database errors return a 5xx so Lettermint retries; the unique event id keeps retries idempotent

Bulk Sending: No Retries

Decision: send_bulk sends every chunk it is asked to send, once, and reports per message whether Lettermint accepted it and why not. It never retries, splits a rejected chunk, throttles, or enforces Lettermint's limits.

Rationale: - Retrying without an idempotency key can send a batch twice; the SDK's send_batch() does not expose one - The caller's task queue already has retry semantics; a second layer underneath makes behaviour impossible to reason about - Lettermint's limits are theirs and change; encoding them would make this package wrong the day they move - Isolating one bad message in a rejected chunk is a one-liner for the caller (send_bulk(failed, batch_size=1)) and a conscious choice

What the package does own: chunking (mapping N messages onto N/batch_size requests, lazily), per-message results with Lettermint's own error text, and recording accepted messages for tracking.

Optional Tracking

Decision: Tracking is off unless lettermint_django is in INSTALLED_APPS. There is no separate on/off setting.

Configuration:

INSTALLED_APPS = [..., "lettermint_django"]  # tracking on

Rationale: - Installing the app is what creates the tables, so it is the natural switch; a second setting would only add a way to misconfigure - Backend-only installs never import the models and need no migrations - No breaking changes: existing installations keep working unchanged

Message Capture Timing

Decision: message_id is captured synchronously after .send() returns.

# backend.py
response = mail.send()  # Sync call to Lettermint API
record_sent(email_message, response, from_email=sender, route=route)  # no-op unless the app is installed

Rationale: - Lettermint API returns message_id immediately in response - No additional API calls needed - Failure to capture = email still sent (acceptable trade-off); record_sent logs and swallows errors inside a savepoint so a caller's transaction stays usable - Events logged later via webhooks (eventual consistency)

Signal Naming & Scoping

Decision: Signals defined in lettermint_django.signals, emitted from lettermint_django.tracking.record_event (not from the view), so any future replay or import path emits them too.

# signals.py
lm_email_delivered = django.dispatch.Signal()

# tracking.py
lm_email_delivered.send_robust(sender=LmEmailEvent, event=event, email_message=event.email_message)

Rationale: - Centralized signal definitions (easy to discover) - Explicit sender: users know which app emitted signal - Decouples webhook handler from business logic - Allows multiple handlers per signal


Module Layout

Convention: One module per model, view, signal, admin class and service function, grouped in packages: models/lm_email_message.py, views/message_events.py, signals/lm_email_bounced.py, tracking/record_event.py, admin/lm_email_message.py. Each package's __init__.py only re-exports, so the public import paths stay flat (from lettermint_django.models import LmEmailMessage, from lettermint_django.signals import lm_email_bounced).

Rationale: - Small, single-purpose files are easier to review and to navigate - Adding a model, view or signal never means editing a growing file - Public import paths are decoupled from the file layout

File Structure (v0.3.0+)

lettermint_django/
├── __init__.py              # Exports the backend only; never imports models
├── apps.py                  # Django app config
├── backend.py               # LettermintEmailBackend
├── urls.py                  # Webhook URL route (path from LETTERMINT_WEBHOOK_PATH)
├── checks/
│   └── webhook_secret.py    # W002: webhook served without a secret
├── models/
│   ├── __init__.py          # Re-exports
│   ├── choices.py           # LmMessageStatus, event -> status mapping
│   ├── lm_email_message.py  # LmEmailMessage
│   └── lm_email_event.py    # LmEmailEvent
├── views/
│   └── message_events.py    # Webhook view (signature verification via the SDK)
├── signals/
│   ├── lm_email_event.py
│   ├── lm_email_delivered.py
│   ├── lm_email_bounced.py
│   └── lm_email_failed.py
├── tracking/
│   ├── enabled.py           # is_tracking_enabled()
│   ├── record_sent.py       # record_sent(): backend -> LmEmailMessage
│   ├── record_event.py      # record_event(): webhook -> LmEmailEvent + status update
│   └── emit_signals.py      # Signal emission (send_robust)
├── admin/
│   ├── mixins.py            # ReadOnlyMixin
│   ├── lm_email_message.py  # LmEmailMessageAdmin
│   └── lm_email_event.py    # LmEmailEventAdmin, LmEmailEventInline
└── migrations/

Versioning Policy

  • v0.2.x: Email backend (send only)
  • v0.3.x: Tracking foundation (delivered, bounced)
  • No breaking changes within v0.3.x (patch updates may add features)
  • Signal names frozen after v0.3.0
  • Model fields may be added (with migrations) but not removed/renamed
  • v0.4.x: Bulk sending (batch endpoint, tags)
  • v0.5.x: Advanced tracking (opens, clicks)
  • v1.0.0: Stable (production-ready, 2-year support)

Backward Compatibility: - Model/signal names are frozen once released - New fields added via migrations (always backward compatible) - Function signatures preserved or deprecated with warnings


Testing Strategy

  • Unit Tests: Models, query methods, signal emission
  • Integration Tests: Backend → database flow, webhook verification
  • Webhook Tests: Signature validation, idempotency (duplicate events)
  • End-to-End: Mock Lettermint API, full send + webhook flow

See the test suite in the repository for implementation details.


Migration Strategy (for users)

Users upgrading from v0.2.x to v0.3.0:

# 1. Install new version
pip install --upgrade lettermint-django

# 2. Install model migrations
python manage.py migrate lettermint_django

# 3. Configure tracking (optional)
# In settings.py:
INSTALLED_APPS += ["lettermint_django"]
LETTERMINT_WEBHOOK_SECRET = "..."  # From Lettermint dashboard
# In urls.py (at the root; LETTERMINT_WEBHOOK_PATH is the whole path):
path("", include("lettermint_django.urls"))

# 4. Set up webhook in Lettermint dashboard
# URL: https://myapp.com/lettermint/message-events/  (or your LETTERMINT_WEBHOOK_PATH)
# Events: message.delivered, message.soft_bounced, message.hard_bounced, message.failed

# 5. Test the webhook with the dashboard's test button (sends a webhook.test event)

Future Considerations

  • Async Send Option (v1.1+): Celery integration for background email sending
  • Event Replay (v1.1+): Mechanism to re-process missed events
  • Admin Dashboard (v1.1+): Rich UI for email history and analytics
  • Inbound Support (v1.2+): Process inbound emails via Lettermint

References