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
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:
parent
defe6d5b00
commit
2f8a602bbd
8 changed files with 444 additions and 140 deletions
92
services.py
92
services.py
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue