Turn a persisted Notification row into a delivered email, SMS or in-site message, with an audit trail of every attempt. PostgreSQL only.
Project description
django-notifier-hub
Notifications as rows. Your code writes a Notification; the package delivers it by email, SMS or
in-site, and records every attempt.
from notifier import send
send(
channel="email",
recipients=["customer@example.com"],
key="order-shipped", # a template stored in the database
context={"order": order.number},
source="order.shipped", # what caused this, for later triage
)
There is no send_email() to call from a view, no queue to wire up, and no branch for "does this
project have Celery". A row reaching status="ready" is what causes delivery.
| Install | pip install django-notifier-hub |
| Import path / app label | notifier |
| Python | ≥ 3.13 |
| Django | ≥ 5.2.8 (5.2 LTS, 6.0) |
| Database | PostgreSQL only |
| Hard dependencies | Django only ([celery] extra for async delivery) |
| Licence | MIT |
Status:
0.1.0, the first release. All four milestones are implemented and tested (227 tests, both a full and a headless Django configuration). The public API is a compatibility promise from0.1.0onward.
Requirements, in full
PostgreSQL is not optional. ArrayField, a GIN index and array-equality check constraints are
load-bearing. There is no SQLite or MySQL fallback, and manage.py check will tell you so.
No user model needed. There is no foreign key to AUTH_USER_MODEL and no dependency on
django.contrib.auth, admin or contenttypes, so this runs on a headless service with no user
accounts at all. A complete install:
INSTALLED_APPS = [
"django.contrib.postgres", # required: Django only allows ArrayField when this is installed
"notifier",
]
TEMPLATES = [
# Required: bodies render through your template engine, so your own tags and
# filters work. No context processors are needed -- there is no request.
{"BACKEND": "django.template.backends.django.DjangoTemplates"},
]
Then python manage.py migrate. manage.py check reports anything missing with an actionable
message rather than leaving you to guess.
How it works
- You create a row. Either through
send()/send_multi(), orNotification.objects.create()directly when you want to review something before it goes out. - Reaching
readytriggers delivery — on create, or on a transition intoreadyfrom any other status. Aready→readyre-save does not re-send. - Dispatch waits for the transaction to commit, so a worker never queries a row your transaction has not committed, and a rollback sends nothing.
- One
NotificationLogrow per attempt — success, failure, suppression or skip. The log is a history, not a last-known state.
draft ──mark_ready()──> ready ──dispatch──> sending ──┬── ok ────> sent
▲ │
│ └── error ─> failed
└────── mark_ready() / resend() ──────────┘
suppressed and skipped leave the status at ready: the notification never claimed to have been
sent, so re-running it in a working environment just works.
Non-production safety
Outside production, real recipients are never contacted. The environment resolves as
NOTIFIER["ENV"] → settings.DJANGO_ENV → $DJANGO_ENV → "dev". Anything not in
PRODUCTION_ENVS is non-production — including a project that configures nothing at all, which is
deliberate: the accident this produces is "staging sent nothing", never "staging emailed the
customer list".
In non-production, recipients are replaced by the enabled DefaultRecipient rows for that channel.
If there are none, the send is suppressed and nothing leaves the process. The log keeps the
original list in requested_recipients, so you can see what would have gone out.
In-site is exempt: it has no transport, and the row is persisted before dispatch, so gating it would protect nobody while making local development impossible.
Templates
NotificationTemplate rows hold Django template strings, keyed by (key, channel) — one logical
key resolves per channel, which is what lets a single send_multi() call reach three channels.
NotificationTemplate.objects.create(
key="order-shipped",
channel="email",
name="Order shipped",
subject="Order {{ order }} has shipped",
body_text="Hello, order {{ order }} is on its way.",
body_html="<p>Hello, order <strong>{{ order }}</strong> is on its way.</p>",
)
Rendering is the full Django engine — loops, filters, your own template tags, autoescaping — so
it behaves as a TemplateResponse does, with one difference: there is no request.
{{ request }}, {{ user }} and {% csrf_token %} render empty, and {% url %} gives a path
rather than an absolute URL. Pass anything else the template needs in context, and supply your
site's origin through TEMPLATE_CONTEXT_PROCESSOR.
What was actually sent is snapshotted onto the notification (rendered_subject, rendered_text,
rendered_html) at send time. Editing a template later never rewrites history.
Security: a full template engine means anyone who can edit a
NotificationTemplatecan execute template tags in your server process. Treatchange_notificationtemplateas a privileged permission.
A notification with several addresses is one message, one SMTP transaction, one log row.
By default the header depends on how many recipients there are: one goes in To:, two or more
go in Bcc:.
NOTIFIER = {"EMAIL": {"RECIPIENT_MODE": "auto"}} # "auto" (default) | "to" | "bcc" | "separate"
With two or more recipients, putting them all in To: leaks one customer's address to another and
cannot be undone, so the list is hidden. With exactly one recipient there is nobody to leak to, so
the mail is sent normally — a Bcc-only envelope arrives with a blank recipient field, which looks
wrong for a receipt or a password reset and scores worse with some spam filters.
The count is the effective one, after non-production redirection: three recipients collapsing to
one QA mailbox in staging send as to.
Use an explicit to for group threads where replies should reach everyone, bcc to hide the list
unconditionally, and separate when each recipient needs their own message.
SMTP settings are Django's: EMAIL_HOST, EMAIL_HOST_USER, EMAIL_USE_TLS, DEFAULT_FROM_EMAIL,
mail.outbox in tests, and any third-party email backend all work unchanged. The package defines
none of its own. NOTIFIER["EMAIL"]["CONNECTION"] exists only to send over a different server.
The plaintext part
An HTML email always gets a text/plain alternative. If you write body_text, that is used
verbatim. If you write only HTML, it is derived — keeping link targets, paragraphs and list bullets:
<p>Track it <a href="https://app.example.com/track/A-1001">here</a>.</p>
<ul><li>2x Widget</li><li>1x Gadget</li></ul>
Track it here (https://app.example.com/track/A-1001).
- 2x Widget
- 1x Gadget
The derivation is stdlib-only and deliberately modest: no text wrapping, no table layout, no CSS.
For exact plaintext, write body_text — it always wins.
Verifying against real infrastructure
The test suite runs against locmem and eager Celery, which cannot show you how a real MTA treats
your envelope or what a real worker does when it dies. example/ is a real Django project wired to
a throwaway stack for exactly that:
docker compose up -d # PostgreSQL, MailPit (SMTP + web UI), Redis
cd example
python manage.py migrate
python smoke.py # real SMTP: envelopes, encoding, multipart, suppression
NOTIFIER_USE_CELERY=1 celery -A config worker -l info &
python smoke_celery.py # real worker: the on_commit handoff, rollback
python smoke_celery_stuck.py # the two ways a row can be stranded
Read the actual messages at http://localhost:8025.
smoke_real_smtp.py sends through a real third-party provider instead of MailPit. It takes
everything from the environment (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD,
SMTP_FROM, SMTP_TO) and hardcodes nothing — this repository is public, so a real address or
credential committed here would be permanent.
These scripts have already earned their keep: the auto recipient mode and the corrected
no-worker status both came out of running them.
SMS
The package ships the abstraction plus console and locmem backends. No vendor SDK is or becomes a dependency — integrating a provider is a subclass:
# myproject/sms.py
import requests
from notifier.backends.sms import BaseSmsBackend
class HttpGatewaySmsBackend(BaseSmsBackend):
# Transport errors worth retrying. Anything else is treated as permanent.
retryable_exceptions = (requests.RequestException,)
def send_one(self, number, message):
"""Deliver to one number. Return a provider reference, or raise."""
response = requests.post(
"https://gateway.example.com/v1/sms",
json={"to": number, "from": self.sender, "text": message.text},
headers={"Authorization": f"Bearer {self.provider_options['api_key']}"},
timeout=10,
)
response.raise_for_status()
return response.json()["message_id"]
NOTIFIER = {
"BACKENDS": {"sms": "myproject.sms.HttpGatewaySmsBackend"},
"SMS": {"FROM": "MYAPP", "OPTIONS": {"api_key": os.environ["SMS_API_KEY"]}},
}
recipients may hold one number or many. Providers are per-number, so the base class fans out and
records a per-number outcome in provider_response — while staying one notification and one log
row, because the unit of work is the send, not the number.
Two failure rules the base class applies for you:
- Nothing got through and every failure was retryable → the exception is re-raised, so retries engage.
- Some got through → reported as a failure but not retried, because retrying would send a second message to the numbers that worked.
In-site
InsiteBackend performs no transport; the rendered snapshot is what your app reads.
recipients holds user ids as strings, and read state is read_by, an integer array your project
owns:
notification.mark_read(request.user.pk)
notification.is_read_by(request.user.pk)
unread = Notification.objects.filter(channel="insite", recipients__contains=[str(user.pk)]).exclude(
read_by__contains=[user.pk]
)
This is deliberately a convenience rather than an optimised feature: read_by is unindexed, and
marking read is a read-modify-write on a shared row. Fine for a secondary channel at low volume; if
in-site becomes primary, the answer is a join table, which is a v2 decision.
Several channels at once
One message over email, SMS and in-site creates three rows sharing a group_id — not one row
with three channels, because a row has one status and three channels have three outcomes.
from notifier import send_multi
group_id = send_multi(
key="stock-alert",
recipients={
"email": ["ops@example.com"],
"sms": ["+6421000001"],
"insite": [str(user.pk)],
},
context={"sku": sku},
)
Notification.objects.filter(group_id=group_id) # the whole group API
All rows are validated and created together, so a missing template creates nothing. After that delivery is independent: one channel failing neither blocks nor rolls back the others, and outside production a group can legitimately end up with mixed results.
Async delivery
With [celery] installed and a broker configured, delivery is queued; otherwise it runs inline. The
same deliver() runs either way — only NotificationLog.is_async differs.
NOTIFIER = {
"USE_CELERY": True, # None (default) auto-detects
"CELERY_MAX_RETRIES": 3,
"CELERY_RETRY_BACKOFF": 30, # seconds, exponential: 30, 60, 120
}
Set
USE_CELERYexplicitly in production. Auto-detection asks whether Celery is importable and has a broker — which is not the same as a worker being alive. A project with Celery installed and no worker running will enqueue tasks nobody consumes, leaving notifications atreadyuntil a worker appears. A worker that dies inside a send is the different, worse case: that strands the row atsendingwith no log row, andnotifier_retry_failed --include-sending --older-than 30mis what reclaims it.
Every attempt writes its own log row. When the retry budget is spent, notification_exhausted
fires once — "attempt 3 of 3 failed" and "we have stopped trying" are different events, and only
the second is worth paging someone about.
Signals
All are sent with sender=Notification.
| Signal | Fired when | Extra kwargs |
|---|---|---|
pre_send |
after render, before the backend runs | notification, recipients, message |
notification_sent |
the backend accepted the message | notification, log |
notification_failed |
the backend raised or rejected | notification, log, exception |
notification_suppressed |
non-production, no default recipient | notification, log |
notification_exhausted |
the final retry failed | notification, log, attempts |
Settings
NOTIFIER = {
# environment gating
"ENV": None, # None -> settings.DJANGO_ENV -> $DJANGO_ENV -> "dev"
"PRODUCTION_ENVS": ("prod", "production"),
"NON_PROD_SUBJECT_PREFIX": "[{env}] ", # "" disables
"SUPPRESS_WHEN_NO_DEFAULT_RECIPIENT": True,
# backends
"BACKENDS": {
"email": "notifier.backends.email.DjangoEmailBackend",
"sms": "notifier.backends.sms.ConsoleSmsBackend",
"insite": "notifier.backends.insite.InsiteBackend",
},
"EMAIL": {
"FROM": None, # None -> DEFAULT_FROM_EMAIL
"CONNECTION": None, # dict of get_connection() kwargs
"FAIL_SILENTLY": False,
"RECIPIENT_MODE": "auto",
},
"SMS": {"FROM": None, "OPTIONS": {}},
# delivery
"USE_CELERY": None,
"CELERY_QUEUE": None,
"CELERY_MAX_RETRIES": 3,
"CELERY_RETRY_BACKOFF": 30,
"DISPATCH_ON_COMMIT": True, # you almost certainly want this on
# rendering
"TEMPLATE_CONTEXT_PROCESSOR": None, # dotted path -> dict, merged under context
# housekeeping
"LOG_RETENTION_DAYS": 90,
}
Management commands
| Command | Purpose |
|---|---|
notifier_send_test --channel email --to a@example.com |
Smoke-test the configuration without touching business data. |
notifier_retry_failed [--older-than 2h] [--limit N] [--channel sms] [--dry-run] |
Re-dispatch failed notifications. |
notifier_prune_logs [--days N] [--dry-run] |
Delete logs past LOG_RETENTION_DAYS. |
LOG_RETENTION_DAYSis a policy, not a mechanism. Nothing deletes anything on a timer. Schedulenotifier_prune_logsyourself (cron, Celery beat, a systemd timer) — otherwise the log table grows forever while the setting claims 90 days.
Testing your own project
from django.core import mail
from notifier.backends import sms # sms.outbox, the SMS analogue of mail.outbox
NOTIFIER = {
"BACKENDS": {"sms": "notifier.backends.sms.LocmemSmsBackend"},
"USE_CELERY": False,
}
If your tests run inside a transaction that rolls back — pytest-django's default, and Django's
TestCase — transaction.on_commit callbacks never fire, so nothing is dispatched. Either use
django_capture_on_commit_callbacks / TestCase.captureOnCommitCallbacks, or set
NOTIFIER["DISPATCH_ON_COMMIT"] = False in your test settings.
Deliberately out of scope
Each of these is a decision, not an oversight:
- Deduplication.
ready → draft → readysends twice. No idempotency key. - Per-recipient delivery status. A log row records the batch; per-address detail, where a
provider returns it, lands in
provider_response. - Scheduled send, bounce webhooks, attachments, digests, throttling.
- Fallback between channels. "SMS only if the email fails" is not expressible; channels in a group are independent and simultaneous.
Development
uv sync --all-extras --group dev
uv run ruff check . && uv run ruff format .
uv run pytest # requires a reachable PostgreSQL
uv run pytest --ds=tests.settings_minimal # the headless configuration
Database parameters come from NOTIFIER_TEST_DB_HOST / _PORT / _USER / _PASSWORD / _NAME.
PRD.md is the design record: it states why each alternative was rejected, which is
usually more useful than the code when deciding whether to change something.
Licence
MIT. Published for reuse on a best-effort basis, with no support commitment — issues and pull requests are welcome but carry no response-time promise, and changes that do not serve this project's own use may be declined.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file django_notifier_hub-0.1.0.tar.gz.
File metadata
- Download URL: django_notifier_hub-0.1.0.tar.gz
- Upload date:
- Size: 91.7 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d934d841bf2ffd5986dfc583de0b2e3a667fc43054581f472e7ee615db5e6a9
|
|
| MD5 |
bc04e931287fe12c82123edc7ac05cdd
|
|
| BLAKE2b-256 |
7bbb74752a0f78700d4621b25cfbcff82b5eda12a68148ed68c4efdb07407324
|
Provenance
The following attestation bundles were made for django_notifier_hub-0.1.0.tar.gz:
Publisher:
release.yml on ck-tech-nz/django-notifier-hub
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_notifier_hub-0.1.0.tar.gz -
Subject digest:
8d934d841bf2ffd5986dfc583de0b2e3a667fc43054581f472e7ee615db5e6a9 - Sigstore transparency entry: 2294515706
- Sigstore integration time:
-
Permalink:
ck-tech-nz/django-notifier-hub@ca2f417cb1e0c46f47c8575e08974de7f1b21b66 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ck-tech-nz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ca2f417cb1e0c46f47c8575e08974de7f1b21b66 -
Trigger Event:
push
-
Statement type:
File details
Details for the file django_notifier_hub-0.1.0-py3-none-any.whl.
File metadata
- Download URL: django_notifier_hub-0.1.0-py3-none-any.whl
- Upload date:
- Size: 47.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f0c5fa43524aef596b86ebccb48e416f1e92ed93600f65b9f9a2604a7d972c53
|
|
| MD5 |
f2ec2096b22b45e7673112816e946745
|
|
| BLAKE2b-256 |
9825d5d20221d2dece907d275ee7779e107f179fb4521b5dc591b6e50a80cf82
|
Provenance
The following attestation bundles were made for django_notifier_hub-0.1.0-py3-none-any.whl:
Publisher:
release.yml on ck-tech-nz/django-notifier-hub
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
django_notifier_hub-0.1.0-py3-none-any.whl -
Subject digest:
f0c5fa43524aef596b86ebccb48e416f1e92ed93600f65b9f9a2604a7d972c53 - Sigstore transparency entry: 2294515730
- Sigstore integration time:
-
Permalink:
ck-tech-nz/django-notifier-hub@ca2f417cb1e0c46f47c8575e08974de7f1b21b66 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/ck-tech-nz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@ca2f417cb1e0c46f47c8575e08974de7f1b21b66 -
Trigger Event:
push
-
Statement type: