From 92642a1f24194c82201074a731144477719e5e3c Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 6 Sep 2026 19:53:05 +0200 Subject: [PATCH] feat: multipart ticket email with embedded QR, structured resend result MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port of upstream v1.6.8's delivery layer, wave-free: `_deliver_ticket_ notifications` sends text + HTML (the HTML embeds the ticket QR PNG from this extension on the LNbits host — built from lnbits_baseurl on purpose, since ticket_base_url may point at a separate web app) and returns a `TicketResendResult` with per-channel attempted/sent/error. The SMTP session runs via asyncio.to_thread so a slow relay cannot stall the event loop while a batch of tickets settles. Resend keeps bypassing the per-event email opt-in (organizer asked explicitly) and is email-only. Our nsec-DM Nostr path is kept (upstream went NIP-05-only). Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- services.py | 205 +++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 164 insertions(+), 41 deletions(-) diff --git a/services.py b/services.py index 0a2de28..83ac5c6 100644 --- a/services.py +++ b/services.py @@ -1,14 +1,16 @@ from __future__ import annotations import asyncio +import smtplib from asyncio.tasks import create_task +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from html import escape from lnbits.core.models.users import UserNotifications from lnbits.core.services.nostr import send_nostr_dm -from lnbits.core.services.notifications import ( - send_email_notification, - send_user_notification, -) +from lnbits.core.services.notifications import send_user_notification +from lnbits.helpers import is_valid_email_address from lnbits.settings import settings from lnbits.utils.nostr import normalize_private_key, normalize_public_key from lnurl import execute @@ -21,7 +23,7 @@ from .crud import ( update_event, update_ticket, ) -from .models import Event, Ticket +from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult from .nostr_hooks import publish_or_delete_nostr_event DEFAULT_NOSTR_RELAYS = [ @@ -80,40 +82,13 @@ async def _send_ticket_notification(ticket: Ticket) -> None: logger.warning(f"Event {ticket.event} not found for ticket notification.") return - subject, message = _ticket_notification_message(ticket, event) - updated = False - - if ( - event.extra.email_notifications - and settings.lnbits_email_notifications_enabled - and ticket.email - ): - try: - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to email ticket {ticket.id}: {exc}") - - if ( - event.extra.nostr_notifications - and settings.is_nostr_notifications_configured() - and ticket.extra.nostr_identifier - ): - try: - await _send_nostr_ticket_notification( - ticket.extra.nostr_identifier, message - ) - ticket.extra.nostr_notification_sent = True - updated = True - except Exception as exc: - logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") - - if updated: - await update_ticket(ticket) + await _deliver_ticket_notifications(ticket, event) -async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: +async def resend_ticket_email_notification(ticket: Ticket) -> TicketResendResult: + """Organizer-triggered re-delivery of the ticket email. Bypasses the + per-event `email_notifications` opt-in (the organizer asked explicitly) + but still needs the instance mailer and an address on the ticket.""" event = await get_event(ticket.event) if not event: raise ValueError("Event does not exist.") @@ -122,10 +97,7 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket: if not ticket.email: raise ValueError("Ticket does not have an email address.") - subject, message = _ticket_notification_message(ticket, event) - await send_email_notification([ticket.email], message, subject) - ticket.extra.email_notification_sent = True - return await update_ticket(ticket) + return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False) def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]: @@ -142,6 +114,149 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str return subject, f"{body}\n\nOpen it here: {ticket_url}" +def _ticket_delivery_message(ticket: Ticket, base_message: str) -> str: + return f"{base_message}\n\nTicket image: {_ticket_image_url(ticket)}" + + +def _ticket_email_html_message(ticket: Ticket, base_message: str) -> str: + text_message = _ticket_delivery_message(ticket, base_message) + html_message = f"

{escape(text_message).replace(chr(10), '
')}

" + image_url = escape(_ticket_image_url(ticket), quote=True) + return ( + f"{html_message}" + f'

Ticket QR code

' + ) + + +def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: + subject, base_message = _ticket_notification_message(ticket, event) + text_message = _ticket_delivery_message(ticket, base_message) + html_message = _ticket_email_html_message(ticket, base_message) + return subject, text_message, html_message + + +async def _deliver_ticket_notifications( + ticket: Ticket, + event: Event, + *, + email: bool | None = None, + nostr: bool | None = None, +) -> TicketResendResult: + """Send the ticket by every configured channel and report per-channel + outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's + opt-ins when not None; the instance-level prerequisites always apply.""" + subject, text_message, html_message = _ticket_notification_payload(ticket, event) + updated = False + + email_wanted = event.extra.email_notifications if email is None else email + nostr_wanted = event.extra.nostr_notifications if nostr is None else nostr + result = TicketResendResult( + ticket=ticket, + email=NotificationDeliveryResult( + attempted=bool( + email_wanted + and settings.lnbits_email_notifications_enabled + and ticket.email + ) + ), + nostr=NotificationDeliveryResult( + attempted=bool( + nostr_wanted + and settings.is_nostr_notifications_configured() + and ticket.extra.nostr_identifier + ) + ), + ) + + if result.email.attempted: + try: + assert ticket.email + await _send_ticket_email_notification( + [ticket.email], text_message, subject, html_message + ) + ticket.extra.email_notification_sent = True + result.email.sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to email ticket {ticket.id}: {exc}") + result.email.error = str(exc) + + if result.nostr.attempted: + try: + identifier = ticket.extra.nostr_identifier + assert identifier + await _send_nostr_ticket_notification(identifier, text_message) + ticket.extra.nostr_notification_sent = True + result.nostr.sent = True + updated = True + except Exception as exc: + logger.warning(f"Failed to send nostr DM for ticket {ticket.id}: {exc}") + result.nostr.error = str(exc) + + if updated: + result.ticket = await update_ticket(ticket) + return result + + +async def _send_ticket_email_notification( + to_emails: list[str], + message: str, + subject: str, + html_message: str | None = None, +) -> None: + """Multipart (text + HTML) ticket email through the instance SMTP + settings. Core's `send_email_notification` is plain-text only, which is + why this lives here (ported from upstream v1.6.8). The blocking smtplib + session runs in a worker thread so a slow relay cannot stall the event + loop while a batch of tickets settles.""" + if not settings.lnbits_email_notifications_enabled: + raise ValueError("Email notifications are disabled") + from_email = settings.lnbits_email_notifications_email + if not is_valid_email_address(from_email): + raise ValueError(f"Invalid from email address: {from_email}") + if not to_emails: + raise ValueError("No email addresses provided") + for address in to_emails: + if not is_valid_email_address(address): + raise ValueError(f"Invalid email address: {address}") + + msg = MIMEMultipart("alternative") + msg["From"] = from_email + msg["To"] = ", ".join(to_emails) + msg["Subject"] = subject + msg.attach(MIMEText(message, "plain")) + if html_message: + msg.attach(MIMEText(html_message, "html")) + + username = settings.lnbits_email_notifications_username or from_email + await asyncio.to_thread( + _smtp_send, + settings.lnbits_email_notifications_server, + settings.lnbits_email_notifications_port, + username, + settings.lnbits_email_notifications_password, + from_email, + to_emails, + msg.as_string(), + ) + + +def _smtp_send( + server: str, + port: int, + username: str, + password: str, + from_email: str, + to_emails: list[str], + payload: str, +) -> None: + with smtplib.SMTP(server, port, timeout=30) as smtp_server: + smtp_server.starttls() + smtp_server.login(username, password) + smtp_server.sendmail(from_email, to_emails, payload) + + async def _send_nostr_ticket_notification(identifier: str, message: str) -> None: if "@" in identifier: await send_user_notification( @@ -161,6 +276,14 @@ def _ticket_url(ticket: Ticket) -> str: return f"{base_url}/events/ticket/{ticket.id}" +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 + same host).""" + return f"{settings.lnbits_baseurl.rstrip('/')}/events/api/v1/qr/{ticket.id}" + + async def refund_tickets(event_id: str): """ Refund tickets for an event that has not met the minimum ticket requirement.