Skip to main content

drf-anonymous-messages

PyPI Python Django License: MIT

Pluggable, secure, API-first anonymous messaging for Django REST Framework. Add NGL / Sarahah / Tellonym-style "send me anonymous messages" features to any Django project in a few lines, then swap any part of it through settings.

  • 🔗 Shareable inboxes (many per user) with custom or random handles and one-click link rotation
  • 📨 Anonymous sending by anyone (or logged-in users only), with nothing exposed about the sender
  • 💬 Replies, private or published to a public Q&A feed
  • ⭐ Read, favourite and archive flags, bulk mark-all-read, stats
  • 🚫 Blocking an unknown sender, without ever revealing who they are
  • 🚩 Abuse reports with evidence snapshots and a staff moderation queue
  • 🛡️ Moderation pipeline (banned words with anti-obfuscation, link blocking, duplicate detection, plus your own sync or async moderators, e.g. an ML classifier)
  • 🤖 Captcha (Cloudflare Turnstile, hCaptcha, reCAPTCHA v2/v3, or your own)
  • 🔐 Security: UUID primary keys everywhere, no stored IPs, per-recipient HMAC fingerprints, optional Fernet encryption at rest with key rotation, anti-spoofing proxy handling, Trojan-Source/zero-width sanitising, rate limits
  • ⚡ Scale: cursor pagination, denormalised and indexed hot paths, atomic counters/dedupe in your cache, background jobs in-process or on Celery
  • 🔔 Notifications: signals, email, signed webhooks, or your own backend
  • ✅ System checks, admin, management commands, typed (py.typed), 93% test coverage

Installation

pip install drf-anonymous-messages
# optional extras
pip install "drf-anonymous-messages[celery]"       # Celery task runner
pip install "drf-anonymous-messages[encryption]"   # Fernet encryption at rest
pip install "drf-anonymous-messages[all]"
# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "anonymous_messages",
]
# urls.py
from django.urls import include, path

urlpatterns = [
    path("api/anon/", include("anonymous_messages.urls")),
]
python manage.py migrate

That's it. Authentication is whatever you already use with DRF (session, token, JWT, ...). The package never assumes one.

Quick tour

# Owner creates a shareable inbox
curl -X POST /api/anon/inboxes/ -H "Authorization: Bearer <token>" \
     -d '{"slug": "ada", "title": "Ask me anything"}'

# Anyone can view the public profile...
curl /api/anon/u/ada/

# ...and send an anonymous message
curl -X POST /api/anon/u/ada/messages/ -d '{"body": "Love your work!"}'
# -> 201 {"status": "sent"}

# Owner reads, replies publicly, blocks or reports
curl /api/anon/messages/                                   -H "Authorization: ..."
curl -X POST /api/anon/messages/<id>/reply/ -d '{"body": "Thanks!", "is_public": true}'
curl -X POST /api/anon/messages/<id>/block-sender/
curl -X POST /api/anon/messages/<id>/report/ -d '{"reason": "harassment"}'

# Everyone can read published answers
curl /api/anon/u/ada/replies/

Endpoints

Method Path Who Description
GET, POST /inboxes/ owner List / create inboxes (slug optional, random if omitted)
GET, PATCH, PUT, DELETE /inboxes/{id}/ owner Manage an inbox
POST /inboxes/{id}/rotate-slug/ owner New random link; old one dies instantly
GET /inboxes/{id}/stats/ owner Counts (total, unread, held, replied, 24h, 7d, ...)
POST /inboxes/{id}/mark-all-read/ owner Bulk mark read
GET /messages/ owner Received messages. Filters: inbox, status (visible/held), is_read, is_favorite, is_archived (default false, all), has_reply
GET, PATCH, DELETE /messages/{id}/ owner Retrieve (auto-marks read), update flags, delete
POST, PUT, PATCH, DELETE /messages/{id}/reply/ owner Reply, private or public
POST /messages/{id}/block-sender/ owner Block the anonymous sender from all your inboxes
POST /messages/{id}/report/ owner Report to moderators (auto-blocks by default)
POST /messages/mark-all-read/ owner Bulk mark read, all inboxes
GET /messages/stats/ owner Stats across all inboxes
GET, DELETE /blocks/, /blocks/{id}/ owner List / unblock
GET /sent/ sender Messages you sent while logged in (only with STORE_SENDER_USER)
GET /u/{slug}/ public Public profile (owner is not exposed)
POST /u/{slug}/messages/ public Send {"body", "captcha_token"?}
GET /u/{slug}/replies/ public Published Q&A feed
GET, PATCH /moderation/reports/, /moderation/reports/{id}/ staff Moderation queue (?status=open)

All list endpoints use cursor pagination (?page_size= up to MAX_PAGE_SIZE).

Errors raised by the package have a stable machine-readable code:

{"detail": "Your message could not be sent.", "code": "links_not_allowed"}

Codes: inbox_closed, self_message, captcha_invalid, sender_blocked, banned_content, links_not_allowed, duplicate, inbox_limit, plus DRF's own validation and 429 throttling responses.

Configuration

Everything lives in one optional dict. Every key below shows its default.

ANONYMOUS_MESSAGES = {
    # Inboxes
    "MAX_INBOXES_PER_USER": 10,
    "SLUG_MIN_LENGTH": 3,
    "SLUG_MAX_LENGTH": 30,
    "AUTO_SLUG_BYTES": 6,
    "RESERVED_SLUGS": ["admin", "api", "login", ...],
    "DEFAULT_PROMPT": "Send me anonymous messages!",

    # Messages
    "MIN_MESSAGE_LENGTH": 1,
    "MAX_MESSAGE_LENGTH": 1000,
    "MAX_REPLY_LENGTH": 2000,
    "MAX_CONSECUTIVE_NEWLINES": 3,
    "MARK_READ_ON_RETRIEVE": True,
    "ALLOW_SELF_MESSAGES": False,
    "SILENT_BLOCK": True,           # blocked senders still see "sent"
    "AUTO_BLOCK_ON_REPORT": True,
    "MESSAGE_RETENTION_DAYS": None, # e.g. 90, used by the purge job/command
    "STORE_SENDER_USER": False,     # enables /sent/; weakens DB-level anonymity

    # Feature flags
    "ENABLE_REPLIES": True,
    "ENABLE_PUBLIC_REPLIES": True,
    "ENABLE_BLOCKS": True,
    "ENABLE_REPORTS": True,

    # Security
    "FINGERPRINT_SECRET": None,     # strongly recommended; defaults to SECRET_KEY
    "FINGERPRINT_FUNCTION": "anonymous_messages.fingerprint.default_fingerprint",
    "IP_RESOLVER": "anonymous_messages.fingerprint.get_client_ip",
    "NUM_PROXIES": 0,               # trusted reverse proxies in front of Django
    "IPV6_PREFIX_LENGTH": 64,
    "ENCRYPTION_BACKEND": "anonymous_messages.crypto.NoEncryption",
    "ENCRYPTION_KEYS": [],
    "CAPTCHA_BACKEND": None,        # e.g. "anonymous_messages.captcha.TurnstileCaptcha"
    "CAPTCHA_SECRET": None,
    "CAPTCHA_REQUIRED": False,      # or per-inbox `require_captcha`
    "CAPTCHA_MIN_SCORE": 0.5,       # reCAPTCHA v3
    "CAPTCHA_TIMEOUT": 5,

    # Moderation
    "MODERATORS": [
        "anonymous_messages.moderation.BannedWordsModerator",
        "anonymous_messages.moderation.LinkModerator",
        "anonymous_messages.moderation.DuplicateModerator",
    ],
    "ASYNC_MODERATORS": [],
    "BANNED_WORDS": [],
    "BANNED_WORDS_ACTION": "reject",  # or "hold"
    "DUPLICATE_WINDOW_SECONDS": 300,
    "CACHE_ALIAS": "default",

    # Throttling (merged with defaults; set any to None to disable)
    "THROTTLE_RATES": {
        "send_burst": "10/min",
        "send_sustained": "300/day",
        "send_per_inbox": "5/min",
        "public_read": "300/min",
        "report": "30/hour",
    },

    # Notifications & background jobs
    "NOTIFICATION_BACKENDS": [],    # see below
    "TASK_RUNNER": "on_commit",     # "sync" | "on_commit" | "celery"
    "CELERY_QUEUE": None,
    "EMAIL_SUBJECT": "You have a new anonymous message",
    "EMAIL_FROM": None,
    "MESSAGE_URL_TEMPLATE": None,   # "https://app.example/inbox/{message_id}"
    "WEBHOOK_URL": None,
    "WEBHOOK_SECRET": None,
    "WEBHOOK_TIMEOUT": 5,
    "WEBHOOK_INCLUDE_BODY": False,

    # API surface
    "PAGE_SIZE": 20,
    "MAX_PAGE_SIZE": 100,
    "OWNER_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    "PUBLIC_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"],
    "SEND_PERMISSION_CLASSES": ["rest_framework.permissions.AllowAny"],
    "MODERATION_PERMISSION_CLASSES": ["rest_framework.permissions.IsAdminUser"],
    "SERIALIZERS": {},              # override any serializer, see below
}

python manage.py check validates this dict: it flags typos, import errors, missing secrets and a missing Celery install. check --deploy adds production advice.

Security model

Threat Mitigation
Enumerating or guessing objects UUIDv4 primary keys on every model; IDs are validated in URLs; querysets are always scoped to the owner, so another user's object returns 404 and 403 never leaks that it exists
De-anonymising senders No IP, user-agent or account is stored by default. The sender is identified only by HMAC-SHA256(secret, recipient_id | identity). It is never serialised, and it is per recipient, so the same sender can't be linked across recipients even with database access
Spoofed X-Forwarded-For to dodge blocks or limits Ignored unless you set NUM_PROXIES; even then only the proxy-appended hop is trusted
IPv6 address rotation Identities are grouped by a /64 network
Probing whether you're blocked or held Identical 201 {"status": "sent"} for delivered, held and silently blocked messages
Spam and floods Three send throttles (burst, sustained, per inbox), atomic cache-based duplicate detection, captcha, link blocking
Filter evasion and spoofing NFC normalisation; strips zero-width, bidi-override ("Trojan Source") and control characters; banned-word matching undoes leetspeak and in-word separators
Database leak Optional Fernet (AES + HMAC) encryption of message, reply and report text with key rotation (anonmsg_reencrypt)
Lost evidence Reports snapshot the text, and the snapshot survives the message being deleted
Webhook forgery HMAC-SHA256 over timestamp.body; verify_webhook_signature() helper with replay window
Malicious Celery payloads Workers execute only allow-listed job names

Behind a proxy or load balancer

ANONYMOUS_MESSAGES = {"NUM_PROXIES": 1, "FINGERPRINT_SECRET": env("ANON_FP_SECRET")}

Encryption at rest

ANONYMOUS_MESSAGES = {
    "ENCRYPTION_BACKEND": "anonymous_messages.crypto.FernetEncryption",
    "ENCRYPTION_KEYS": [env("ANON_KEY_NEW"), env("ANON_KEY_OLD")],  # first one encrypts
}

Generate a key with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())". To rotate, put the new key first, run python manage.py anonmsg_reencrypt, then remove the old key. Encrypted columns can't be used in equality lookups.

Scaling

The package is designed to run horizontally behind a load balancer:

  • Shared cache is required for multi-process deployments. Throttles and duplicate detection use your Django cache, so point CACHES["default"] (or CACHE_ALIAS) at Redis or Memcached.
  • Cursor pagination keeps every page O(1), however large an inbox gets.
  • Message.recipient is denormalised and indexed, so "my inbox" queries never need a join. Composite indexes cover the unread, per-inbox and retention-purge paths.
  • Bulk operations (mark-all-read, purge) are single UPDATE/DELETE statements, with the purge running in bounded batches.
  • Side effects never block the request: in on_commit mode they run after the transaction commits, and with Celery they go to a queue.

Celery

ANONYMOUS_MESSAGES = {
    "TASK_RUNNER": "celery",
    "CELERY_QUEUE": "anonymous-messages",  # optional dedicated queue
    "NOTIFICATION_BACKENDS": ["anonymous_messages.notifications.WebhookNotificationBackend"],
    "MESSAGE_RETENTION_DAYS": 90,
}

CELERY_BEAT_SCHEDULE = {
    "anonmsg-purge": {"task": "anonymous_messages.purge_expired", "schedule": 3600},
}

Each notification backend is its own job, retried with exponential backoff and jitter (acks_late). One flaky webhook never delays email or the others. Without Celery, use cron with python manage.py anonmsg_purge.

Extending

Custom moderator (sync or async)

from anonymous_messages.moderation import BaseModerator, Verdict

class ToxicityModerator(BaseModerator):
    def moderate(self, text, context):          # context.inbox, context.fingerprint, context.request
        score = my_model.predict(text)
        if score > 0.95:
            return Verdict.reject("toxic")
        if score > 0.7:
            return Verdict.hold("maybe_toxic")   # stored, hidden until reviewed (?status=held)
        return Verdict.allow()

ANONYMOUS_MESSAGES = {
    "MODERATORS": [..., "myapp.moderation.ToxicityModerator"],   # inline, can reject
    # or run slow checks after storing, on the task runner:
    "ASYNC_MODERATORS": ["myapp.moderation.ToxicityModerator"],
}

With async moderators, notifications are sent only after the message passes.

Custom notification backend (push, SMS, websockets...)

from anonymous_messages.notifications import BaseNotificationBackend

class PushBackend(BaseNotificationBackend):
    def notify(self, message):
        fcm.send(user=message.recipient, title="New anonymous message 👀")

Signals

from django.dispatch import receiver
from anonymous_messages.signals import message_received

@receiver(message_received)
def on_message(sender, message, inbox, held, **kwargs):
    ...

Available: message_received, message_replied, sender_blocked, message_reported, message_moderated.

Swap serializers, permissions or fingerprinting

ANONYMOUS_MESSAGES = {
    # e.g. expose the owner's display name on the public profile
    "SERIALIZERS": {"public_inbox": "myapp.serializers.ProfileWithAvatar"},
    # only logged-in users may send
    "SEND_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
    # your own identity strategy: (request, inbox) -> str
    "FINGERPRINT_FUNCTION": "myapp.security.fingerprint",
}

Serializer keys: inbox, public_inbox, send, message, reply, public_reply, block, report_create, report, sent.

Use the service layer directly

Views are thin wrappers, so you can drive everything from your own code (GraphQL, websockets, tasks):

from anonymous_messages import services

msg = services.send_message(inbox=inbox, body="hi", fingerprint="any-stable-id")
services.reply_to_message(message=msg, body="hello!", is_public=True)
services.block_sender(message=msg)

Receiving signed webhooks

from anonymous_messages.notifications import verify_webhook_signature

ok = verify_webhook_signature(
    secret, request.headers["X-AnonMsg-Timestamp"], request.body,
    request.headers["X-AnonMsg-Signature"],
)

Management commands

Command Purpose
anonmsg_purge [--days N] Delete messages older than the retention period
anonmsg_reencrypt [--batch-size N] Re-encrypt all stored text with the current primary key

OpenAPI

All views use explicit serializers, so schema generators such as drf-spectacular document them automatically.

Compatibility

Python 3.10–3.14 · Django 4.2 LTS, 5.x, 6.x · DRF 3.14+ · any database Django supports.

Development

pip install -e ".[dev]"
pytest --cov
ruff check . && ruff format --check .
python -m build && twine check dist/*

License

MIT. See LICENSE.

Metadata

Release files for drf-anonymous-messages 1.0.0

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

Source distribution (sdist)

Source distribution for drf-anonymous-messages 1.0.0
File Size Uploaded
drf_anonymous_messages-1.0.0.tar.gz 42.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for drf-anonymous-messages 1.0.0
File Interpreter ABI Platform
drf_anonymous_messages-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 88.3 kB

Release files / drf_anonymous_messages-1.0.0.tar.gz

Download URL drf_anonymous_messages-1.0.0.tar.gz
Size 42.4 kB
Tags Source
SHA-256 checksum
How to use checksums
668bfd6e67b16a66ee37a0ecde2684bf36e567c2a2b37f3037aebb77efafc670
BLAKE2b-256 checksum
How to use checksums
841b5f9f89c96287b5d1197023f97f7e7b2b919c834e12c991d27044b8191a06
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 Oct 10, 2026.

Transparency log

Release files / drf_anonymous_messages-1.0.0-py3-none-any.whl

Download URL drf_anonymous_messages-1.0.0-py3-none-any.whl
Size 45.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3ec6c6cb8d8d34ca3314853f2e26f6f344ea9a8c3bce378c702dd4b1009f6746
BLAKE2b-256 checksum
How to use checksums
d1a06f94a524a800bc3f052fc84dfcda39bc8d2999f1bc6fdbf4a7366d3fe4d8
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 Oct 10, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files

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