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
176 lines
6 KiB
Python
176 lines
6 KiB
Python
from datetime import datetime, timezone
|
|
|
|
import pytest
|
|
from lnbits.settings import settings
|
|
|
|
from ..models import Event, Ticket
|
|
from ..services import _ticket_notification_payload, build_ticket_email
|
|
|
|
|
|
def _event(**overrides) -> Event:
|
|
data = {
|
|
"id": "evt1",
|
|
"wallet": "w",
|
|
"name": "Test Event",
|
|
"info": "",
|
|
"closing_date": "2030-01-01",
|
|
"event_start_date": "2030-01-01T16:00:00+01:00",
|
|
"event_end_date": "2030-01-01T20:00:00+01:00",
|
|
"location": "The Chateau",
|
|
"amount_tickets": 10,
|
|
"price_per_ticket": 5,
|
|
"time": datetime.now(timezone.utc),
|
|
}
|
|
data.update(overrides)
|
|
return Event(**data)
|
|
|
|
|
|
def _ticket(**overrides) -> Ticket:
|
|
now = datetime.now(timezone.utc)
|
|
data = {
|
|
"id": "tkt1",
|
|
"wallet": "w",
|
|
"event": "evt1",
|
|
"name": "Ada",
|
|
"email": "ada@example.com",
|
|
"registered": False,
|
|
"paid": True,
|
|
"time": now,
|
|
"reg_timestamp": now,
|
|
}
|
|
data.update(overrides)
|
|
return Ticket(**data)
|
|
|
|
|
|
def test_email_carries_date_message_id_and_display_name(monkeypatch):
|
|
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
|
|
msg = build_ticket_email(
|
|
"tickets@example.org", ["ada@example.com"], "Subj", "text", "<p>html</p>"
|
|
)
|
|
assert msg["Date"]
|
|
assert msg["Message-ID"].endswith("@example.org>")
|
|
assert msg["From"] == "Oyez! <tickets@example.org>"
|
|
parts = [p.get_content_type() for p in msg.get_payload()]
|
|
assert parts == ["text/plain", "text/html"]
|
|
|
|
|
|
def test_email_attaches_the_ticket_card(monkeypatch):
|
|
monkeypatch.setattr(settings, "lnbits_site_title", "Oyez!")
|
|
png = b"\x89PNG\r\n\x1a\n" + b"0" * 32
|
|
msg = build_ticket_email(
|
|
"tickets@example.org",
|
|
["ada@example.com"],
|
|
"Subj",
|
|
"text",
|
|
"<p>html</p>",
|
|
attachments=[("ticket-test-8auNuuaB.png", png)],
|
|
)
|
|
assert msg.get_content_type() == "multipart/mixed"
|
|
body, attachment = msg.get_payload()
|
|
assert body.get_content_type() == "multipart/alternative"
|
|
assert attachment.get_content_type() == "image/png"
|
|
assert attachment.get_filename() == "ticket-test-8auNuuaB.png"
|
|
assert attachment.get_payload(decode=True) == png
|
|
|
|
|
|
def test_payload_includes_event_details_and_qr(monkeypatch):
|
|
monkeypatch.setattr(settings, "lnbits_baseurl", "https://lnbits.example/")
|
|
subject, text, html = _ticket_notification_payload(_ticket(), _event())
|
|
assert "Test Event" in subject
|
|
for needle in (
|
|
"Event: Test Event",
|
|
"Where: The Chateau",
|
|
"Name on ticket: Ada",
|
|
"Ticket ID: tkt1",
|
|
"at the door",
|
|
):
|
|
assert needle in text
|
|
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"]
|