django-freeletter
A small Django newsletter engine: subscribers with double opt-in, issues made of ordered content blocks, a web-viewable archive, and a plain management-command send queue. No e-commerce framework, no CMS, no particular project's assumptions baked in — just Django.
What this package does, and doesn't, do
Subscriber: email + optional name, double opt-in (confirm via a tokenized email link before ever receiving a send), a separate unsubscribe token.Issue: a draft → queued → sent newsletter, made of orderedIssueBlocks.- One built-in block type:
"html"— free-form content (rich text + an optional image). That's the only content type this package itself knows about. - A pluggable block-type registry (
freeletter.blocks) — anything else (a blog post teaser, a product card, whatever a host project wants to feature in an issue) is registered by a different package or the host project itself, pointing at its own template and its own model, via a generic(content_type, object_id)pointer. freeletter never imports or knows about that other model — see "Registering a block type" below. - A public archive (
freeletter/) of sent issues, subscribe/confirm/ unsubscribe views, and asend_freeletter_issuesmanagement command meant to run off a plain cron/systemd timer, not a task queue. - It does not ship any dashboard/admin UI beyond Django's own
admin.pyregistration, any e-commerce integration, or any CTA placement — all host-project scope.
Installation
uv add django-freeletter
INSTALLED_APPS = [
...,
"django.contrib.contenttypes", # required by IssueBlock's generic FK
"freeletter",
]
FREELETTER_FROM_EMAIL = "newsletter@example.com" # defaults to DEFAULT_FROM_EMAIL
FREELETTER_BASE_URL = "https://example.com" # required for links in emails sent outside a request (the send command)
# urls.py
urlpatterns = [
path("newsletter/", include("freeletter.urls")),
]
Run manage.py migrate.
Registering a block type
# yourapp/apps.py
from django.apps import AppConfig
class YourAppConfig(AppConfig):
def ready(self):
from freeletter.blocks import BlockType, register_block_type
register_block_type(
BlockType(
slug="product",
label="Product",
template_name="yourapp/freeletter_blocks/product.html",
)
)
{# yourapp/freeletter_blocks/product.html — `object` is the resolved content_object #}
<a href="{{ object.get_absolute_url }}">{{ object.title }} — {{ object.price }}</a>
An editor then creates an IssueBlock with block_type="product" and
content_object=<some product instance> (e.g. via Django admin, or a
host project's own dashboard) — freeletter resolves and renders it
without ever importing the product model.
Sending
manage.py send_freeletter_issues
Sends every Issue with status="queued" to every active (is_confirmed and not unsubscribed_at) subscriber, then marks it sent. A single
recipient's send failure is logged and skipped, not fatal to the run.
Point a systemd timer or cron entry at this command — there's no daemon
or task queue involved by design, matching the "prepare for sending in an
agnostic way" requirement this package was built for: swapping the mail
transport later (a different SMTP relay, an ESP used as an SMTP backend)
only ever means changing EMAIL_BACKEND, nothing here.
Optional batching, if a mail relay needs it:
FREELETTER_BATCH_SIZE = 50
FREELETTER_BATCH_DELAY = 2 # seconds, after every FREELETTER_BATCH_SIZE sends
Development
uv sync
uv run pytest
Metadata
Release files for django-freeletter 0.1.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 | |
|---|---|---|---|
| django_freeletter-0.1.0.tar.gz | 59.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| django_freeletter-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 76.5 kB
Release files / django_freeletter-0.1.0.tar.gz
| Download URL | django_freeletter-0.1.0.tar.gz |
|---|---|
| Size | 59.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
cda436cee7beaca6bb5280b3f542bdc6f329423b84848958eaa8643847183240
|
|
BLAKE2b-256 checksum How to use checksums |
0ab0c614fd07d703eca5e785d05b3c06fa577890906175b40b29cd561053e47b
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / django_freeletter-0.1.0-py3-none-any.whl
| Download URL | django_freeletter-0.1.0-py3-none-any.whl |
|---|---|
| Size | 17.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2f17296eef1a930ef78edf71702c3b40220d1af0bf434928286d910ce5796e43
|
|
BLAKE2b-256 checksum How to use checksums |
7170b7ef640e3b4f1eb01b5e239d057593eda2e8bb91d5a3e29c6775ff846980
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.9 {"installer":{"name":"uv","version":"0.12.9","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|