Famulus News: upkeep and mail

How the Rundbrief is updated, extended and mailed to a growing list of subscribers

Published

October 19, 2026

Famulus News is the Rundbrief of the Famulus platform. Each issue is one landing page in six languages (DE, EN, FR, IT, ES, PL), with news items that can be paged through. Readers can subscribe with their email address. When an issue is published, a mail with the link to the issue goes out from gerd.grasshoff@gmail.com to every confirmed subscriber. The mail carries only links: the issue in the reader’s language, one deep link per item, and an unsubscribe link.

Read it /famulus-news/ (public at https://lettre.ngrok.dev/famulus-news/) · readers’ guide · public site: https://famulus.lettre.ai/news/ (GitHub Pages repo grasshoff/news; the domain famulus.lettre.ai is carried by the user-site repo grasshoff/grasshoff.github.io, see platform/config/landings.yaml site:)
Status Rundbrief status in the hub: issue, subscriber counts, mailer, last mailing
Workspace publication/rundbriefe/famulus-news (all commands below run there, uv run famulus-news …)
Subscribers subscribers/*.yaml, one file per person
Send logs mailings/<issue>.yaml, who got which issue and when
Served by the hub (platform/hub/src/ai_platform_hub/famulus_news.py), route /famulus-news in routes.yaml
NoteThe rule behind every number

No figure is typed into an issue text. A text writes {key}, and the figure comes from facts.json, which collect reads from the workspaces. An unknown key stops the build. An assert: ties a sentence to a fact: when the fact changes, the build stops until the sentence is revised.

An issue in five steps

cd publication/rundbriefe/famulus-news
uv run famulus-news collect     # read the figures for the period -> issues/<slug>/facts.json + manifest.json
#   edit issues/<slug>/issue.yaml and the six text.<lang>.yaml where the facts changed
uv run famulus-news build       # -> site/ (the hub serves it at once)
uv run famulus-news verify      # six languages complete, no open macro, every link on a registered route
uv run famulus-news publish     # shows recipients + one sample mail, asks, then marks published and mails

collect, build and verify can be repeated at will. The period of an issue runs from the last publication (or period_floor for the first issue) to the day of collect.

publish does three things in order:

  1. It builds and verifies.
  2. It shows the list of recipients and one complete sample mail, then asks publish 2026-10 and send it to N subscriber(s)? [y/N]. A no changes nothing.
  3. On a yes, it sets last_published_iso in state.yaml (the next period starts there), stamps the issue as published, rebuilds, and sends.

Options:

  • publish --no-mail publishes without mailing.
  • mail --send sends later. It reaches only subscribers who have not received the issue yet.
TipLook before you send
  • uv run famulus-news mail is a dry run: recipients and a sample mail, nothing sent.
  • uv run famulus-news mail --to-self --send sends one real mail to gerd.grasshoff@gmail.com only, outside the send log, so you can check it in the inbox.

A new issue

  1. Set current_issue in state.yaml (for example "2026-11").
  2. Copy issues/2026-10/ to issues/2026-11/ and set slug and number in issue.yaml.
  3. Delete the items that are no longer news. Then add the new ones: one entry in issue.yaml (id, pillar, date, status, optional metric, assert, links) and one entry with the same id in each of the six text files (headline, metric_label if there is a metric, one to three body paragraphs).
  4. Run collect, build, verify, look at the page, then publish.

Older issues stay reachable at /famulus-news/issues/<slug>/.

The subscriber list

The list is a folder: subscribers/ in the workspace, with one YAML file per person. The file name is the address with @ written as _at_.

# subscribers/jane.doe_at_example.org.yaml
email: jane.doe@example.org
name: Jane Doe                  # optional
lang: en                        # de | en | fr | it | es | pl: language of the mail and its links
status: active                  # pending | active | unsubscribed
source: manual                  # manual (added here) | web (the subscribe box)
lists: [famulus-news]
added: '2026-10-19'
confirmed: '2026-10-19'
token: ...                      # written by the tool; it makes the unsubscribe link
Task How
Add someone uv run famulus-news subscribers add jane.doe@example.org --lang en --name "Jane Doe". Or create the file by hand with email, lang and status: active; the token is added with the first mail
See everyone uv run famulus-news subscribers list
Remove someone uv run famulus-news subscribers remove jane.doe@example.org (deletes the file), or set status: unsubscribed
Clean up uv run famulus-news subscribers prune removes web sign-ups not confirmed within 14 days
ImportantWho may be added by hand

An address added here is active at once. Add only people who have asked for the mail.

One file per person means two synced machines never write the same file. Neither subscribers/ nor mailings/ is ever copied into site/, site-pages/ or the GitHub Pages repository. verify stops when a subscriber address appears in a built tree.

The subscribe box (double opt-in)

Every page of the Rundbrief, the issue and the guide, ends with a box: an email field and a Subscribe button, with a short privacy note in the reader’s language.

flowchart LR
  A[address entered] --> B[file with status pending]
  B --> C[confirmation mail]
  C -->|link clicked| D[status active]
  D --> E[issue mails, each with an unsubscribe link]
  E -->|unsubscribe| F[status unsubscribed]
  C -->|not clicked within 14 days| G[removed by prune]

The endpoints are served by the hub under /famulus-news/api/:

  • POST subscribe: email and lang.
  • GET confirm?t=…
  • GET or POST unsubscribe?t=…: the POST serves the one-click unsubscribe of mail programs.

The answer to a sign-up is the same whether the address is new, pending or already active, so the page reveals nothing about who is subscribed. Protections against misuse:

  • a hidden honeypot field;
  • five sign-ups per hour per network address;
  • at most one confirmation mail per address within 24 hours;
  • daily_cap_web_signups in state.yaml (default 50 new pending sign-ups a day).

The GitHub Pages copy of the Rundbrief (https://famulus.lettre.ai/news/) posts to the platform’s public address. Its origin must be allowed: https://<pages.domain> is allowed automatically, other origins are listed under mail.allowed_origins in state.yaml.

The mailer and the Gmail App Password

Mail goes over SMTP to smtp.gmail.com:587 with an App Password. The password lives in the macOS Keychain (service famulus-news-gmail, account gerd.grasshoff@gmail.com), never in a file of the workspace. One-time setup:

  1. Open https://myaccount.google.com/apppasswords. Two-step verification must be on for the account. Create an app password named, for example, “Famulus News”.
  2. Run uv run famulus-news mail-setup and paste the 16 letters. The command stores them and tests the login.

Until this is done, the subscribe box answers with an error, and mail --send refuses with the same instruction. Neither stores an address silently.

The settings are in the mail: block of state.yaml:

  • sender, sender_name;
  • smtp_host, smtp_port;
  • keychain_service;
  • pause_seconds (between two mails; Gmail allows about 500 a day);
  • daily_cap_web_signups;
  • allowed_origins.

The mail texts (in six languages) are in i18n/ui.json under mail and subscribe.

Extending it

Extension Where
A new figure for the texts a reader in src/famulus_news/collect.py that returns (value, source, how); add it to the reader list in collect()
A new news item issue.yaml + the six text.<lang>.yaml (see A new issue)
A new pillar or status PILLARS in build.py and the keys pillar / status of all six languages in i18n/ui.json
A further mailing list a name in lists: of the subscriber files; subscribers.active("<list>") selects them
A new language LANGS in paths.py, languages in state.yaml, a block in i18n/ui.json, a text.<lang>.yaml per issue
A document in the readers’ guide guide/guide.yaml (title, topic, audiences, summary in six languages)
The permanent public copy uv run famulus-news export-pages --push rebuilds, verifies, commits and pushes to grasshoff/news (checkout scholarly/projects/news); Pages serves it at https://famulus.lettre.ai/news/ within a minute

Tests: uv run pytest -q in the workspace.