diff --git a/README.md b/README.md
index cd02134..43ab768 100644
--- a/README.md
+++ b/README.md
@@ -62,13 +62,10 @@ 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 (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.
+- **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.
## Powered by LNbits
diff --git a/config.json b/config.json
index 68e908a..46be592 100644
--- a/config.json
+++ b/config.json
@@ -1,6 +1,6 @@
{
"id": "events",
- "version": "1.6.1-aio.10",
+ "version": "1.6.1-aio.9",
"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 93e6c2f..539fbef 100644
--- a/docs/upstream-candidates.md
+++ b/docs/upstream-candidates.md
@@ -4,16 +4,15 @@ 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 |
-| 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) |
+| 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 |
diff --git a/qr.py b/qr.py
deleted file mode 100644
index 79a693f..0000000
--- a/qr.py
+++ /dev/null
@@ -1,254 +0,0 @@
-"""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 9e6d1ce..8476798 100644
--- a/services.py
+++ b/services.py
@@ -1,10 +1,8 @@
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
@@ -28,13 +26,6 @@ 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",
@@ -127,15 +118,17 @@ 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)."""
- lines = [f"Event: {event.name}", f"When: {format_event_when(event)}"]
+ 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}"]
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(
- "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 "
+ "Show the QR code below (or open the link above on your phone) at the "
"door to be scanned in."
)
return "\n".join(lines)
@@ -149,18 +142,14 @@ 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 = escape(text_message)
- html = re.sub(
- r"(https?://[^\s<]+)",
- lambda m: f'{m.group(1)}',
- html,
+ 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()) @@ -84,6 +65,5 @@ def test_payload_includes_event_details_and_qr(monkeypatch): "at the door", ): assert needle in text - assert "/events/api/v1/ticket-card/tkt1" in text - assert "