Compare commits
3 commits
main
...
feat/ticke
| Author | SHA1 | Date | |
|---|---|---|---|
| f04ff73b28 | |||
| a44de1aa3f | |||
| 72b9c540f7 |
7 changed files with 267 additions and 26 deletions
|
|
@ -67,7 +67,10 @@ Events includes a shareable ticket scanner, which can be used to register attend
|
|||
instance logo, name on ticket, ticket id) also served at
|
||||
`GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at
|
||||
`GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a
|
||||
From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email`
|
||||
From display name — `extra.organizer_name` gives "Organizer via <site title>",
|
||||
otherwise the site title. Replies go to `extra.reply_to_email`, falling back
|
||||
to the event owner's account email (no Reply-To when neither exists). The
|
||||
From _address_ is always the instance mailbox, which is what DKIM signs. `extra.copy_to_organizer` BCCs every ticket email to that reply-to address (envelope only, no header) so the organizer keeps a copy; the admin ticket table shows an "Email sent" column from `extra.email_notification_sent`. `POST /events/api/v1/tickets/{ticket_id}/resend-email`
|
||||
returns a `TicketResendResult` with per-channel outcome.
|
||||
|
||||
## Powered by LNbits
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ Running log of fork features that are shaped so they could be offered to
|
|||
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||
|
||||
| Feature | Where | Upstream target | Readiness |
|
||||
| ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||
| `frontend_url` + origin allow-list + `?checkout=` return contract | `views_api.py` `_resolve_frontend_root`, `api_ticket_create` | lnbits/events | after the #33 rebase, as a small PR |
|
||||
| Ticket ids minted before the invoice so `success_url` can carry them | `api_ticket_create` | lnbits/events | ships with the above |
|
||||
| `extra.checkout` (success/cancel URL, `customer_email`, line item, metadata) on fiat purchases | `api_ticket_create` | lnbits/events (needs lnbits `StripeCheckoutOptions.cancel_url`/`customer_email`, PR'd from aiolabs/lnbits) | with the lnbits patch |
|
||||
|
|
@ -17,3 +17,5 @@ in an upstream-compatible form; strike it when the PR merges upstream.
|
|||
| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 |
|
||||
| `Date` + `Message-ID` + From display name on the ticket email (`build_ticket_email`); event details in the body | `services.py` | lnbits/events (mailer) **and** lnbits/lnbits `send_email` (same omissions, hits password-reset/admin mails) | trivial, standalone — measured: SpamAssassin MISSING_DATE 1.4 + MISSING_MID 0.14 |
|
||||
| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `<img>` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) |
|
||||
| Per-event sender identity: `extra.organizer_name` (From display name "X via <site>") + `extra.reply_to_email` (Reply-To, falls back to owner account email) | `models.py`, `services.py`, admin dialog | lnbits/events | small, standalone |
|
||||
| `extra.copy_to_organizer` (envelope BCC of ticket emails to the reply-to) + "Email sent" column in the admin ticket table | `services.py`, `static/js/index.*` | lnbits/events | small, standalone |
|
||||
|
|
|
|||
18
models.py
18
models.py
|
|
@ -31,6 +31,24 @@ class EventExtra(BaseModel):
|
|||
nostr_notifications: bool = False
|
||||
notification_subject: str = ""
|
||||
notification_body: str = ""
|
||||
# Sender identity for ticket emails, per event: the From display name
|
||||
# becomes "<organizer_name> via <site title>" and replies go to
|
||||
# `reply_to_email` (falls back to the event owner's account email).
|
||||
# The From *address* stays the instance mailbox — that is what DKIM
|
||||
# signs — so organizers get a human sender without touching mail auth.
|
||||
organizer_name: str = ""
|
||||
reply_to_email: EmailStr | None = None
|
||||
# BCC every ticket email to the reply-to address (explicit or the
|
||||
# owner's account email) so the organizer keeps a copy — LNbits submits
|
||||
# over SMTP, nothing lands in a Sent folder otherwise.
|
||||
copy_to_organizer: bool = False
|
||||
|
||||
@validator("reply_to_email", pre=True)
|
||||
def blank_reply_to_is_none(cls, v):
|
||||
if isinstance(v, str) and not v.strip():
|
||||
return None
|
||||
return v
|
||||
|
||||
# Rails the organizer accepts for this event. Empty = legacy rule
|
||||
# ("lightning" always, "fiat" when allow_fiat) — see
|
||||
# `effective_payment_methods`. Same field name/shape as upstream v2 so the
|
||||
|
|
|
|||
103
services.py
103
services.py
|
|
@ -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
|
||||
|
|
@ -206,6 +255,13 @@ async def _deliver_ticket_notifications(
|
|||
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,
|
||||
|
|
@ -220,6 +276,9 @@ 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),
|
||||
bcc=bcc,
|
||||
)
|
||||
ticket.extra.email_notification_sent = True
|
||||
result.email.sent = True
|
||||
|
|
@ -251,6 +310,9 @@ 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,
|
||||
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
|
||||
|
|
@ -267,9 +329,20 @@ async def _send_ticket_email_notification(
|
|||
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
|
||||
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(
|
||||
|
|
@ -279,7 +352,7 @@ async def _send_ticket_email_notification(
|
|||
username,
|
||||
settings.lnbits_email_notifications_password,
|
||||
from_email,
|
||||
to_emails,
|
||||
envelope_recipients,
|
||||
msg.as_string(),
|
||||
)
|
||||
|
||||
|
|
@ -291,6 +364,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 +387,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])
|
||||
|
|
|
|||
|
|
@ -133,6 +133,19 @@ window.PageEvents = {
|
|||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
||||
{
|
||||
name: 'email_sent',
|
||||
align: 'left',
|
||||
label: 'Email sent',
|
||||
field: row =>
|
||||
!row.email
|
||||
? 'no email'
|
||||
: row.extra?.email_notification_sent
|
||||
? '\u2713 sent'
|
||||
: row.paid
|
||||
? 'not sent'
|
||||
: 'unpaid'
|
||||
},
|
||||
{
|
||||
name: 'registered',
|
||||
align: 'left',
|
||||
|
|
@ -206,6 +219,9 @@ window.PageEvents = {
|
|||
extra: {
|
||||
payment_methods: ['lightning'],
|
||||
promo_codes: [],
|
||||
organizer_name: '',
|
||||
copy_to_organizer: false,
|
||||
reply_to_email: '',
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
}
|
||||
|
|
@ -582,6 +598,9 @@ window.PageEvents = {
|
|||
email_notifications: false,
|
||||
nostr_notifications: false,
|
||||
promo_codes: [],
|
||||
organizer_name: '',
|
||||
copy_to_organizer: false,
|
||||
reply_to_email: '',
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
}
|
||||
|
|
@ -602,6 +621,9 @@ window.PageEvents = {
|
|||
email_notifications: false,
|
||||
nostr_notifications: false,
|
||||
promo_codes: [],
|
||||
organizer_name: '',
|
||||
copy_to_organizer: false,
|
||||
reply_to_email: '',
|
||||
notification_subject: '',
|
||||
notification_body: ''
|
||||
}
|
||||
|
|
|
|||
|
|
@ -804,6 +804,14 @@
|
|||
label="Nostr notifications"
|
||||
left-label
|
||||
></q-toggle>
|
||||
<q-toggle
|
||||
v-model="formDialog.data.extra.copy_to_organizer"
|
||||
label="Send me a copy of each ticket email"
|
||||
left-label
|
||||
></q-toggle>
|
||||
<div class="text-caption text-grey-7">
|
||||
Copies go to the reply-to address (or your account email).
|
||||
</div>
|
||||
</q-expansion-item>
|
||||
|
||||
<q-separator class="q-my-md"></q-separator>
|
||||
|
|
@ -823,6 +831,28 @@
|
|||
label="Ticket notification body"
|
||||
hint="Shown before the ticket link in the paid ticket notification."
|
||||
></q-input>
|
||||
<div class="row q-col-gutter-md">
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.organizer_name"
|
||||
type="text"
|
||||
label="Organizer name (email sender)"
|
||||
hint="Shown as 'Name via <site>' in the From line of ticket emails."
|
||||
></q-input>
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<q-input
|
||||
filled
|
||||
dense
|
||||
v-model.trim="formDialog.data.extra.reply_to_email"
|
||||
type="email"
|
||||
label="Reply-to email"
|
||||
hint="Where attendee replies go. Blank = your account email."
|
||||
></q-input>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row q-mt-lg">
|
||||
<q-btn
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from lnbits.settings import settings
|
||||
|
||||
from ..models import Event, Ticket
|
||||
|
|
@ -87,3 +88,89 @@ def test_payload_includes_event_details_and_qr(monkeypatch):
|
|||
assert "/events/api/v1/ticket-card/tkt1" in text
|
||||
assert "<img" not in html and "Ticket ID: tkt1" in html
|
||||
assert '<a href="https://lnbits.example/events/api/v1/ticket-card/tkt1">' in html
|
||||
|
||||
|
||||
def test_reply_to_and_organizer_sender_name(monkeypatch):
|
||||
from ..services import organizer_sender_name
|
||||
|
||||
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
|
||||
event = _event(
|
||||
extra={
|
||||
"organizer_name": "Château du Faune",
|
||||
"reply_to_email": "hello@faune.example",
|
||||
}
|
||||
)
|
||||
assert organizer_sender_name(event) == "Château du Faune via Oyez!"
|
||||
assert organizer_sender_name(_event()) == "Oyez!"
|
||||
|
||||
msg = build_ticket_email(
|
||||
"tickets@example.org",
|
||||
["ada@example.com"],
|
||||
"Subj",
|
||||
"text",
|
||||
None,
|
||||
reply_to="hello@faune.example",
|
||||
sender_name=organizer_sender_name(event),
|
||||
)
|
||||
assert msg["Reply-To"] == "hello@faune.example"
|
||||
# Non-ASCII display names are RFC 2047-encoded on the wire; decode to compare.
|
||||
from email.header import decode_header, make_header
|
||||
|
||||
assert str(make_header(decode_header(msg["From"]))) == (
|
||||
"Château du Faune via Oyez! <tickets@example.org>"
|
||||
)
|
||||
|
||||
no_reply = build_ticket_email(
|
||||
"tickets@example.org", ["ada@example.com"], "Subj", "text"
|
||||
)
|
||||
assert no_reply["Reply-To"] is None
|
||||
|
||||
|
||||
def test_reply_hint_only_when_reply_to_exists(monkeypatch):
|
||||
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
||||
event = _event(extra={"organizer_name": "Château du Faune"})
|
||||
_, text, _ = _ticket_notification_payload(
|
||||
_ticket(), event, reply_to="hello@faune.example"
|
||||
)
|
||||
assert "Organizer: Château du Faune" in text
|
||||
assert "Reply to this email and it reaches Château du Faune" in text
|
||||
_, text, _ = _ticket_notification_payload(_ticket(), event, reply_to=None)
|
||||
assert "Reply to this email" not in text
|
||||
|
||||
|
||||
def test_blank_reply_to_email_is_none():
|
||||
from ..models import EventExtra
|
||||
|
||||
assert EventExtra(reply_to_email="").reply_to_email is None
|
||||
assert EventExtra(reply_to_email=" ").reply_to_email is None
|
||||
assert EventExtra(reply_to_email="a@b.example").reply_to_email == "a@b.example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_copy_to_organizer_is_bcc_on_the_envelope_only(monkeypatch):
|
||||
from .. import services
|
||||
|
||||
monkeypatch.setattr(settings, "lnbits_email_notifications_enabled", True)
|
||||
monkeypatch.setattr(
|
||||
settings, "lnbits_email_notifications_email", "oyez@example.org"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
settings, "lnbits_email_notifications_server", "smtp.example.org"
|
||||
)
|
||||
monkeypatch.setattr(settings, "lnbits_email_notifications_port", 587)
|
||||
monkeypatch.setattr(settings, "lnbits_email_notifications_username", "")
|
||||
monkeypatch.setattr(settings, "lnbits_email_notifications_password", "x")
|
||||
sent = {}
|
||||
|
||||
def fake_smtp_send(
|
||||
server, port, username, password, from_email, recipients, payload
|
||||
):
|
||||
sent.update(recipients=recipients, payload=payload)
|
||||
|
||||
monkeypatch.setattr(services, "_smtp_send", fake_smtp_send)
|
||||
await services._send_ticket_email_notification(
|
||||
["buyer@example.com"], "text", "Subj", None, bcc=["organizer@example.org"]
|
||||
)
|
||||
assert sent["recipients"] == ["buyer@example.com", "organizer@example.org"]
|
||||
assert "organizer@example.org" not in sent["payload"] # no Bcc/To leak
|
||||
assert "To: buyer@example.com" in sent["payload"]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue