Organizers had no way to see whether a ticket email went out short of
reading the Postfix journal, and no copy of what the attendee received —
LNbits submits over SMTP, so nothing lands in a Sent folder.
- Admin ticket table gains an "Email sent" column derived from
`extra.email_notification_sent` ("✓ sent" / "not sent" / "unpaid" /
"no email").
- `extra.copy_to_organizer`: BCC every ticket email to the event's
reply-to address (explicit, else the owner's account email) on the SMTP
envelope only — no Bcc/To header, and skipped when the buyer *is* that
address. Toggle sits with the other delivery switches in the admin form.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
471 lines
16 KiB
Python
471 lines
16 KiB
Python
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
|
|
from html import escape
|
|
|
|
from lnbits.core.crud import get_user
|
|
from lnbits.core.crud.wallets import get_wallet
|
|
from lnbits.core.models.users import UserNotifications
|
|
from lnbits.core.services.nostr import send_nostr_dm
|
|
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
|
|
from loguru import logger
|
|
|
|
from .crud import (
|
|
get_event,
|
|
get_event_tickets,
|
|
purge_unpaid_tickets,
|
|
update_event,
|
|
update_ticket,
|
|
)
|
|
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",
|
|
"wss://relay.primal.net",
|
|
"wss://relay.nostr.band",
|
|
]
|
|
|
|
# Per-event lock: serializes the counter-update + Nostr republish for a
|
|
# single event_id so two paid invoices landing on the listener queue back-
|
|
# to-back can't reorder the published state. Lazy-populated; entries are
|
|
# left in memory for the lifetime of the process (cheap — one asyncio.Lock
|
|
# object per event ever sold).
|
|
_event_paid_locks: dict[str, asyncio.Lock] = {}
|
|
|
|
|
|
def _event_paid_lock(event_id: str) -> asyncio.Lock:
|
|
lock = _event_paid_locks.get(event_id)
|
|
if lock is None:
|
|
lock = asyncio.Lock()
|
|
_event_paid_locks[event_id] = lock
|
|
return lock
|
|
|
|
|
|
async def set_ticket_paid(ticket: Ticket) -> Ticket:
|
|
if ticket.paid:
|
|
return ticket
|
|
|
|
async with _event_paid_lock(ticket.event):
|
|
ticket.paid = True
|
|
await update_ticket(ticket)
|
|
|
|
event = await get_event(ticket.event)
|
|
assert event, "Couldn't get event from ticket being paid"
|
|
event.sold += 1
|
|
event.amount_tickets -= 1
|
|
await update_event(event)
|
|
|
|
# Republish the NIP-52 calendar event so connected clients see
|
|
# the new tickets_available / tickets_sold counters via their
|
|
# existing relay subscription. Failures are logged + swallowed
|
|
# inside publish_or_delete_nostr_event so a Nostr outage doesn't
|
|
# break the payment flow.
|
|
await publish_or_delete_nostr_event(event)
|
|
|
|
return ticket
|
|
|
|
|
|
def send_ticket_notification_in_background(ticket: Ticket) -> None:
|
|
create_task(_send_ticket_notification(ticket))
|
|
|
|
|
|
async def _send_ticket_notification(ticket: Ticket) -> None:
|
|
event = await get_event(ticket.event)
|
|
if not event:
|
|
logger.warning(f"Event {ticket.event} not found for ticket notification.")
|
|
return
|
|
|
|
await _deliver_ticket_notifications(ticket, event)
|
|
|
|
|
|
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.")
|
|
if not settings.lnbits_email_notifications_enabled:
|
|
raise ValueError("Email notifications are not enabled.")
|
|
if not ticket.email:
|
|
raise ValueError("Ticket does not have an email address.")
|
|
|
|
return await _deliver_ticket_notifications(ticket, event, email=True, nostr=False)
|
|
|
|
|
|
def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str]:
|
|
ticket_url = _ticket_url(ticket)
|
|
subject = (
|
|
event.extra.notification_subject.strip()
|
|
or f"Your ticket for '{event.name}' is ready"
|
|
)
|
|
body = (
|
|
event.extra.notification_body.strip()
|
|
or f"Your ticket for '{event.name}' is ready."
|
|
)
|
|
|
|
return subject, f"{body}\n\nOpen it here: {ticket_url}"
|
|
|
|
|
|
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)}"]
|
|
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 "
|
|
"door to be scanned in."
|
|
)
|
|
if event.extra.organizer_name:
|
|
lines.append(f"Organizer: {event.extra.organizer_name}")
|
|
return "\n".join(lines)
|
|
|
|
|
|
def _ticket_details_with_reply_hint(
|
|
ticket: Ticket, event: Event, reply_to: str | None
|
|
) -> str:
|
|
details = _ticket_details(ticket, event)
|
|
if reply_to:
|
|
who = event.extra.organizer_name or "the organizer"
|
|
details += f"\nQuestions? Reply to this email and it reaches {who}."
|
|
return details
|
|
|
|
|
|
def _ticket_delivery_message(
|
|
ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None
|
|
) -> str:
|
|
return (
|
|
f"{base_message}\n\n{_ticket_details_with_reply_hint(ticket, event, reply_to)}"
|
|
f"\n\nTicket image: {_ticket_image_url(ticket)}"
|
|
)
|
|
|
|
|
|
def _ticket_email_html_message(
|
|
ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None
|
|
) -> str:
|
|
"""HTML twin of the text part. Deliberately no <img>: 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, reply_to)
|
|
html = escape(text_message)
|
|
html = re.sub(
|
|
r"(https?://[^\s<]+)",
|
|
lambda m: f'<a href="{m.group(1)}">{m.group(1)}</a>',
|
|
html,
|
|
)
|
|
return f"<p>{html.replace(chr(10), '<br />')}</p>"
|
|
|
|
|
|
def _ticket_notification_payload(
|
|
ticket: Ticket, event: Event, reply_to: str | None = None
|
|
) -> tuple[str, str, str]:
|
|
subject, base_message = _ticket_notification_message(ticket, event)
|
|
text_message = _ticket_delivery_message(ticket, event, base_message, reply_to)
|
|
html_message = _ticket_email_html_message(ticket, event, base_message, reply_to)
|
|
return subject, text_message, html_message
|
|
|
|
|
|
async def organizer_reply_to(event: Event) -> str | None:
|
|
"""Where replies to a ticket email should go: the event's explicit
|
|
`reply_to_email`, else the email on the LNbits account that owns the
|
|
event wallet, else nothing (no Reply-To header)."""
|
|
if event.extra.reply_to_email:
|
|
return str(event.extra.reply_to_email)
|
|
try:
|
|
wallet = await get_wallet(event.wallet)
|
|
if not wallet:
|
|
return None
|
|
user = await get_user(wallet.user)
|
|
email = (user.email or "").strip() if user else ""
|
|
return email if is_valid_email_address(email) else None
|
|
except Exception as exc:
|
|
logger.warning(f"Could not resolve organizer email for {event.id}: {exc}")
|
|
return None
|
|
|
|
|
|
def organizer_sender_name(event: Event) -> str:
|
|
"""From display name: "<organizer> via <site title>" when the event
|
|
names an organizer, else the site title."""
|
|
site = (settings.lnbits_site_title or "").strip() or "Tickets"
|
|
organizer = (event.extra.organizer_name or "").strip()
|
|
return f"{organizer} via {site}" if organizer else site
|
|
|
|
|
|
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."""
|
|
reply_to = await organizer_reply_to(event)
|
|
subject, text_message, html_message = _ticket_notification_payload(
|
|
ticket, event, reply_to
|
|
)
|
|
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
|
|
bcc = (
|
|
[reply_to]
|
|
if event.extra.copy_to_organizer
|
|
and reply_to
|
|
and reply_to.lower() != ticket.email.lower()
|
|
else []
|
|
)
|
|
card = render_ticket_card(
|
|
ticket,
|
|
event,
|
|
logo=await load_qr_logo(),
|
|
site_title=settings.lnbits_site_title,
|
|
)
|
|
await _send_ticket_email_notification(
|
|
[ticket.email],
|
|
text_message,
|
|
subject,
|
|
html_message,
|
|
attachments=[
|
|
(ticket_card_filename(ticket, event), image_png_bytes(card))
|
|
],
|
|
reply_to=reply_to,
|
|
sender_name=organizer_sender_name(event),
|
|
bcc=bcc,
|
|
)
|
|
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,
|
|
attachments: list[tuple[str, bytes]] | None = None,
|
|
reply_to: str | None = None,
|
|
sender_name: str | None = None,
|
|
bcc: list[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}")
|
|
# BCC recipients ride only on the SMTP envelope, never in a header.
|
|
envelope_recipients = list(to_emails) + [
|
|
b for b in (bcc or []) if is_valid_email_address(b)
|
|
]
|
|
|
|
msg = build_ticket_email(
|
|
from_email,
|
|
to_emails,
|
|
subject,
|
|
message,
|
|
html_message,
|
|
attachments,
|
|
reply_to=reply_to,
|
|
sender_name=sender_name,
|
|
)
|
|
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,
|
|
envelope_recipients,
|
|
msg.as_string(),
|
|
)
|
|
|
|
|
|
def build_ticket_email(
|
|
from_email: str,
|
|
to_emails: list[str],
|
|
subject: str,
|
|
message: str,
|
|
html_message: str | None = None,
|
|
attachments: list[tuple[str, bytes]] | None = None,
|
|
reply_to: str | None = None,
|
|
sender_name: str | None = None,
|
|
) -> MIMEMultipart:
|
|
"""Assemble the ticket email: text + HTML alternatives, PNG attachments
|
|
(the ticket card), and the headers receivers score on — a Date and a
|
|
Message-ID (their absence is what SpamAssassin's MISSING_DATE /
|
|
MISSING_MID flag, and what Gmail/Outlook read as machine-generated) and
|
|
a display name on From so the sender is not a bare address."""
|
|
body = MIMEMultipart("alternative")
|
|
body.attach(MIMEText(message, "plain"))
|
|
if html_message:
|
|
body.attach(MIMEText(html_message, "html"))
|
|
|
|
if attachments:
|
|
msg = MIMEMultipart("mixed")
|
|
msg.attach(body)
|
|
for filename, data in attachments:
|
|
part = MIMEImage(data, _subtype="png")
|
|
part.add_header("Content-Disposition", "attachment", filename=filename)
|
|
msg.attach(part)
|
|
else:
|
|
msg = body
|
|
|
|
display_name = (
|
|
sender_name or settings.lnbits_site_title or ""
|
|
).strip() or "Tickets"
|
|
msg["From"] = formataddr((display_name, from_email))
|
|
msg["To"] = ", ".join(to_emails)
|
|
if reply_to and is_valid_email_address(reply_to):
|
|
msg["Reply-To"] = reply_to
|
|
msg["Subject"] = subject
|
|
msg["Date"] = formatdate(localtime=True)
|
|
msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1])
|
|
return msg
|
|
|
|
|
|
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(
|
|
UserNotifications(nostr_identifier=identifier),
|
|
message,
|
|
"text_message",
|
|
)
|
|
return
|
|
|
|
private_key = normalize_private_key(settings.lnbits_nostr_notifications_private_key)
|
|
public_key = normalize_public_key(identifier)
|
|
await send_nostr_dm(private_key, public_key, message, DEFAULT_NOSTR_RELAYS)
|
|
|
|
|
|
def _ticket_url(ticket: Ticket) -> str:
|
|
base_url = (ticket.extra.ticket_base_url or settings.lnbits_baseurl).rstrip("/")
|
|
return f"{base_url}/events/ticket/{ticket.id}"
|
|
|
|
|
|
def _ticket_image_url(ticket: Ticket) -> str:
|
|
"""The ticket card 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/ticket-card/{ticket.id}"
|
|
)
|
|
|
|
|
|
async def refund_tickets(event_id: str):
|
|
"""
|
|
Refund tickets for an event that has not met the minimum ticket requirement.
|
|
This function should be called when the event is closed and the minimum ticket
|
|
condition is not met.
|
|
"""
|
|
await purge_unpaid_tickets(event_id)
|
|
tickets = await get_event_tickets(event_id)
|
|
|
|
if not tickets:
|
|
return
|
|
|
|
for ticket in tickets:
|
|
if ticket.extra.refunded:
|
|
continue
|
|
if ticket.paid and ticket.extra.refund_address and ticket.extra.sats_paid:
|
|
try:
|
|
res = await execute(
|
|
ticket.extra.refund_address, str(ticket.extra.sats_paid)
|
|
)
|
|
if res:
|
|
ticket.extra.refunded = True
|
|
await update_ticket(ticket)
|
|
except Exception as e:
|
|
logger.error(f"Error refunding ticket {ticket.id}: {e}")
|