Skip to main content

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_MAILGUN_SENDER_NAME (newsletter's from_name) Display name shown in the From field; defaults to the newsletter's own name
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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

quillet-0.5.1.tar.gz (38.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

quillet-0.5.1-py3-none-any.whl (34.2 kB view details)

Uploaded Python 3

File details

Details for the file quillet-0.5.1.tar.gz.

File metadata

  • Download URL: quillet-0.5.1.tar.gz
  • Upload date:
  • Size: 38.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for quillet-0.5.1.tar.gz
Algorithm Hash digest
SHA256 23649ddd51842cf9e3c519f707fe7468e1941630e775818249b761443f735723
MD5 742d3a9efa579841a6b78970a2809b95
BLAKE2b-256 a72413f625366fa10c9f631feaffaafe0933a0eb248de1cd9117e79098372ca5

See more details on using hashes here.

Provenance

The following attestation bundles were made for quillet-0.5.1.tar.gz:

Publisher: release.yml on TinMarkovic/quillet

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file quillet-0.5.1-py3-none-any.whl.

File metadata

  • Download URL: quillet-0.5.1-py3-none-any.whl
  • Upload date:
  • Size: 34.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for quillet-0.5.1-py3-none-any.whl
Algorithm Hash digest
SHA256 d7afc585095fffcf8d9651de033d2ac2a5f7f0f0b939bc0233b6d9eba00adf93
MD5 395d1511762edebc2b6c658586272eb5
BLAKE2b-256 7e00ea3e970eae667f07ecaf1af70e42f3999a322f716e099961b5fc163f1279

See more details on using hashes here.

Provenance

The following attestation bundles were made for quillet-0.5.1-py3-none-any.whl:

Publisher: release.yml on TinMarkovic/quillet

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page