Metadata-Version: 2.5
Name: quillet
Version: 0.9.1
Summary: Flask newsletter + microblog package. Importable Blueprint, standalone web app, or headless API.
Project-URL: Repository, https://github.com/tinthe/quillet
License: MIT
License-File: LICENSE
Keywords: email,flask,microblog,newsletter
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Flask
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Requires-Python: >=3.10
Requires-Dist: flask>=3.0
Requires-Dist: markdown2>=2.4
Provides-Extra: all
Requires-Dist: a2wsgi>=1.10; extra == 'all'
Requires-Dist: mcp[cli]<2,>=1.28; extra == 'all'
Requires-Dist: requests>=2.31; extra == 'all'
Requires-Dist: sqlalchemy>=2.0; extra == 'all'
Requires-Dist: starlette>=0.37; extra == 'all'
Requires-Dist: uvicorn>=0.30; extra == 'all'
Provides-Extra: dev
Requires-Dist: a2wsgi>=1.10; extra == 'dev'
Requires-Dist: black; extra == 'dev'
Requires-Dist: mcp[cli]<2,>=1.28; extra == 'dev'
Requires-Dist: pytest-flask>=1.3; extra == 'dev'
Requires-Dist: pytest>=8.0; extra == 'dev'
Requires-Dist: requests>=2.31; extra == 'dev'
Requires-Dist: ruff; extra == 'dev'
Requires-Dist: sqlalchemy>=2.0; extra == 'dev'
Requires-Dist: starlette>=0.37; extra == 'dev'
Requires-Dist: uvicorn>=0.30; extra == 'dev'
Provides-Extra: mailgun
Requires-Dist: requests>=2.31; extra == 'mailgun'
Provides-Extra: mcp
Requires-Dist: a2wsgi>=1.10; extra == 'mcp'
Requires-Dist: mcp[cli]<2,>=1.28; extra == 'mcp'
Requires-Dist: starlette>=0.37; extra == 'mcp'
Requires-Dist: uvicorn>=0.30; extra == 'mcp'
Provides-Extra: smtp
Provides-Extra: sqlalchemy
Requires-Dist: sqlalchemy>=2.0; extra == 'sqlalchemy'
Provides-Extra: supabase
Requires-Dist: requests>=2.31; extra == 'supabase'
Description-Content-Type: text/markdown

# 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

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

# Site-root /robots.txt is not mounted by the blueprint (the host may already
# own that path). Generate it from the live slug list:
from quillet import robots_txt, register_robots

register_robots(app, db, url_prefix="/newsletter", base_url="https://example.com")
# Or splice into an existing view:
# @app.get("/robots.txt")
# def robots():
#     extra = "User-agent: *\nAllow: /\n"
#     return extra + robots_txt(db, url_prefix="/newsletter", base_url="https://example.com")
```

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

```bash
export FLASK_APP=your_app
flask quillet create "My Blog" --slug=blog --from-email=hi@example.com
```

### Blueprint options

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

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

```bash
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. HTML, JSON (`Accept: application/json`), or markdown. `?cursor=` + `?limit=` (default 20, max 100). |
| `GET` | `/<slug>/posts/<post_slug>` | Single post. HTML, JSON, or markdown (`Accept: text/markdown`). |
| `POST` | `/<slug>/subscribe` | Subscribe. Accepts form `email` or JSON `{"email": "..."}`. Sends double opt-in email. |
| `GET` | `/<slug>/confirm/<token>` | Confirm subscription via emailed link. Pending confirms expire after 3 days. |
| `GET` | `/<slug>/unsubscribe/<token>` | Confirm form (no write). JSON GET → 405. |
| `POST` | `/<slug>/unsubscribe/<token>` | Unsubscribe. |
| `GET` | `/<slug>/feed.xml` | RSS 2.0 of the latest 50 published posts. |
| `GET` | `/<slug>/sitemap.xml` | URL index of the archive and all published posts. |
| `GET` | `/<slug>/llms.txt` | LLM discovery index of published posts (markdown). |

### 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 |
|---|---|---|
| `GET` | `/<slug>/api/openapi.yaml` | OpenAPI 3 spec (public, no auth) |
| `GET` | `/<slug>/api/mcp.md` | MCP bootstrap for agents (public, no auth) |
| `GET` | `/<slug>/api/posts` | `?status=all\|draft\|published\|unsent\|sent` `&cursor=` `&limit=` → `{"posts": [...], "next_cursor": ...}` |
| `GET` | `/<slug>/api/posts/<post_slug>` | → `{"post": {...}}` (drafts included) |
| `POST` | `/<slug>/api/posts` | `{"title", "slug", "body_md"}` → `{"post": {...}}` |
| `PUT` | `/<slug>/api/posts/<post_slug>` | `{"title", "body_md", "slug?"}` → `{"post": {...}}` |
| `POST` | `/<slug>/api/posts/<post_slug>/publish` | → `{"post": {...}}` |
| `POST` | `/<slug>/api/posts/<post_slug>/send-test` | `{"email"}` → `{"ok": true}` (does not set `sent_at`) |
| `POST` | `/<slug>/api/posts/<post_slug>/send` | → `{"ok": true, "recipients": N}` |
| `DELETE` | `/<slug>/api/posts/<post_slug>` | → `{"ok": true}` |
| `GET` | `/<slug>/api/subscribers` | `?cursor=` `&limit=` → `{"subscribers": [...], "next_cursor": ...}` |
| `DELETE` | `/<slug>/api/subscribers/<id>` | → `{"ok": true}` |

### MCP (optional extra)

MCP is Streamable HTTP on the same Quillet process as REST (`/{slug}/mcp/`).

```bash
pip install "quillet[mcp]"   # mcp + uvicorn + a2wsgi

# Newsletter must already exist (see CLI below), then:
QUILLET_ADMIN_PASSWORD=secret \
uvicorn --factory quillet.factory:create_asgi_app --host 127.0.0.1 --port 8000
```

MCP mounts are created at startup — seed newsletters **before** starting uvicorn. `flask run` / gunicorn serve REST only.

1. Give an agent the base URL and admin password (e.g. `http://127.0.0.1:8000/blog`).
2. It reads `/blog/api/mcp.md` and writes `.cursor/mcp.json` with a **url** to `/blog/mcp/` — you approve.
3. Cursor connects over HTTP; tools call Quillet in-process.

---

## CLI

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

```python
from quillet.db import NewsletterRepository
from quillet.models import Newsletter, NewsletterConfig, Page, Post, Subscriber

class MyRepository:
    def get_newsletter(self, slug: str) -> Newsletter | None: ...
    def list_newsletter_slugs(self) -> list[str]: ...
    def list_posts(
        self,
        newsletter_slug: str,
        published_only: bool = True,
        *,
        limit: int = 20,
        before: str | None = None,
        status: str | None = None,
    ) -> Page: ...
    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 get_subscriber_by_token(self, token: str) -> Subscriber | None: ...
    def list_confirmed_subscribers(self, newsletter_slug: str) -> list[Subscriber]: ...
    def list_all_subscribers(
        self, newsletter_slug: str, *, limit: int = 20, before: str | None = None
    ) -> Page: ...
    def purge_expired_pending(self, newsletter_id: int) -> int: ...
    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

```python
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
    ├── unsubscribe_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:

```yaml
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:

```html
{{ [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:

```python
from quillet.email.noop import NoopSender

create_blueprint(db=..., email=NoopSender(), admin_password="...")
```

Or via env var: `QUILLET_EMAIL_BACKEND=noop`

---

## Testing

```bash
# Fast: Flask test client, no server required
python scripts/smoke_test.py

# MCP Streamable HTTP (ASGI test client)
python scripts/mcp_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
```
