events/services.py
Padreug a44de1aa3f feat: "Email sent" column and per-event copy-to-organizer BCC
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
2026-09-13 18:19:33 +02:00

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}")