feat: bell-tower favicon, lfx-clean flows, branded HTML mail
This commit is contained in:
parent
672faf3af8
commit
df5c1e296c
15 changed files with 288 additions and 30 deletions
|
|
@ -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
|
||||
|
||||
|
|
@ -10,11 +16,72 @@ from app.settings import settings
|
|||
|
||||
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:
|
||||
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:
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as smtp:
|
||||
smtp.starttls()
|
||||
|
|
@ -23,6 +90,33 @@ def _send(message: EmailMessage) -> None:
|
|||
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:
|
||||
"""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:
|
||||
logger.info("SMTP not configured; magic link for %s: %s", to_email, link)
|
||||
return False
|
||||
message = EmailMessage()
|
||||
message["Subject"] = "Your kirche-einfach login link"
|
||||
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"Hello,\n\nUse this link to log in (valid 15 minutes, single use):\n{link}\n"
|
||||
message = _branded_mail(
|
||||
subject="Dein Login-Link für Kirche Einfach",
|
||||
to_email=to_email,
|
||||
headline="Anmeldung bei Kirche Einfach",
|
||||
intro=(
|
||||
"Klicke auf den Button, um dich anzumelden. Der Link ist "
|
||||
"15 Minuten gültig und funktioniert nur einmal."
|
||||
),
|
||||
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:
|
||||
_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:
|
||||
logger.info("new access request %s from %s <%s>", request_id, name, email)
|
||||
return False
|
||||
message = EmailMessage()
|
||||
message["Subject"] = f"New access request: {name}"
|
||||
message["To"] = settings.contact_to
|
||||
message["From"] = settings.mail_from or settings.contact_to
|
||||
message.set_content(
|
||||
f"Name: {name}\nEmail: {email}\nRequest: {request_id}\n"
|
||||
message = _branded_mail(
|
||||
subject=f"Neue Zugangsanfrage: {name}",
|
||||
to_email=settings.contact_to,
|
||||
headline="Neue Zugangsanfrage",
|
||||
intro=(
|
||||
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:
|
||||
_send(message)
|
||||
|
|
|
|||
|
|
@ -96,6 +96,11 @@ def _field_schema(name: str, field: dict) -> tuple[dict, dict]:
|
|||
|
||||
value = field.get("value")
|
||||
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.
|
||||
enum = prop.get("enum")
|
||||
if not (enum is not None and value not in enum):
|
||||
|
|
|
|||
58
backend/components/kirche/README.md
Normal file
58
backend/components/kirche/README.md
Normal 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
|
||||
|
|
@ -422,7 +422,7 @@
|
|||
"placeholder": "",
|
||||
"show": true,
|
||||
"name": "full_name",
|
||||
"value": "",
|
||||
"value": " ",
|
||||
"display_name": "Full name",
|
||||
"advanced": false,
|
||||
"api_editable": false,
|
||||
|
|
|
|||
|
|
@ -143,6 +143,19 @@ def kirche_node(
|
|||
node_dict = _normalize(node_dict, node_id, comp_name, *position)
|
||||
for field, value in (values or {}).items():
|
||||
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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -422,7 +422,7 @@
|
|||
"placeholder": "",
|
||||
"show": true,
|
||||
"name": "full_name",
|
||||
"value": "",
|
||||
"value": " ",
|
||||
"display_name": "Full name",
|
||||
"advanced": false,
|
||||
"api_editable": false,
|
||||
|
|
|
|||
|
|
@ -316,3 +316,34 @@ async def test_magic_request_rate_limit(client, session):
|
|||
"/api/auth/magic/request", json={"email": MEMBER}
|
||||
)
|
||||
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
|
||||
|
|
|
|||
34
backend/tests/test_lfx_validate.py
Normal file
34
backend/tests/test_lfx_validate.py
Normal 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}"
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue