Metadata-Version: 2.5
Name: drf-anonymous-messages
Version: 1.0.0
Summary: Pluggable, secure, API-first anonymous messaging (NGL / Sarahah / Tellonym style) for Django REST Framework.
Project-URL: Homepage, https://github.com/NzeStan/anonymous-message
Project-URL: Documentation, https://github.com/NzeStan/anonymous-message#readme
Project-URL: Repository, https://github.com/NzeStan/anonymous-message
Project-URL: Issues, https://github.com/NzeStan/anonymous-message/issues
Project-URL: Changelog, https://github.com/NzeStan/anonymous-message/blob/main/CHANGELOG.md
Author-email: Ifeanyi Stanley Nnamani <nnamaniifeanyi10@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: anonymous,api,django,djangorestframework,drf,inbox,messages,ngl,sarahah,tellonym
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Framework :: Django :: 5.1
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: django>=4.2
Requires-Dist: djangorestframework>=3.14
Provides-Extra: all
Requires-Dist: celery>=5.3; extra == 'all'
Requires-Dist: cryptography>=42; extra == 'all'
Provides-Extra: celery
Requires-Dist: celery>=5.3; extra == 'celery'
Provides-Extra: dev
Requires-Dist: build>=1.2; extra == 'dev'
Requires-Dist: celery>=5.3; extra == 'dev'
Requires-Dist: cryptography>=42; extra == 'dev'
Requires-Dist: pytest-cov>=5; extra == 'dev'
Requires-Dist: pytest-django>=4.8; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Requires-Dist: twine>=5; extra == 'dev'
Provides-Extra: encryption
Requires-Dist: cryptography>=42; extra == 'encryption'
Description-Content-Type: text/markdown

# drf-anonymous-messages

[![PyPI](https://img.shields.io/pypi/v/drf-anonymous-messages.svg)](https://pypi.org/project/drf-anonymous-messages/)
[![Python](https://img.shields.io/pypi/pyversions/drf-anonymous-messages.svg)](https://pypi.org/project/drf-anonymous-messages/)
[![Django](https://img.shields.io/badge/django-4.2%20%7C%205.x%20%7C%206.x-0C4B33.svg)](https://www.djangoproject.com/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)

**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

```bash
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]"
```

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "rest_framework",
    "anonymous_messages",
]
```

```python
# urls.py
from django.urls import include, path

urlpatterns = [
    path("api/anon/", include("anonymous_messages.urls")),
]
```

```bash
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

```bash
# 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:

```json
{"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.

```python
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

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

### Encryption at rest

```python
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

```python
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)

```python
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...)

```python
from anonymous_messages.notifications import BaseNotificationBackend

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

### Signals

```python
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

```python
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):

```python
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

```python
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](https://github.com/tfranzel/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

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

## License

MIT. See [LICENSE](LICENSE).
