feat: bell-tower favicon, lfx-clean flows, branded HTML mail

This commit is contained in:
Jonas Miederer 2026-09-13 17:57:42 +02:00
parent 672faf3af8
commit df5c1e296c
15 changed files with 288 additions and 30 deletions

View file

@ -135,6 +135,22 @@ request); anything else → 403 and the frontend forwards to
`/access?email=…` (prefilled). Rate-limited (5/hour), token by mail only, `/access?email=…` (prefilled). Rate-limited (5/hour), token by mail only,
delivery failures never 500 (see §0.4 hardening). delivery failures never 500 (see §0.4 hardening).
## 0.6 Polish round (bell-tower favicon, lfx-clean flows, HTML mail)
* Favicon simplified to a stylized bell tower (spire + shaft + bell-arch
knockout); PNGs regenerated via `npm run icons`.
* `lfx validate --level 4` now green for ALL canonical flows + regression
test (`test_lfx_validate.py`). Root cause: required form fields have no
edge (values arrive via the form_values envelope). Fix: generator writes
a single-space sentinel value for required template fields; the form
parser treats whitespace-only values as "no default". L3
Message-vs-str warnings are known lfx-validator noise (MessageTextInput
accepts Message at runtime).
* New-input authoring docs: `backend/components/kirche/README.md`.
* Magic-link + access-notification mails are branded multipart
(text + HTML): kirchenblau header, serif headline, real button link,
German copy, email-safe tables/inline styles, no external assets.
## 1. Locked decisions (do not re-ask) ## 1. Locked decisions (do not re-ask)
* Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`, * Auth: SMTP magic-link, 15-min single-use tokens (`secrets.token_urlsafe`,

View file

@ -33,7 +33,8 @@ needs judgment. Every step is documented and traceable.
`LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a `LANGFLOW_COMPONENTS_PATH`). `GET /api/flows/{id}/input-schema` derives a
JSON Schema + RJSF uiSchema from the deployed version; the webapp renders JSON Schema + RJSF uiSchema from the deployed version; the webapp renders
the matching form automatically. Flows without such a node fall back to a the matching form automatically. Flows without such a node fall back to a
single textarea. single textarea. See
`backend/components/kirche/README.md` for authoring new form fields.
- **Web UI** — landing page (*Start*), access request (*Zugang*), and the - **Web UI** — landing page (*Start*), access request (*Zugang*), and the
logged-in webapp: *Workflows* (choose + run with dynamic form), logged-in webapp: *Workflows* (choose + run with dynamic form),
*Aufgaben* (approvals), *Verwaltung* (admin: access requests, import, *Aufgaben* (approvals), *Verwaltung* (admin: access requests, import,

View file

@ -1,4 +1,10 @@
"""Magic-link delivery. SMTP when configured, dev log fallback otherwise.""" """Magic-link delivery. SMTP when configured, dev log fallback otherwise.
Mail design follows the web branding (see frontend/src/theme.ts):
kirchenblau header, serif headline, white card on paper background.
Email-safe by construction: table layout, inline styles only, system
fonts, no external images (many clients block them / track opens).
"""
from __future__ import annotations from __future__ import annotations
@ -10,11 +16,72 @@ from app.settings import settings
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Brand palette (mirrors frontend/src/theme.ts kirchenblau).
BLUE = "#1d4a79"
BLUE_LIGHT = "#eef4fb"
INK = "#162f4c"
MUTED = "#5b6b7c"
BLUE = "#1d4a79"
BLUE_LIGHT = "#eef4fb"
INK = "#162f4c"
MUTED = "#5b6b7c"
def magic_link(token: str) -> str: def magic_link(token: str) -> str:
return f"{settings.frontend_url.rstrip('/')}/login?token={token}" return f"{settings.frontend_url.rstrip('/')}/login?token={token}"
def _page(title: str, intro: str, button_label: str, button_url: str | None, note: str) -> str:
"""Branded HTML mail body (table layout, inline styles, no images)."""
button = ""
if button_url:
button = (
'<table role="presentation" cellpadding="0" cellspacing="0" '
'style="margin:24px auto 8px auto;"><tr>'
f'<td bgcolor="{BLUE}" style="border-radius:8px;">'
f'<a href="{button_url}" '
'style="display:inline-block;padding:12px 28px;font-family:-apple-system,'
'Segoe UI,Roboto,Helvetica,Arial,sans-serif;font-size:15px;'
'font-weight:600;color:#ffffff;text-decoration:none;'
f'border-radius:8px;">{button_label}</a></td></tr></table>'
)
return f"""<!DOCTYPE html>
<html lang="de">
<body style="margin:0;padding:0;background-color:{BLUE_LIGHT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0"
style="background-color:{BLUE_LIGHT};padding:24px 12px;">
<tr><td align="center">
<table role="presentation" width="520" cellpadding="0" cellspacing="0"
style="max-width:520px;width:100%;font-family:Georgia,'Times New Roman',serif;
border-radius:12px;overflow:hidden;border:1px solid #d9e6f5;">
<tr>
<td bgcolor="{BLUE}" style="padding:20px 28px;">
<span style="color:#ffffff;font-size:18px;font-weight:700;
letter-spacing:0.02em;">Kirche Einfach</span>
</td>
</tr>
<tr>
<td bgcolor="#ffffff" style="padding:28px 28px 24px 28px;">
<h1 style="margin:0 0 12px 0;font-size:22px;line-height:1.3;
color:{INK};">{title}</h1>
<p style="margin:0;font-family:-apple-system,Segoe UI,Roboto,
Helvetica,Arial,sans-serif;font-size:15px;line-height:1.6;
color:{MUTED};">{intro}</p>
{button}
</td>
</tr>
</table>
<p style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;
font-size:12px;color:{MUTED};margin:16px 0 0 0;max-width:520px;">
{note}
</p>
</td></tr>
</table>
</body>
</html>"""
def _send(message: EmailMessage) -> None: def _send(message: EmailMessage) -> None:
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp: with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
smtp.starttls() smtp.starttls()
@ -23,6 +90,33 @@ def _send(message: EmailMessage) -> None:
smtp.send_message(message) smtp.send_message(message)
def _branded_mail(
subject: str,
to_email: str,
headline: str,
intro: str,
button_label: str,
button_url: str,
note: str,
) -> EmailMessage:
"""Text + branded HTML alternative; the link is a real button."""
message = EmailMessage()
message["Subject"] = subject
message["To"] = to_email
message["From"] = settings.mail_from or settings.contact_to or to_email
if settings.contact_to:
message["Reply-To"] = settings.contact_to
message.set_content(
f"{headline}\n\n{intro}\n\n{button_label}:\n{button_url}\n\n{note}\n"
)
# Email-safe HTML: tables + inline CSS; some clients strip <style>, so
# the palette is inlined via the f-string constants above.
message.add_alternative(
_page(headline, intro, button_label, button_url, note), subtype="html"
)
return message
def send_magic_link(to_email: str, link: str) -> bool: def send_magic_link(to_email: str, link: str) -> bool:
"""Mail the login link. Returns False (logs instead) when SMTP is unset. """Mail the login link. Returns False (logs instead) when SMTP is unset.
@ -36,14 +130,22 @@ def send_magic_link(to_email: str, link: str) -> bool:
if not settings.smtp_host: if not settings.smtp_host:
logger.info("SMTP not configured; magic link for %s: %s", to_email, link) logger.info("SMTP not configured; magic link for %s: %s", to_email, link)
return False return False
message = EmailMessage() message = _branded_mail(
message["Subject"] = "Your kirche-einfach login link" subject="Dein Login-Link für Kirche Einfach",
message["To"] = to_email to_email=to_email,
message["From"] = settings.mail_from or settings.contact_to or to_email headline="Anmeldung bei Kirche Einfach",
if settings.contact_to: intro=(
message["Reply-To"] = settings.contact_to "Klicke auf den Button, um dich anzumelden. Der Link ist "
message.set_content( "15 Minuten gültig und funktioniert nur einmal."
f"Hello,\n\nUse this link to log in (valid 15 minutes, single use):\n{link}\n" ),
button_label="Jetzt anmelden",
button_url=link,
note=(
"Falls du den Button nicht nutzen kannst, kopiere den Link "
"aus der Textversion dieser E-Mail in deinen Browser. "
"Diese E-Mail wurde angefordert — wenn du das warst, ist "
"alles gut; sonst kannst du sie ignorieren."
),
) )
try: try:
_send(message) _send(message)
@ -59,12 +161,17 @@ def send_access_notification(request_id: str, name: str, email: str) -> bool:
if not settings.smtp_host or not settings.contact_to: if not settings.smtp_host or not settings.contact_to:
logger.info("new access request %s from %s <%s>", request_id, name, email) logger.info("new access request %s from %s <%s>", request_id, name, email)
return False return False
message = EmailMessage() message = _branded_mail(
message["Subject"] = f"New access request: {name}" subject=f"Neue Zugangsanfrage: {name}",
message["To"] = settings.contact_to to_email=settings.contact_to,
message["From"] = settings.mail_from or settings.contact_to headline="Neue Zugangsanfrage",
message.set_content( intro=(
f"Name: {name}\nEmail: {email}\nRequest: {request_id}\n" f"{name} ({email}) hat Zugang angefordert und wartet auf "
"Freigabe in der Verwaltung."
),
button_label="In der Verwaltung prüfen",
button_url=f"{settings.frontend_url.rstrip('/')}/app/admin",
note="Diese Benachrichtigung geht an das Kontakt-Postfach.",
) )
try: try:
_send(message) _send(message)

View file

@ -96,6 +96,11 @@ def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
value = field.get("value") value = field.get("value")
if value is not None: if value is not None:
# Whitespace-only string values are the flow generator's sentinel
# for "required, filled at runtime" (see make_flow_fixtures.py) —
# not a real default for the web form.
if isinstance(value, str) and not value.strip():
return prop, ui
# A default outside its enum would fail validation on touch. # A default outside its enum would fail validation on touch.
enum = prop.get("enum") enum = prop.get("enum")
if not (enum is not None and value not in enum): if not (enum is not None and value not in enum):

View file

@ -0,0 +1,58 @@
# Authoring web forms (CustomInputForm)
Flows that should render a web form start with a `CustomInputForm` node
(this package — the `kirche` category in the Langflow palette). The API
derives a JSON Schema + uiSchema from the node's template
(`app/flows/input_schema.py`) and the webapp renders it with RJSF.
## Adding a new form field
1. Open the Langflow authoring UI (http://localhost:7860) and load the
flow from `LANGFLOW_LOAD_FLOWS_PATH` (canonical files are preloaded).
2. Edit the `CustomInputForm` node: every entry in its `inputs` list is
one web-form field. Add an `lfx.io` input, e.g.
```python
from lfx.io import DropdownInput
DropdownInput(
name="room", # JSON-Schema property key
display_name="Room", # form label (title)
info="Where should it take place", # help text (description)
options=["Chapel", "Hall"], # dropdown choices (enum)
value="Chapel", # prefill (default)
required=True, # marks the field required
)
```
Supported input types → widgets: `StrInput`/`MessageTextInput` → text,
`MultilineInput` → textarea, `SecretStrInput` → password,
`IntInput` → integer, `FloatInput` → number, `BoolInput` → checkbox,
`DropdownInput` → select, `MultiselectInput` → multi-select,
`SliderInput` → slider (needs `range_spec`), `FileInput` → text stub
(real upload later).
3. Add the new field to `collect()` so it reaches the JSON output.
4. **Wire the envelope**: keep the `ChatInput → form_values` edge (hidden
input, `show=False`). Submitted web-form values arrive there as
`{"payload": {...}}` and are merged over the defaults. Never make form
fields required *with* an incoming edge — that's the envelope's job.
5. Export the flow (JSON) into `backend/flows/<name>.json` and commit.
The API watcher syncs + deploys it automatically; the form schema is
regenerated per version.
## Conventions the pipeline relies on
- `required=True` on a template field: `lfx validate --level 4` demands a
value or an edge, so the canonical-flow generator
(`backend/scripts/make_flow_fixtures.py`) writes a single-space
sentinel value for such fields. The form parser treats whitespace-only
values as "no default" — don't ship real whitespace defaults.
- Field key = `name` (stable); label = `display_name`; help = `info`.
- Unknown future input types degrade to a plain text field in the web
form (parser fallback), so adding exotic lfx inputs never breaks runs.
## Regenerating the bundled fixtures
After editing the component source (`form_inputs.py`), refresh the
canonical + test-fixture exports:
docker compose exec api /opt/venv/bin/python scripts/make_flow_fixtures.py
docker compose exec -T api uv run pytest tests/test_lfx_validate.py

View file

@ -422,7 +422,7 @@
"placeholder": "", "placeholder": "",
"show": true, "show": true,
"name": "full_name", "name": "full_name",
"value": "", "value": " ",
"display_name": "Full name", "display_name": "Full name",
"advanced": false, "advanced": false,
"api_editable": false, "api_editable": false,

View file

@ -143,6 +143,19 @@ def kirche_node(
node_dict = _normalize(node_dict, node_id, comp_name, *position) node_dict = _normalize(node_dict, node_id, comp_name, *position)
for field, value in (values or {}).items(): for field, value in (values or {}).items():
node_dict["data"]["node"]["template"][field]["value"] = value node_dict["data"]["node"]["template"][field]["value"] = value
# lfx validate --level 4 errors on required inputs with no value and no
# incoming edge. Our form fields are filled at runtime via the
# form_values envelope (collect() merges), never via edges — so required
# template fields carry a single-space sentinel value. The form parser
# (app/flows/input_schema.py) treats whitespace-only values as "no
# default", keeping the web form clean.
for field_def in node_dict["data"]["node"]["template"].values():
if (
isinstance(field_def, dict)
and field_def.get("required")
and field_def.get("value") in (None, "", [], {})
):
field_def["value"] = " "
return node_dict return node_dict

View file

@ -422,7 +422,7 @@
"placeholder": "", "placeholder": "",
"show": true, "show": true,
"name": "full_name", "name": "full_name",
"value": "", "value": " ",
"display_name": "Full name", "display_name": "Full name",
"advanced": false, "advanced": false,
"api_editable": false, "api_editable": false,

View file

@ -316,3 +316,34 @@ async def test_magic_request_rate_limit(client, session):
"/api/auth/magic/request", json={"email": MEMBER} "/api/auth/magic/request", json={"email": MEMBER}
) )
assert limited.status_code == 429 assert limited.status_code == 429
def test_magic_link_mail_has_clickable_branded_html(monkeypatch):
from app.auth import mail
monkeypatch.setattr(settings, "smtp_host", "dummy")
message = mail._branded_mail(
subject="Dein Login-Link",
to_email="a@example.com",
headline="Anmeldung bei Kirche Einfach",
intro="Klicke auf den Button.",
button_label="Jetzt anmelden",
button_url="http://localhost:3000/login?token=GEHEIM",
note="Gültig 15 Minuten.",
)
html_part = next(
p for p in message.walk() if p.get_content_type() == "text/html"
)
html = html_part.get_content()
# Button = anchor with href around the label; token never in text-only form.
assert '<a href="http://localhost:3000/login?token=GEHEIM"' in html
assert ">Jetzt anmelden</a>" in html
assert mail.BLUE in html # kirchenblau brand bar
assert 'lang="de"' in html
# Multipart: plain-text fallback keeps the raw link.
parts = [p.get_content_type() for p in message.walk()]
assert "text/plain" in parts and "text/html" in parts
plain = next(
p.get_content() for p in message.walk() if p.get_content_type() == "text/plain"
)
assert "http://localhost:3000/login?token=GEHEIM" in plain

View file

@ -0,0 +1,34 @@
"""Canonical flows must pass lfx's strict validator (level 4).
Guards the source-of-truth directory against authoring mistakes: the
input_schema parser and the LFX runtime both assume well-formed exports.
Runs `lfx validate --level 4` (the dev dep, same minor as Langflow).
"""
import subprocess
import sys
from pathlib import Path
FLOW_DIR = Path(__file__).resolve().parent.parent / "flows"
def test_canonical_flows_pass_lfx_validate():
files = sorted(FLOW_DIR.glob("*.json"))
assert files, "no canonical flows found"
process = subprocess.run(
[
sys.executable,
"-m",
"lfx",
"validate",
"--level",
"4",
*[str(f) for f in files],
],
capture_output=True,
text=True,
timeout=300,
)
assert process.returncode == 0, (
f"lfx validate failed:\n{process.stdout}\n{process.stderr}"
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 661 B

After

Width:  |  Height:  |  Size: 679 B

Before After
Before After

View file

@ -7,16 +7,9 @@
} }
</style> </style>
<rect class="bg" x="4" y="4" width="56" height="56" rx="14"/> <rect class="bg" x="4" y="4" width="56" height="56" rx="14"/>
<!-- tower --> <!-- stylized bell tower: spire + shaft + bell arch -->
<rect class="fg" x="26" y="22" width="12" height="28"/> <path class="fg" d="M32 9 L41 25 H23 Z"/>
<!-- spire --> <rect class="fg" x="25" y="25" width="14" height="27"/>
<path class="fg" d="M32 8 L40 22 H24 Z"/> <!-- bell arch knockout -->
<!-- cross --> <path class="bg" d="M28.5 40 v-4 a3.5 3.5 0 0 1 7 0 v4 Z"/>
<rect class="fg" x="31.2" y="2.5" width="1.6" height="6"/>
<rect class="fg" x="29.4" y="4" width="5.2" height="1.6"/>
<!-- nave -->
<rect class="fg" x="14" y="36" width="12" height="14"/>
<path class="fg" d="M12 36 L20 28 L28 36 Z"/>
<!-- door knockout -->
<path class="bg" d="M29 50 v-5 a3 3 0 0 1 6 0 v5 Z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 762 B

After

Width:  |  Height:  |  Size: 536 B

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Before After
Before After