feat: attach a self-describing ticket card to the email (1.6.1-aio.10)
Some checks failed
lint.yml / feat: attach a self-describing ticket card to the email (1.6.1-aio.10) (pull_request) Failing after 0s

The v1.6.1-aio.9 mail still scored 8.4/10 on mail-tester: the remaining
deduction was HTML_IMAGE_ONLY (1.8) — an HTML part whose only content of
note is a remote <img>. Remote images are also blocked by default in most
clients until the reader opts in, and a bare QR saved from that mail says
nothing about what it opens.

- New `qr.py` module (QR + logo helpers moved out of views_api) with
  `render_ticket_card`: site title, event name, when/where, the branded
  QR, name on ticket, ticket id and the door instruction, laid out with
  the bundled DejaVu Sans; `format_event_when` gives "Fri 19 Feb 2027,
  16:00 - 20:00"; filenames are `ticket-<event-slug>-<id8>.png`.
- `GET /events/api/v1/ticket-card/{ticket_id}` serves the same PNG
  (anonymous, like the QR endpoint); the email's "Ticket image" link
  now points there.
- The ticket email becomes multipart/mixed: text + HTML alternatives
  (URLs as links, no <img>) plus the card as a PNG attachment, which
  clients show inline at the end of the message and which works offline
  at the door.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
This commit is contained in:
Padreug 2026-09-08 16:40:25 +02:00
commit 2f8a602bbd
8 changed files with 444 additions and 140 deletions

View file

@ -53,6 +53,25 @@ def test_email_carries_date_message_id_and_display_name(monkeypatch):
assert parts == ["text/plain", "text/html"]
def test_email_attaches_the_ticket_card(monkeypatch):
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
png = b"\x89PNG\r\n\x1a\n" + b"0" * 32
msg = build_ticket_email(
"tickets@example.org",
["ada@example.com"],
"Subj",
"text",
"<p>html</p>",
attachments=[("ticket-test-8auNuuaB.png", png)],
)
assert msg.get_content_type() == "multipart/mixed"
body, attachment = msg.get_payload()
assert body.get_content_type() == "multipart/alternative"
assert attachment.get_content_type() == "image/png"
assert attachment.get_filename() == "ticket-test-8auNuuaB.png"
assert attachment.get_payload(decode=True) == png
def test_payload_includes_event_details_and_qr(monkeypatch):
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
subject, text, html = _ticket_notification_payload(_ticket(), _event())
@ -65,5 +84,6 @@ def test_payload_includes_event_details_and_qr(monkeypatch):
"at the door",
):
assert needle in text
assert "/events/api/v1/qr/tkt1" in text
assert "<img" in html and "Ticket ID: tkt1" in html
assert "/events/api/v1/ticket-card/tkt1" in text
assert "<img" not in html and "Ticket ID: tkt1" in html
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html

View file

@ -2,7 +2,7 @@ from io import BytesIO
from PIL import Image
from ..views_api import make_qr_png
from ..qr import make_qr_png
def test_make_qr_png_renders_requested_size():
@ -20,3 +20,45 @@ def test_make_qr_png_pastes_a_centred_logo():
out = BytesIO()
img.save(out, format="PNG")
assert out.getvalue().startswith(b"\x89PNG")
def test_render_ticket_card_is_self_describing():
from datetime import datetime, timezone
from ..models import Event, Ticket
from ..qr import format_event_when, render_ticket_card, ticket_card_filename
now = datetime.now(timezone.utc)
event = Event(
id="evt1",
wallet="w",
name="Château du Faune | Uru Ecstatic Dance",
info="",
closing_date="2027-02-19",
event_start_date="2027-02-19T16:00:00+01:00",
event_end_date="2027-02-19T20:00:00+01:00",
location="The Chateau",
amount_tickets=10,
price_per_ticket=15,
time=now,
)
ticket = Ticket(
id="8auNuuaBv7TFGj7BYoVnTN",
wallet="w",
event="evt1",
name="Guest Test",
email="g@example.com",
registered=False,
paid=True,
time=now,
reg_timestamp=now,
)
assert format_event_when(event) == "Fri 19 Feb 2027, 16:00 - 20:00"
assert (
ticket_card_filename(ticket, event)
== "ticket-ch-teau-du-faune-uru-ecstatic-dance-8auNuuaB.png"
)
card = render_ticket_card(ticket, event, site_title="Oyez!")
assert card.width == 800 and card.height > 800
# QR area is centred; its finder pattern is black
assert card.getpixel((400, card.height // 2)) in ((0, 0, 0), (255, 255, 255))