Skip to main content

Django Missive

🚀 A complete Django library for managing multi-channel missive sending: email, SMS, WhatsApp, postal mail, and in-app notifications.

✨ Features

Main features

  • 📧 Multi-channel: 14 supported types (Email, SMS, WhatsApp, Telegram, Signal, Messenger, RCS, Postal mail, LRE, Voice calls, Push notifications, Slack, Teams)
  • 🔌 15+ integrated providers: SendGrid, Mailgun, Twilio, La Poste, Telegram, FCM, APN, Slack, Teams, etc.
  • 📎 Flexible attachments: Local files OR external URLs (S3, Google Drive)
  • 🔔 Unified webhooks: Single endpoint /missive/webhook/{provider}/
  • 📊 Full tracking: History, statuses, events
  • 🎯 Recipient model: Centralized contact details (email, phone, address)
  • 🔍 Built-in validation: Pre-send risk checks
  • 👨‍💼 Complete Django admin: Management interface with validation actions
  • 🔗 GenericForeignKey: Flexible link with your business models
  • 📝 Reusable templates: Create missive templates
  • 📊 Advanced monitoring: Services, credits, SLA and health check per provider
  • 🔄 Automatic fallback: Switch to backup provider on failure

Technical architecture

  • ✅ Compatible with Django 3.2+ and Python 3.10+
  • ✅ Modular structure with mixins (providers/base/)
  • ✅ Comprehensive unit tests (8/8 ✅)
  • ✅ Exhaustive documentation (16 .md files)
  • ✅ CI/CD with GitHub Actions
  • ✅ Type hints and mypy
  • ✅ Code formatted with Black + isort

Installation

🔧 Development mode (local project)

# Core only (Django + validation)
pip install -r requirements.txt

# Development (tests, linters)
pip install -r requirements-dev.txt

# All providers
pip install -r requirements-all.txt

📦 Production mode (future - after PyPI publication)

# Base installation
pip install django-missive

# With specific providers
pip install django-missive[email]        # Email (SendGrid, Mailgun, SES)
pip install django-missive[sms]          # SMS & Voice (Twilio, Vonage)
pip install django-missive[messaging]    # Telegram, Signal, Messenger
pip install django-missive[push]         # Push notifications (FCM, APN)
pip install django-missive[professional] # Slack, Teams
pip install django-missive[postal]       # Postal, LRE
pip install django-missive[all]          # All providers

Quick Start

  1. Add django_pymissive to your INSTALLED_APPS in settings.py:
INSTALLED_APPS = [
    ...
    'django_pymissive',
]
  1. Run migrations:
python manage.py migrate django_pymissive
  1. Include the URLconf in your project urls.py:
from django.urls import path, include

urlpatterns = [
    ...
    path('missive/', include('django_pymissive.urls')),  # Webhooks
]

This will create the following URLs:

  • /missive/webhook/{provider}/ - Unified webhook for all providers
  1. Configure providers in settings.py:
# Django Missive configuration
MISSIVE_PROVIDERS = {
    # Providers by missive type (uses python-missive)
    'EMAIL': {
        'backend': 'pymissive.providers.sendgrid.SendGridProvider',
        'config': {
            'SENDGRID_API_KEY': os.getenv('SENDGRID_API_KEY'),
        }
    },
    'SMS': {
        'backend': 'pymissive.providers.twilio.TwilioProvider',
        'config': {
            'TWILIO_ACCOUNT_SID': os.getenv('TWILIO_ACCOUNT_SID'),
            'TWILIO_AUTH_TOKEN': os.getenv('TWILIO_AUTH_TOKEN'),
            'TWILIO_PHONE_NUMBER': '+33123456789',
        }
    },
    'POSTAL': {
        'backend': 'pymissive.providers.laposte.LaPosteProvider',
        'config': {
            'LAPOSTE_API_KEY': os.getenv('LAPOSTE_API_KEY'),
        }
    },
}

# Default email
DEFAULT_FROM_EMAIL = 'noreply@example.com'

# Applied on save when sender fields are empty.
# Campaigns receive every provided key. Missives only receive the fields
# for their type, and only when the campaign does not already have a sender.
PYMISSIVE_DEFAULT_SENDER = {
    "name": "Octolo",
    "email": "contact@octolo.tech",
    "phone": "+33123456789",
    "address": {
        "organization": "Octolo",
        "address_line1": "1 rue de la Paix",
        "postal_code": "75002",
        "city": "Paris",
        "country": "France",
    },
}

🚀 Quick usage

Send an email

from django_pymissive.models import Missive, MissiveType, MissiveEventType

# Create an email missive
missive = Missive.objects.create(
    sender=request.user,
    missive_type=MissiveType.EMAIL,
    recipient_email="client@example.com",
    subject="Order confirmed",
    body="<p>Your order #123 is confirmed</p>",
    body_text="Your order #123 is confirmed",
    status=MissiveEventType.PENDING,
)

# Sending can be handled via async tasks or manually

Use the model to create missives

from django_pymissive.models import Missive, MissiveType, MissiveEventType

# Create a recipient with all contact details
missive = Missive.objects.create(
    sender=request.user,
    missive_type=MissiveType.EMAIL,
    recipient_first_name="Jean",
    recipient_last_name="Dupont",
    recipient_email="jean@acme.com",
    recipient_phone="+33600000000",
    recipient_address_line1="123 Rue de la Paix",
    recipient_postal_code="75001",
    recipient_city="Paris",
    recipient_country="FR",
    subject="Welcome",
    body="<p>Hello Jean, welcome!</p>",
    status=MissiveEventType.PENDING,
)

# Create an SMS with the same contact details
sms = Missive.objects.create(
    sender=request.user,
    missive_type=MissiveType.SMS,
    recipient_phone="+33600000000",
    body="Your verification code: 123456",
    status=MissiveEventType.PENDING,
)

Provider monitoring (via python-missive)

from pymissive.providers.sendgrid import SendGridProvider

# Configure and verify the provider
provider = SendGridProvider(config={
    'SENDGRID_API_KEY': 'your-api-key'
})

# Verify configuration
is_configured = provider.is_configured()
print(f"Provider configured: {is_configured}")

# Send a test email
result = provider.send_email(
    from_email='sender@example.com',
    to_email='recipient@example.com',
    subject='Test',
    body='<p>Test message</p>'
)

Validate and send

from django_pymissive.models import Missive, MissiveEventType

# Retrieve a missive
missive = Missive.objects.get(id=123)

# Check it is ready to be sent
if missive.status == MissiveEventType.PENDING:
    # Mark as sent (actual sending is done via the configured provider)
    missive.status = MissiveEventType.SENT
    missive.save()

Counters and annotations

Supports rather than types

A support groups the missive types of one physical channel (pymissive.config.GENERIC_SUPPORT): address covers lre and hand_delivery, email covers email, email_marketing and ere, and so on. Plain mail is not a type of its own — it is lre without acknowledgement_of_receipt, which is what providers key the mode on. Count per support instead of listing types by hand:

from pymissive.config import missive_types_for_support, normalize_support

normalize_support("courrier")        # 'address'
normalize_support("postal")          # 'address' (legacy synonym)
missive_types_for_support("address") # ['lre', 'hand_delivery']

Counters are opt-in on both sides, under the same name — with_counts() on the campaign as on its runs, so a template can switch between the two:

MissiveCampaign.objects.with_counts()
# count_missive, count_sent / count_pending / count_error,
# count_support_<support> (+ _sent, _error), count_missive_<status>,
# count_type_<type>, count_thread_<type>,
# count_recipient (+ _<status>), count_event, count_related_object,
# count_attachment, pct_recipient_<status>

MissiveCampaign.objects itself annotates only what costs no GROUP BY: last_send_date and last_ended_at (subqueries), has_sent_missives and has_open_run (booleans). A .get(pk=…) stays a primary key lookup — asking for the counters everywhere made it four times slower, and the recipient and event joins multiply each other on top of that (5 000 missives with 2 recipients and 8 events each is 80 000 intermediate rows for every COUNT(DISTINCT)).

Reading a counter on a campaign nobody annotated still works: the whole set is fetched in one query and cached on the instance. Convenient for a detail page, ruinous for a list — measured on 100 campaigns, reading three counters costs 11 ms annotated and 500 ms through the fallback. Call with_counts() as soon as you iterate.

Name the ones you display: with_counts("count_missive", "pct_recipient_success") annotates those two (plus the counters a percentage divides) instead of all sixty. They share one scan of the joined rows, so the cost follows the number of aggregates — naming the handful the admin changelist shows measured 2,5 times faster than taking every counter along. An unknown name raises ValueError rather than quietly falling back to one query per row.

count_sent / count_pending / count_error and count_support_* cover thread_type=MISSIVE only, on a campaign as on a run, since the HISTORY rows a resend leaves behind are not new sends. They say where the payload stands now, so they follow the missives: the total of a finished run drops when a newer run takes over a draft it never sent, or when a retry archives one of its attempts.

The historical counters keep the archived threads instead — count_missive and count_type_<type> on the campaign, count_missive_<status> and count_thread_<type> on both — which is what keeps the failures of a finished run readable once a later run has retried them (run.history_count says how many of its attempts were archived that way). Per-status counts may therefore sum above count_total.

Never filter on status="draft" to tell sent from pending: databases predating the draft default hold an empty status too. Use the shared predicates:

from django_pymissive.models.choices import (
    error_missive_q, missive_type_filter, pending_missive_q, sent_missive_q,
)

Missive.objects.filter(sent_missive_q())
campaign.to_missive.filter(pending_missive_q())
Missive.objects.filter(**missive_type_filter("courrier"))

Annotating your own models

Missives point at your models through MissiveRelatedObject, campaigns through CampaignRelatedObject. Mix MissiveRelatedQuerySetMixin into your queryset to annotate them:

from django_pymissive.managers.related_object import MissiveRelatedQuerySetMixin

class ParticipantQuerySet(MissiveRelatedQuerySetMixin, models.QuerySet):
    pass

class Participant(models.Model):
    objects = ParticipantQuerySet.as_manager()
qs = (
    Participant.objects
    # {"uid", "status", "missive_type", "sent_at"} of the newest match, or {}
    .with_last_missive(campaign=campaign, support="email")
    # one integer per counter you name
    .with_missive_count(name="email_count", support="email")
    .with_missive_count(name="sent_count", sent=True)
    # both counters in a single scan: {"missives": N, "events": E}
    .with_missive_counts(prefix="totals")
)

Every method takes the same filters — campaign, support, missive_type, thread_type, metadata, sent — described on django_pymissive.managers.related_object.missive_link_q. The prefix / name arguments above name the annotation; prefix is refused as a filter, since these methods own the lookup path to the missive. For an object linked to a whole campaign (a meeting, a company) rather than to individual missives, with_campaign_missive_count() walks the campaign link instead.

Outside of an annotation, missive_related_queryset(instance, **filters) returns the link rows themselves, newest missive first.

Each annotation is a correlated subquery, re-evaluated for every row it annotates: the right trade on a paginated page, the wrong one on a full export. For a batch, missive_summaries_by_object() answers in two grouped queries whatever its size — the link rows, then the events of their missives:

from django_pymissive.managers.related_object import (
    missive_summaries_by_object, object_id_value,
)

summaries = missive_summaries_by_object(Participant, [p.pk for p in participants])
summaries[object_id_value(participant.pk)]
# {"missives": 2, "events": 5, "last": {"uid", "status", "missive_type", "sent_at"}}

It takes the same filters, and the values reuse the names the annotations produce, so a template can read either source. Every object asked for gets an entry, zeroed with an empty last when it has no matching missive. On 1 000 objects carrying three missives each, the five annotations above take ~1 000 ms where the batch pass takes ~22 ms.

Your model's primary key can be an integer or a UUID: object_id is a text column, holding the pk without its dashes (uuid.hex), because PostgreSQL renders a UUID dashed where SQLite stores 32 hex characters. Go through missive_related_queryset() or the mixin rather than filtering object_id yourself; if you must, normalise with django_pymissive.managers.related_object.object_id_value(pk).

Send dates and recipient values

Missive has no sent_at column and updated_at is bumped by any later save, so neither dates a send. The sent_at annotation (on by default) reports the oldest event proving the missive left, and is NULL until then.

To render recipients for a page of missives without a query per row:

MissiveRecipient.objects.targets_by_missive(missive_ids, support="email")
# {"<missive_id>": ["alice@example.com", …]}

Values keep their natural type — a string for e-mail, phone and notification id, the address dict for postal supports — and fall back to the recipient name when no contact value is on file.

Before and after a send

campaign.send_preview_payload()  # {"total", "by_support", "by_type"} — what a send would push out
campaign.progress_payload()      # what already went out, per type, plus the runs
campaign.is_processing           # flag set, or a run still open
campaign.can_remove              # nothing sent yet

Both payloads cover thread_type=MISSIVE, so their totals agree with count_sent / count_error and stay below count_missive, which counts the archived and conversation threads too.

The last two read the has_open_run / has_sent_missives annotations that MissiveCampaign.objects adds, and fall back to one EXISTS each, memoised on the instance. Iterate a list of campaigns through that manager rather than through Missive.objects.select_related("campaign"), which cannot carry the annotations: the fallback then costs two queries per campaign instead of none.

Development

Quick Start

This project includes service.py - a cross-platform development tool that works on all operating systems.

# Setup development environment
python service.py dev install-dev

# Run tests
python service.py dev test

# Format code
python service.py dev format

# Build package
python service.py dev build

Linux/macOS users can make it executable:

chmod +x service.py
./service.py dev install-dev
./service.py dev test

Available Commands

Development:

  • python service.py dev venv - Create virtual environment
  • python service.py dev install - Install in production mode
  • python service.py dev install-dev - Install in development mode

Testing:

  • python service.py dev test - Run tests with pytest
  • python service.py dev test-verbose - Run tests with verbose output
  • python service.py dev coverage - Run tests with coverage report

Code Quality:

  • python service.py quality lint - Run linters (flake8, mypy)
  • python service.py quality format - Format code (black, isort)
  • python service.py quality check - Run all checks (lint + format check)

Building:

  • python service.py dev build - Build wheel and source distribution
  • python service.py dev clean - Remove all build artifacts
  • python service.py dev clean-test - Remove test artifacts (htmlcov, .coverage, etc.)

Publishing:

  • python service.py dev upload-test - Upload to TestPyPI
  • python service.py dev upload - Upload to PyPI
  • python service.py dev release - Full release workflow

Utilities:

  • python service.py dev show-version - Show current version
  • python service.py dev venv-clean - Recreate virtual environment

Run python service.py dev help to see all available commands.

Django Development Server

Test the library with a Django development server:

# Run migrations and create superuser (admin/admin)
python service.py dev migrate

# Start development server
python service.py dev runserver

Access the admin interface at http://127.0.0.1:8000/admin/ (login: admin/admin)

See docs/development.md for detailed development guide (if available).

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/AmazingFeature)
  3. Commit your changes (git commit -m 'Add some AmazingFeature')
  4. Push to the branch (git push origin feature/AmazingFeature)
  5. Open a Pull Request

License

This project is licensed under the MIT License - see the LICENSE file for details.

Changelog

0.1.0 (Initial Release)

  • Initial release
  • Basic functionality

Support

If you encounter any issues or have questions, please file an issue on the GitHub issue tracker.

Metadata

Release files for django-pymissive 1.3.14

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-pymissive 1.3.14
File Size Uploaded Provenance
django_pymissive-1.3.14.tar.gz 242.8 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for django-pymissive 1.3.14
File Interpreter ABI Platform
django_pymissive-1.3.14-py3-none-any.whl Python 3 none any Details

Total release size: 480.2 kB

Release files / django_pymissive-1.3.14.tar.gz

Download URL django_pymissive-1.3.14.tar.gz
Size 242.8 kB
Tags Source
SHA-256 checksum
How to use checksums
cd1e7ccbbe88be8889a2af21336fe52d4e805fb980d8e2979da66c32fb1c88ea
BLAKE2b-256 checksum
How to use checksums
46fb16ca2ee6b7330fff588111617f772ae86a15453ff7466b7715b42f6168a0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.

Transparency log

Release files / django_pymissive-1.3.14-py3-none-any.whl

Download URL django_pymissive-1.3.14-py3-none-any.whl
Size 237.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
457e48ae6bdfbb7c39b7c80bd912bfd636d02f1e6c97c828f018dd559fac7f02
BLAKE2b-256 checksum
How to use checksums
1fcb54d8d9e1e435802307ff4acbe27df4be9c29ce022ceed141cb32a36ca01b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.

Transparency log

Security for django-pymissive 1.3.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for django_pymissive-1.3.14.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 13, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

1.3.14 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page