Flask newsletter + microblog package. Importable Blueprint, standalone web app, or headless API.
Project description
QUILLET
Flask newsletter + microblog package. Three deployment modes, pluggable backends, minimal deps.
pip install "quillet[sqlalchemy,mailgun]"
Three modes
| Mode | When to use |
|---|---|
| Blueprint | Add newsletters to an existing Flask app |
| Standalone web | Run as a separate Docker service, proxy via nginx |
| Headless API | JSON-only backend; bring your own frontend |
Mode 1 — Flask Blueprint
from quillet import create_blueprint, get_or_create_newsletter
from quillet.db.sqlalchemy import SQLAlchemyRepository
from quillet.email.mailgun import MailgunSender
db = SQLAlchemyRepository("sqlite:///newsletter.db")
# Seed your newsletter on first run — safe to call on every startup.
get_or_create_newsletter(db, slug="blog", name="My Blog", from_email="hi@example.com")
app.register_blueprint(
create_blueprint(
db=db,
email=MailgunSender(api_key="...", domain="..."),
admin_password="secret",
),
url_prefix="/newsletter",
)
All routes are now available at /newsletter/<newsletter_slug>/. Visit http://localhost:5000/newsletter/blog/.
You can also seed from the CLI instead (idempotent — safe to re-run):
export FLASK_APP=your_app
flask quillet create "My Blog" --slug=blog --from-email=hi@example.com
Blueprint options
create_blueprint(
db=...,
email=...,
admin_password="secret",
admin_username="admin", # Basic Auth username (default: "admin")
mode="web", # "web" (default) or "api" — disables HTML routes
admin_ui=True, # set False to disable the browser admin UI
base_url="", # override for email links (confirm, unsubscribe)
name="quillet", # blueprint name; change if you register multiple instances
)
Mode 2 — Standalone Docker (web)
docker compose up
Visit http://localhost:8000. Configure via environment variables:
| Variable | Default | Description |
|---|---|---|
QUILLET_MODE |
web |
web or api |
QUILLET_ADMIN_PASSWORD |
(required) | Basic Auth password |
QUILLET_ADMIN_USERNAME |
admin |
Basic Auth username |
QUILLET_ADMIN_UI |
true |
Set false to disable the browser admin panel |
QUILLET_BASE_URL |
(host URL) | Public URL used in email links — set this in production |
QUILLET_DB_BACKEND |
sqlalchemy |
sqlalchemy or supabase_rest |
QUILLET_DB_URL |
sqlite:////data/quillet.db |
SQLAlchemy connection string |
QUILLET_SUPABASE_URL |
— | Supabase project URL |
QUILLET_SUPABASE_KEY |
— | Supabase anon key |
QUILLET_EMAIL_BACKEND |
smtp |
mailgun, smtp, or noop |
QUILLET_MAILGUN_API_KEY |
— | Mailgun private API key |
QUILLET_MAILGUN_DOMAIN |
— | Mailgun sending domain |
QUILLET_MAILGUN_REGION |
us |
Mailgun region — us or eu |
QUILLET_MAILGUN_SENDER_EMAIL |
quillet@<domain> |
Envelope From address (must be on your Mailgun domain for SPF/DKIM to pass) |
QUILLET_SUBJECT_PREFIX |
(empty) | Prepended to every post subject line, e.g. [My Blog] |
QUILLET_SMTP_HOST |
localhost |
SMTP host |
QUILLET_SMTP_PORT |
587 |
SMTP port |
QUILLET_SMTP_USE_TLS |
true |
Set false for local dev (e.g. Mailhog) |
QUILLET_SMTP_USERNAME |
— | SMTP username (optional) |
QUILLET_SMTP_PASSWORD |
— | SMTP password (optional) |
QUILLET_SMTP_FROM_EMAIL |
(required) | Sender email address |
QUILLET_SMTP_FROM_NAME |
— | Sender display name |
Create a newsletter after starting (idempotent):
docker compose exec quillet flask quillet create "My Blog" --slug=blog --from-email=hi@example.com
Mode 3 — Headless API
Set QUILLET_MODE=api (and QUILLET_ADMIN_UI=false). All endpoints return JSON only; no HTML templates are rendered.
Routes
All routes are prefixed with /<newsletter_slug>/.
Public
| Method | Path | Description |
|---|---|---|
GET |
/<slug>/ |
Post archive. Returns HTML or JSON (Accept: application/json). |
GET |
/<slug>/posts/<post_slug> |
Single post. Returns HTML or JSON. |
POST |
/<slug>/subscribe |
Subscribe. Accepts form email or JSON {"email": "..."}. Sends double opt-in email. |
GET |
/<slug>/confirm/<token> |
Confirm subscription via emailed link. |
GET |
/<slug>/unsubscribe/<token> |
Unsubscribe via emailed link. |
GET |
/<slug>/feed.xml |
RSS 2.0 feed of published posts. |
Admin browser UI (Basic Auth)
| Method | Path | Description |
|---|---|---|
GET |
/<slug>/admin/ |
Dashboard — post list with status badges, subscriber count. |
GET/POST |
/<slug>/admin/posts/new |
Create post form. |
GET/POST |
/<slug>/admin/posts/<post_slug>/edit |
Edit post. Publish and Send buttons are inline. |
POST |
/<slug>/admin/posts/<post_slug>/publish |
Publish (sets published_at). |
POST |
/<slug>/admin/posts/<post_slug>/send |
Send to all confirmed subscribers. Idempotent — a sent post cannot be re-sent. |
POST |
/<slug>/admin/posts/<post_slug>/delete |
Delete a post. Warns if the post was already sent. |
GET |
/<slug>/admin/subscribers |
Subscriber list with confirmation status. |
POST |
/<slug>/admin/subscribers/<id>/delete |
Hard-delete a subscriber. |
JSON API (Basic Auth)
| Method | Path | Body / Response |
|---|---|---|
POST |
/<slug>/api/posts |
{"title", "slug", "body_md"} → {"post": {...}} |
POST |
/<slug>/api/posts/<post_slug>/publish |
→ {"post": {...}} |
POST |
/<slug>/api/posts/<post_slug>/send |
→ {"ok": true, "recipients": N} |
DELETE |
/<slug>/api/posts/<post_slug> |
→ {"ok": true} |
GET |
/<slug>/api/subscribers |
→ {"subscribers": [...]} |
DELETE |
/<slug>/api/subscribers/<id> |
→ {"ok": true} |
CLI
# Create a newsletter (idempotent — safe to re-run)
flask quillet create "My Blog" --slug=blog --from-email=hi@example.com --from-name="My Blog"
# List all newsletters
flask quillet list
# Send a published post
flask quillet send blog my-post-slug
# Force re-send (already sent posts are blocked by default)
flask quillet send blog my-post-slug --force
# List subscribers
flask quillet subscribers blog
Custom backends
Implement the NewsletterRepository or EmailSender protocol — no base classes, just matching method signatures.
Custom DB backend
from quillet.db import NewsletterRepository
from quillet.models import Newsletter, Post, Subscriber
class MyRepository:
def get_newsletter(self, slug: str) -> Newsletter | None: ...
def list_posts(self, newsletter_slug: str, published_only: bool = True) -> list[Post]: ...
def get_post(self, newsletter_slug: str, post_slug: str) -> Post | None: ...
def create_post(self, newsletter_slug: str, title: str, slug: str, body_md: str) -> Post: ...
def update_post(self, post_id: int, title: str, slug: str, body_md: str) -> Post: ...
def publish_post(self, post_id: int) -> Post: ...
def mark_sent(self, post_id: int) -> None: ...
def delete_post(self, post_id: int) -> None: ...
def add_subscriber(self, newsletter_slug: str, email: str, token: str) -> Subscriber: ...
def confirm_subscriber(self, token: str) -> Subscriber | None: ...
def list_confirmed_subscribers(self, newsletter_slug: str) -> list[Subscriber]: ...
def list_all_subscribers(self, newsletter_slug: str) -> list[Subscriber]: ...
def unsubscribe(self, token: str) -> None: ...
def delete_subscriber(self, subscriber_id: int) -> None: ...
def create_newsletter(self, slug: str, name: str, from_email: str, from_name: str, reply_to: str | None) -> Newsletter: ...
Custom email backend
from quillet.email import EmailSender
from quillet.models import Newsletter, Post, Subscriber
class MyEmailSender:
def send_confirmation(
self,
newsletter: Newsletter,
subscriber: Subscriber,
confirm_url: str,
) -> None: ...
def send_post(
self,
newsletter: Newsletter,
post: Post,
subscribers: list[Subscriber],
unsubscribe_url_template: str, # contains {token} placeholder
) -> None: ...
Template overrides
All built-in templates live at quillet/templates/quillet/. Flask resolves templates in this order: app templates → blueprint templates. Drop an override anywhere Flask finds templates first.
Blueprint mode — add to your app's templates/ directory:
templates/
└── quillet/
├── base.html
├── post_list.html
├── post_detail.html
├── subscribe_confirm.html
└── admin/
├── dashboard.html
├── post_form.html
└── subscribers.html
base.html exposes {% block head %} (inside <head>) and {% block content %} for easy integration with a parent layout.
Standalone Docker — mount a volume:
volumes:
- ./my-templates:/app/templates/quillet
Template context variables
| Template | Variables |
|---|---|
post_list.html |
newsletter, posts, subscribe_error (optional) |
post_detail.html |
newsletter, post, post_html (rendered HTML string) |
subscribe_confirm.html |
newsletter, state (pending/confirmed/unsubscribed/invalid) |
admin/dashboard.html |
newsletter, posts, subscriber_count, confirmed_count |
admin/post_form.html |
newsletter, post (None if new), error (optional) |
admin/subscribers.html |
newsletter, subscribers |
All model fields are accessible as attributes (e.g. newsletter.name, post.title, post.published_at).
Template blocks
base.html defines the following overridable blocks:
| Block | Default | Notes |
|---|---|---|
title |
newsletter.name |
Also used as og:title |
description |
(empty) | Populates <meta name="description"> and OG/Twitter tags when non-empty |
canonical |
(empty) | Populates <link rel="canonical"> and og:url when non-empty |
og_type |
website |
Override to article on post pages |
head |
(empty) | Extra content inside <head> (styles, scripts) |
content |
(empty) | Page body |
post_detail.html sets description (first 160 chars of body text), canonical (absolute post URL), and og_type (article) automatically.
A wordcount Jinja2 filter is registered on the app, so you can use reading-time estimates in any template override:
{{ [1, ((post_html | striptags | wordcount) / 200) | round | int] | max }} min read
Development email backend
Use noop to skip all email sending — confirmation links are printed to logs instead:
from quillet.email.noop import NoopSender
create_blueprint(db=..., email=NoopSender(), admin_password="...")
Or via env var: QUILLET_EMAIL_BACKEND=noop
Testing
# Fast: Flask test client, no server required
python scripts/smoke_test.py
# Full: build Docker image + one-shot container test
./scripts/docker_test.sh
# Skip rebuild
./scripts/docker_test.sh --no-build
# HTTP tests against any running instance
QUILLET_TEST_URL=https://your-server.com \
QUILLET_TEST_SLUG=blog \
QUILLET_ADMIN_PASSWORD=secret \
python scripts/http_smoke_test.py
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 quillet-0.4.1.tar.gz.
File metadata
- Download URL: quillet-0.4.1.tar.gz
- Upload date:
- Size: 35.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
7dd9c0a4b22e8f659a7bcdc3530bcbb5a4bf9aa844187d917dd7ebd25c06f2ac
|
|
| MD5 |
d92e1b3319123163b2cb4edc4ea57361
|
|
| BLAKE2b-256 |
1a285ed9e8eabb9a417ed013dc2dcff0b20a228c803918b0aca61a748012821c
|
Provenance
The following attestation bundles were made for quillet-0.4.1.tar.gz:
Publisher:
release.yml on TinMarkovic/quillet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
quillet-0.4.1.tar.gz -
Subject digest:
7dd9c0a4b22e8f659a7bcdc3530bcbb5a4bf9aa844187d917dd7ebd25c06f2ac - Sigstore transparency entry: 1295519804
- Sigstore integration time:
-
Permalink:
TinMarkovic/quillet@30ed0dfc4aa491d39874bbce9c27c8dc95679c1c -
Branch / Tag:
refs/tags/v0.4.1 - Owner: https://github.com/TinMarkovic
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@30ed0dfc4aa491d39874bbce9c27c8dc95679c1c -
Trigger Event:
push
-
Statement type:
File details
Details for the file quillet-0.4.1-py3-none-any.whl.
File metadata
- Download URL: quillet-0.4.1-py3-none-any.whl
- Upload date:
- Size: 30.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
67c576aec0248931f27860bb53aa63fd33ba6f65eb08c8942050c7ed5c41854e
|
|
| MD5 |
10225b8e2e995bdc7455382d20bc7e61
|
|
| BLAKE2b-256 |
9c57db1ed3f775468f86658d49dbf32ab3711f8ae66a4b1e22449f923abdc6cd
|
Provenance
The following attestation bundles were made for quillet-0.4.1-py3-none-any.whl:
Publisher:
release.yml on TinMarkovic/quillet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
quillet-0.4.1-py3-none-any.whl -
Subject digest:
67c576aec0248931f27860bb53aa63fd33ba6f65eb08c8942050c7ed5c41854e - Sigstore transparency entry: 1295519869
- Sigstore integration time:
-
Permalink:
TinMarkovic/quillet@30ed0dfc4aa491d39874bbce9c27c8dc95679c1c -
Branch / Tag:
refs/tags/v0.4.1 - Owner: https://github.com/TinMarkovic
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@30ed0dfc4aa491d39874bbce9c27c8dc95679c1c -
Trigger Event:
push
-
Statement type: