diff --git a/README.md b/README.md
index 43ab768..cd02134 100644
--- a/README.md
+++ b/README.md
@@ -62,10 +62,13 @@ Events includes a shareable ticket scanner, which can be used to register attend
- **Stripe session.** The buyer's email is passed as `customer_email`
(prefilled and locked on the hosted page); the line item is named after the
event; `event_id`, `quantity` and `ticket_ids` ride along as metadata.
-- **Email.** Multipart text + HTML with the ticket QR embedded from
- `GET /events/api/v1/qr/{ticket_id}` (PNG, branded with the instance QR logo).
- `POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a
- `TicketResendResult` with per-channel outcome.
+- **Email.** Multipart text + HTML (links, no images) with the **ticket card**
+ attached — a self-describing PNG (site, event, when, where, QR with the
+ instance logo, name on ticket, ticket id) also served at
+ `GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
+ `GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
+ From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email`
+ returns a `TicketResendResult` with per-channel outcome.
## Powered by LNbits
diff --git a/config.json b/config.json
index 46be592..68e908a 100644
--- a/config.json
+++ b/config.json
@@ -1,6 +1,6 @@
{
"id": "events",
- "version": "1.6.1-aio.9",
+ "version": "1.6.1-aio.10",
"name": "Events",
"repo": "https://git.atitlan.io/aiolabs/events",
"short_description": "Sell and register event tickets",
diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md
index 539fbef..93e6c2f 100644
--- a/docs/upstream-candidates.md
+++ b/docs/upstream-candidates.md
@@ -4,15 +4,16 @@ Running log of fork features that are shaped so they could be offered to
`lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here
in an upstream-compatible form; strike it when the PR merges upstream.
-| Feature | Where | Upstream target | Readiness |
-| --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
-| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
-| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
-| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
-| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
-| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
-| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
-| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
-| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
-| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
-| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
+| Feature | Where | Upstream target | Readiness |
+| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
+| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
+| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
+| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
+| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 |
+| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone |
+| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone |
+| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 |
+| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone |
+| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
+| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
+| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
diff --git a/qr.py b/qr.py
new file mode 100644
index 0000000..79a693f
--- /dev/null
+++ b/qr.py
@@ -0,0 +1,254 @@
+"""QR + ticket-card rendering shared by the API and the mailer.
+
+`make_qr_png` is upstream v1.6.8's helper (pyqrcode + Pillow) with the
+instance QR logo pasted in the centre; `render_ticket_card` wraps it in a
+self-describing card (event, when, where, name, ticket id) so the PNG a
+buyer saves from the email still says what it is for.
+"""
+
+from __future__ import annotations
+
+import re
+from datetime import datetime
+from io import BytesIO
+from pathlib import Path
+
+import httpx
+import pyqrcode # type: ignore[import-untyped]
+from lnbits.settings import settings
+from loguru import logger
+from PIL import Image, ImageDraw, ImageFont
+
+from .models import Event, Ticket
+
+_qr_logo_cache: dict[str, Image.Image | None] = {}
+
+
+async def load_qr_logo() -> Image.Image | None:
+ """LNbits' "QR Code/Favicon Logo" setting, as a Pillow image (cached).
+
+ Local `/static/...` values resolve inside the LNbits package; absolute
+ URLs are fetched once. Any failure just yields a plain QR.
+ """
+ source = (settings.lnbits_qr_logo or "").strip()
+ if not source:
+ return None
+ if source in _qr_logo_cache:
+ return _qr_logo_cache[source]
+ logo: Image.Image | None = None
+ try:
+ if source.startswith(("http://", "https://")):
+ async with httpx.AsyncClient(timeout=5) as client:
+ resp = await client.get(source)
+ resp.raise_for_status()
+ logo = Image.open(BytesIO(resp.content)).convert("RGBA")
+ else:
+ local = Path(settings.lnbits_path) / source.lstrip("/")
+ if local.is_file():
+ logo = Image.open(local).convert("RGBA")
+ except Exception as exc:
+ logger.warning(f"QR logo '{source}' unavailable: {exc}")
+ logo = None
+ _qr_logo_cache[source] = logo
+ return logo
+
+
+def make_qr_png(
+ data: str,
+ size: int = 235,
+ border: int = 4,
+ logo: Image.Image | None = None,
+) -> Image.Image:
+ """Render `data` as a QR image. With `logo`, the code is built at
+ error-correction level H and the logo is pasted in the centre on a white
+ pad at ≤ 20 % of the width — the same look LNbits' client-side
+ `lnbits-qrcode` component produces."""
+ qr = pyqrcode.create(data, error="H" if logo is not None else "M")
+ matrix = qr.code
+ modules = len(matrix)
+
+ total_modules = modules + border * 2
+ box_size = max(1, size // total_modules)
+ img_size = total_modules * box_size
+
+ img = Image.new("RGBA", (img_size, img_size), "white")
+ draw = ImageDraw.Draw(img)
+
+ for y, row in enumerate(matrix):
+ for x, cell in enumerate(row):
+ if cell:
+ x0 = (x + border) * box_size
+ y0 = (y + border) * box_size
+ draw.rectangle(
+ [x0, y0, x0 + box_size - 1, y0 + box_size - 1],
+ fill="black",
+ )
+
+ if img_size != size:
+ img = img.resize((size, size), Image.Resampling.NEAREST)
+
+ if logo is not None:
+ logo_size = max(8, int(size * 0.2))
+ pad = max(2, logo_size // 8)
+ scaled = logo.copy()
+ scaled.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
+ plate = Image.new(
+ "RGBA", (scaled.width + 2 * pad, scaled.height + 2 * pad), "white"
+ )
+ plate.paste(scaled, (pad, pad), scaled)
+ img.paste(
+ plate,
+ ((size - plate.width) // 2, (size - plate.height) // 2),
+ plate,
+ )
+
+ return img
+
+
+def _parse_iso(value: str | None) -> datetime | None:
+ if not value:
+ return None
+ try:
+ return datetime.fromisoformat(value)
+ except ValueError:
+ try:
+ return datetime.strptime(value[:10], "%Y-%m-%d")
+ except ValueError:
+ return None
+
+
+def format_event_when(event: Event) -> str:
+ """'Fri 19 Feb 2027, 16:00 - 20:00' (same day) or a full range; the raw
+ strings when they do not parse."""
+ start = _parse_iso(event.event_start_date)
+ end = _parse_iso(event.event_end_date)
+ if not start:
+ return event.event_start_date or ""
+ has_time = "T" in (event.event_start_date or "")
+ day = start.strftime("%a %d %b %Y")
+ if not end or end == start:
+ return f"{day}, {start.strftime('%H:%M')}" if has_time else day
+ if end.date() == start.date():
+ if has_time:
+ return f"{day}, {start.strftime('%H:%M')} - {end.strftime('%H:%M')}"
+ return day
+ end_day = end.strftime("%a %d %b %Y")
+ if has_time:
+ return f"{day} {start.strftime('%H:%M')} - {end_day} {end.strftime('%H:%M')}"
+ return f"{day} - {end_day}"
+
+
+def ticket_card_filename(ticket: Ticket, event: Event) -> str:
+ slug = re.sub(r"[^a-z0-9]+", "-", event.name.lower()).strip("-")[:40] or "event"
+ return f"ticket-{slug}-{ticket.id[:8]}.png"
+
+
+_FONT_DIR = Path(__file__).resolve().parent / "static" / "fonts"
+
+
+def _font(
+ size: int, bold: bool = False
+) -> ImageFont.ImageFont | ImageFont.FreeTypeFont:
+ """DejaVu Sans shipped with the extension (full Latin coverage — the
+ Pillow-bundled default lacks accented glyphs, so 'Château' would render
+ as tofu); Pillow's default is the fallback."""
+ path = _FONT_DIR / ("DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf")
+ try:
+ return ImageFont.truetype(str(path), size)
+ except OSError:
+ try:
+ return ImageFont.load_default(size=size)
+ except Exception: # very old Pillow: bitmap default only
+ return ImageFont.load_default()
+
+
+def _wrap(draw: ImageDraw.ImageDraw, text: str, font, max_width: int) -> list[str]:
+ lines: list[str] = []
+ for paragraph in text.split("\n"):
+ words = paragraph.split()
+ line = ""
+ for word in words:
+ candidate = f"{line} {word}".strip()
+ if draw.textlength(candidate, font=font) <= max_width or not line:
+ line = candidate
+ else:
+ lines.append(line)
+ line = word
+ lines.append(line)
+ return lines
+
+
+def render_ticket_card(
+ ticket: Ticket,
+ event: Event,
+ *,
+ logo: Image.Image | None = None,
+ site_title: str | None = None,
+ width: int = 800,
+) -> Image.Image:
+ """A self-describing ticket: header (site), event name, when/where, the
+ QR, then name on ticket + ticket id + door instruction."""
+ pad = 48
+ inner = width - 2 * pad
+ title_font = _font(40, bold=True)
+ body_font = _font(28)
+ small_font = _font(22)
+ mono_font = _font(24)
+
+ # Measure first: the card grows with the wrapped title.
+ probe = ImageDraw.Draw(Image.new("RGB", (width, 10), "white"))
+ title_lines = _wrap(probe, event.name, title_font, inner)
+ when = format_event_when(event)
+ meta_lines = [when] if when else []
+ if event.location:
+ meta_lines += _wrap(probe, event.location, body_font, inner)
+ qr_size = min(inner, 560)
+ detail_lines = []
+ if ticket.name:
+ detail_lines.append(f"Name: {ticket.name}")
+ detail_lines.append(f"Ticket {ticket.id}")
+
+ y = pad
+ y += 30 + 16 # site title line
+ y += len(title_lines) * 52 + 12
+ y += len(meta_lines) * 36 + 28
+ qr_y = y
+ y += qr_size + 28
+ y += len(detail_lines) * 36 + 12
+ y += 30 + pad # footer
+ height = y
+
+ img = Image.new("RGB", (width, height), "white")
+ draw = ImageDraw.Draw(img)
+ grey = (110, 110, 110)
+ black = (20, 20, 20)
+
+ y = pad
+ draw.text((pad, y), (site_title or "Ticket").upper(), fill=grey, font=small_font)
+ y += 30 + 16
+ for line in title_lines:
+ draw.text((pad, y), line, fill=black, font=title_font)
+ y += 52
+ y += 12
+ for line in meta_lines:
+ draw.text((pad, y), line, fill=black, font=body_font)
+ y += 36
+ y += 28
+
+ qr = make_qr_png(f"ticket://{ticket.id}", size=qr_size, logo=logo).convert("RGB")
+ img.paste(qr, ((width - qr_size) // 2, qr_y))
+ y = qr_y + qr_size + 28
+
+ for line in detail_lines:
+ font = mono_font if line.startswith("Ticket ") else body_font
+ draw.text((pad, y), line, fill=black, font=font)
+ y += 36
+ y += 12
+ draw.text((pad, y), "Show this QR code at the door.", fill=grey, font=small_font)
+ return img
+
+
+def image_png_bytes(img: Image.Image) -> bytes:
+ out = BytesIO()
+ img.save(out, format="PNG")
+ return out.getvalue()
diff --git a/services.py b/services.py
index 8476798..9e6d1ce 100644
--- a/services.py
+++ b/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
: 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"
{escape(text_message).replace(chr(10), '
')}
{html.replace(chr(10), '
')}
html
", + 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 "