feat: per-event organizer name + reply-to on ticket emails
Some checks failed
lint.yml / feat: per-event organizer name + reply-to on ticket emails (pull_request) Failing after 0s

Each organizer is different, so the sender identity of ticket emails is
now per event rather than per instance:

- `extra.organizer_name` → From display name "Organizer via <site title>"
  (site title alone when unset). The From address stays the instance
  mailbox — that is what DKIM signs — so this costs nothing in mail auth.
- `extra.reply_to_email` → Reply-To; blank falls back to the email on the
  LNbits account that owns the event wallet; no header when neither
  exists. When a reply-to exists the body says "Questions? Reply to this
  email and it reaches <organizer>", and "Organizer: <name>" is listed
  with the ticket details.

Both fields sit next to the existing per-event subject/body in the admin
dialog. The organizer's own wording (in whatever language) remains
`notification_body`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
This commit is contained in:
Padreug 2026-09-09 12:34:19 +02:00
commit 72b9c540f7
7 changed files with 193 additions and 25 deletions

View file

@ -10,6 +10,8 @@ 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
@ -138,22 +140,38 @@ def _ticket_details(ticket: Ticket, event: Event) -> str:
"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_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
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(ticket, event)}"
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) -> str:
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)
text_message = _ticket_delivery_message(ticket, event, base_message, reply_to)
html = escape(text_message)
html = re.sub(
r"(https?://[^\s<]+)",
@ -163,13 +181,41 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str)
return f"<p>{html.replace(chr(10), '<br />')}</p>"
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
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)
html_message = _ticket_email_html_message(ticket, event, base_message)
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,
@ -180,7 +226,10 @@ async def _deliver_ticket_notifications(
"""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)
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
@ -220,6 +269,8 @@ async def _deliver_ticket_notifications(
attachments=[
(ticket_card_filename(ticket, event), image_png_bytes(card))
],
reply_to=reply_to,
sender_name=organizer_sender_name(event),
)
ticket.extra.email_notification_sent = True
result.email.sent = True
@ -251,6 +302,8 @@ async def _send_ticket_email_notification(
subject: str,
html_message: str | None = None,
attachments: list[tuple[str, bytes]] | None = None,
reply_to: str | None = None,
sender_name: 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
@ -269,7 +322,14 @@ async def _send_ticket_email_notification(
raise ValueError(f"Invalid email address: {address}")
msg = build_ticket_email(
from_email, to_emails, subject, message, html_message, attachments
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(
@ -291,6 +351,8 @@ def build_ticket_email(
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
@ -312,9 +374,13 @@ def build_ticket_email(
else:
msg = body
sender_name = (settings.lnbits_site_title or "").strip() or "Tickets"
msg["From"] = formataddr((sender_name, from_email))
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])