From a44de1aa3f398164049973f4af4a6ecdab74805d Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 13 Sep 2026 17:40:49 +0200 Subject: [PATCH] feat: "Email sent" column and per-event copy-to-organizer BCC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- models.py | 4 ++++ services.py | 15 ++++++++++++++- static/js/index.js | 16 ++++++++++++++++ static/js/index.vue | 8 ++++++++ tests/test_ticket_email.py | 31 +++++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 1 deletion(-) diff --git a/models.py b/models.py index 9a24822..be5fb69 100644 --- a/models.py +++ b/models.py @@ -38,6 +38,10 @@ class EventExtra(BaseModel): # 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): diff --git a/services.py b/services.py index 2d2644b..7969b16 100644 --- a/services.py +++ b/services.py @@ -255,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, @@ -271,6 +278,7 @@ async def _deliver_ticket_notifications( ], reply_to=reply_to, sender_name=organizer_sender_name(event), + bcc=bcc, ) ticket.extra.email_notification_sent = True result.email.sent = True @@ -304,6 +312,7 @@ async def _send_ticket_email_notification( 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 @@ -320,6 +329,10 @@ 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, @@ -339,7 +352,7 @@ async def _send_ticket_email_notification( username, settings.lnbits_email_notifications_password, from_email, - to_emails, + envelope_recipients, msg.as_string(), ) diff --git a/static/js/index.js b/static/js/index.js index b882497..b5c4d1f 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -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', @@ -207,6 +220,7 @@ window.PageEvents = { payment_methods: ['lightning'], promo_codes: [], organizer_name: '', + copy_to_organizer: false, reply_to_email: '', notification_subject: '', notification_body: '' @@ -585,6 +599,7 @@ window.PageEvents = { nostr_notifications: false, promo_codes: [], organizer_name: '', + copy_to_organizer: false, reply_to_email: '', notification_subject: '', notification_body: '' @@ -607,6 +622,7 @@ window.PageEvents = { nostr_notifications: false, promo_codes: [], organizer_name: '', + copy_to_organizer: false, reply_to_email: '', notification_subject: '', notification_body: '' diff --git a/static/js/index.vue b/static/js/index.vue index 19b2227..a8d99ab 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -804,6 +804,14 @@ label="Nostr notifications" left-label > + +
+ Copies go to the reply-to address (or your account email). +
diff --git a/tests/test_ticket_email.py b/tests/test_ticket_email.py index 4ed1560..9bb63e8 100644 --- a/tests/test_ticket_email.py +++ b/tests/test_ticket_email.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone +import pytest from lnbits.settings import settings from ..models import Event, Ticket @@ -143,3 +144,33 @@ def test_blank_reply_to_email_is_none(): 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"]