drf-anonymous-messages
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"](orCACHE_ALIAS) at Redis or Memcached. - Cursor pagination keeps every page O(1), however large an inbox gets.
Message.recipientis 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 singleUPDATE/DELETEstatements, with the purge running in bounded batches. - Side effects never block the request: in
on_commitmode 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)
| File | Size | Uploaded | |
|---|---|---|---|
| drf_anonymous_messages-1.0.0.tar.gz | 42.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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