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]
Famulus News: upkeep and mail
How the Rundbrief is updated, extended and mailed to a growing list of subscribers
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 |
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 mailscollect, 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:
- It builds and verifies.
- 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. - On a yes, it sets
last_published_isoinstate.yaml(the next period starts there), stamps the issue as published, rebuilds, and sends.
Options:
publish --no-mailpublishes without mailing.mail --sendsends later. It reaches only subscribers who have not received the issue yet.
uv run famulus-news mailis a dry run: recipients and a sample mail, nothing sent.uv run famulus-news mail --to-self --sendsends one real mail togerd.grasshoff@gmail.comonly, outside the send log, so you can check it in the inbox.
A new issue
- Set
current_issueinstate.yaml(for example"2026-11"). - Copy
issues/2026-10/toissues/2026-11/and setslugandnumberinissue.yaml. - Delete the items that are no longer news. Then add the new ones: one entry in
issue.yaml(id,pillar,date,status, optionalmetric,assert,links) and one entry with the sameidin each of the six text files (headline,metric_labelif there is a metric, one to threebodyparagraphs). - Run
collect,build,verify, look at the page, thenpublish.
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 |
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.
The endpoints are served by the hub under /famulus-news/api/:
POST subscribe: email and lang.GET confirm?t=…GETorPOST 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_signupsinstate.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:
- Open https://myaccount.google.com/apppasswords. Two-step verification must be on for the account. Create an app password named, for example, “Famulus News”.
- Run
uv run famulus-news mail-setupand 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.