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'
'
+ )
+
+
+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.