feat: multipart ticket email with embedded QR, structured resend result
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
This commit is contained in:
parent
f77ad28bdd
commit
92642a1f24
1 changed files with 164 additions and 41 deletions
205
services.py
205
services.py
|
|
@ -1,14 +1,16 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import smtplib
|
||||||
from asyncio.tasks import create_task
|
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.models.users import UserNotifications
|
||||||
from lnbits.core.services.nostr import send_nostr_dm
|
from lnbits.core.services.nostr import send_nostr_dm
|
||||||
from lnbits.core.services.notifications import (
|
from lnbits.core.services.notifications import send_user_notification
|
||||||
send_email_notification,
|
from lnbits.helpers import is_valid_email_address
|
||||||
send_user_notification,
|
|
||||||
)
|
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
|
from lnbits.utils.nostr import normalize_private_key, normalize_public_key
|
||||||
from lnurl import execute
|
from lnurl import execute
|
||||||
|
|
@ -21,7 +23,7 @@ from .crud import (
|
||||||
update_event,
|
update_event,
|
||||||
update_ticket,
|
update_ticket,
|
||||||
)
|
)
|
||||||
from .models import Event, Ticket
|
from .models import Event, NotificationDeliveryResult, Ticket, TicketResendResult
|
||||||
from .nostr_hooks import publish_or_delete_nostr_event
|
from .nostr_hooks import publish_or_delete_nostr_event
|
||||||
|
|
||||||
DEFAULT_NOSTR_RELAYS = [
|
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.")
|
logger.warning(f"Event {ticket.event} not found for ticket notification.")
|
||||||
return
|
return
|
||||||
|
|
||||||
subject, message = _ticket_notification_message(ticket, event)
|
await _deliver_ticket_notifications(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)
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
event = await get_event(ticket.event)
|
||||||
if not event:
|
if not event:
|
||||||
raise ValueError("Event does not exist.")
|
raise ValueError("Event does not exist.")
|
||||||
|
|
@ -122,10 +97,7 @@ async def resend_ticket_email_notification(ticket: Ticket) -> Ticket:
|
||||||
if not ticket.email:
|
if not ticket.email:
|
||||||
raise ValueError("Ticket does not have an email address.")
|
raise ValueError("Ticket does not have an email address.")
|
||||||
|
|
||||||
subject, message = _ticket_notification_message(ticket, event)
|
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
||||||
await send_email_notification([ticket.email], message, subject)
|
|
||||||
ticket.extra.email_notification_sent = True
|
|
||||||
return await update_ticket(ticket)
|
|
||||||
|
|
||||||
|
|
||||||
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
|
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}"
|
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"<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>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
async def _send_nostr_ticket_notification(identifier: str, message: str) -> None:
|
||||||
if "@" in identifier:
|
if "@" in identifier:
|
||||||
await send_user_notification(
|
await send_user_notification(
|
||||||
|
|
@ -161,6 +276,14 @@ def _ticket_url(ticket: Ticket) -> str:
|
||||||
return f"{base_url}/events/ticket/{ticket.id}"
|
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):
|
async def refund_tickets(event_id: str):
|
||||||
"""
|
"""
|
||||||
Refund tickets for an event that has not met the minimum ticket requirement.
|
Refund tickets for an event that has not met the minimum ticket requirement.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue