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

@ -1,8 +1,10 @@
from __future__ import annotations
import asyncio
import re
import smtplib
from asyncio.tasks import create_task
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import formataddr, formatdate, make_msgid
@ -26,6 +28,13 @@ from .crud import (
)
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
from .nostr_hooks import publish_or_delete_nostr_event
from .qr import (
format_event_when,
image_png_bytes,
load_qr_logo,
render_ticket_card,
ticket_card_filename,
)
DEFAULT_NOSTR_RELAYS = [
"wss://relay.damus.io",
@ -118,17 +127,15 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str
def _ticket_details(ticket: Ticket, event: Event) -> str:
"""Human-readable ticket facts for the email body. Also what keeps the
mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY)."""
when = event.event_start_date
if event.event_end_date and event.event_end_date != event.event_start_date:
when = f"{when} to {event.event_end_date}"
lines = [f"Event: {event.name}", f"When: {when}"]
lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"]
if event.location:
lines.append(f"Where: {event.location}")
if ticket.name:
lines.append(f"Name on ticket: {ticket.name}")
lines.append(f"Ticket ID: {ticket.id}")
lines.append(
"Show the QR code below (or open the link above on your phone) at the "
"Your ticket (with its QR code) is attached to this email — save it "
"or open the link above on your phone, and show the QR code at the "
"door to be scanned in."
)
return "\n".join(lines)
@ -142,14 +149,18 @@ def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) ->
def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str:
"""HTML twin of the text part. Deliberately no <img>: the card travels
as an attachment (renders inline in most clients, works offline, and
keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become
links."""
text_message = _ticket_delivery_message(ticket, event, base_message)
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
image_url = escape(_ticket_image_url(ticket), quote=True)
return (
f"{html_message}"
f'<p><img src="{image_url}" alt="Ticket QR code" '
'style="max-width: 300px; height: auto;" /></p>'
html = escape(text_message)
html = re.sub(
r"(https?://[^\s<]+)",
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
html,
)
return f"<p>{html.replace(chr(10), '<br />')}</p>"
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
@ -195,8 +206,20 @@ async def _deliver_ticket_notifications(
if result.email.attempted:
try:
assert ticket.email
card = render_ticket_card(
ticket,
event,
logo=await load_qr_logo(),
site_title=settings.lnbits_site_title,
)
await _send_ticket_email_notification(
[ticket.email], text_message, subject, html_message
[ticket.email],
text_message,
subject,
html_message,
attachments=[
(ticket_card_filename(ticket, event), image_png_bytes(card))
],
)
ticket.extra.email_notification_sent = True
result.email.sent = True
@ -227,6 +250,7 @@ async def _send_ticket_email_notification(
message: str,
subject: str,
html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None,
) -> None:
"""Multipart (text + HTML) ticket email through the instance SMTP
settings. Core's `send_email_notification` is plain-text only, which is
@ -244,7 +268,9 @@ async def _send_ticket_email_notification(
if not is_valid_email_address(address):
raise ValueError(f"Invalid email address: {address}")
msg = build_ticket_email(from_email, to_emails, subject, message, html_message)
msg = build_ticket_email(
from_email, to_emails, subject, message, html_message, attachments
)
username = settings.lnbits_email_notifications_username or from_email
await asyncio.to_thread(
_smtp_send,
@ -264,22 +290,34 @@ def build_ticket_email(
subject: str,
message: str,
html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None,
) -> MIMEMultipart:
"""Assemble the multipart ticket email with the headers receivers score
on: a Date and a Message-ID (their absence is what SpamAssassin's
MISSING_DATE / MISSING_MID flag, and what Gmail/Outlook read as
machine-generated), and a display name on From so the sender is not a
bare address."""
msg = MIMEMultipart("alternative")
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
(the ticket card), and the headers receivers score on — a Date and a
Message-ID (their absence is what SpamAssassin's MISSING_DATE /
MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and
a display name on From so the sender is not a bare address."""
body = MIMEMultipart("alternative")
body.attach(MIMEText(message, "plain"))
if html_message:
body.attach(MIMEText(html_message, "html"))
if attachments:
msg = MIMEMultipart("mixed")
msg.attach(body)
for filename, data in attachments:
part = MIMEImage(data, _subtype="png")
part.add_header("Content-Disposition", "attachment", filename=filename)
msg.attach(part)
else:
msg = body
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets"
msg["From"] = formataddr((sender_name, from_email))
msg["To"] = ", ".join(to_emails)
msg["Subject"] = subject
msg["Date"] = formatdate(localtime=True)
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
msg.attach(MIMEText(message, "plain"))
if html_message:
msg.attach(MIMEText(html_message, "html"))
return msg
@ -318,11 +356,13 @@ def _ticket_url(ticket: Ticket) -> str:
def _ticket_image_url(ticket: Ticket) -> str:
"""The QR PNG is served by THIS extension on the LNbits host, so it is
built from `lnbits_baseurl` even when `ticket_base_url` points at a
separate web app (deviation from upstream, which assumes both are the
"""The ticket card PNG is served by THIS extension on the LNbits host, so
it is built from `lnbits_baseurl` even when `ticket_base_url` points at
a separate web app (deviation from upstream, which assumes both are the
same host)."""
return f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/qr/{ticket.id}"
return (
f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/ticket-card/{ticket.id}"
)
async def refund_tickets(event_id: str):