feat: "Email sent" column and per-event copy-to-organizer BCC (1.6.1-aio.15)
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
This commit is contained in:
parent
3d47862181
commit
598d0ca6ab
6 changed files with 74 additions and 2 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"version": "1.6.1-aio.14",
|
"version": "1.6.1-aio.15",
|
||||||
"name": "Events",
|
"name": "Events",
|
||||||
"repo": "https://git.atitlan.io/aiolabs/events",
|
"repo": "https://git.atitlan.io/aiolabs/events",
|
||||||
"short_description": "Sell and register event tickets",
|
"short_description": "Sell and register event tickets",
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,10 @@ class EventExtra(BaseModel):
|
||||||
# signs — so organizers get a human sender without touching mail auth.
|
# signs — so organizers get a human sender without touching mail auth.
|
||||||
organizer_name: str = ""
|
organizer_name: str = ""
|
||||||
reply_to_email: EmailStr | None = None
|
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)
|
@validator("reply_to_email", pre=True)
|
||||||
def blank_reply_to_is_none(cls, v):
|
def blank_reply_to_is_none(cls, v):
|
||||||
|
|
|
||||||
15
services.py
15
services.py
|
|
@ -255,6 +255,13 @@ async def _deliver_ticket_notifications(
|
||||||
if result.email.attempted:
|
if result.email.attempted:
|
||||||
try:
|
try:
|
||||||
assert ticket.email
|
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(
|
card = render_ticket_card(
|
||||||
ticket,
|
ticket,
|
||||||
event,
|
event,
|
||||||
|
|
@ -271,6 +278,7 @@ async def _deliver_ticket_notifications(
|
||||||
],
|
],
|
||||||
reply_to=reply_to,
|
reply_to=reply_to,
|
||||||
sender_name=organizer_sender_name(event),
|
sender_name=organizer_sender_name(event),
|
||||||
|
bcc=bcc,
|
||||||
)
|
)
|
||||||
ticket.extra.email_notification_sent = True
|
ticket.extra.email_notification_sent = True
|
||||||
result.email.sent = True
|
result.email.sent = True
|
||||||
|
|
@ -304,6 +312,7 @@ async def _send_ticket_email_notification(
|
||||||
attachments: list[tuple[str, bytes]] | None = None,
|
attachments: list[tuple[str, bytes]] | None = None,
|
||||||
reply_to: str | None = None,
|
reply_to: str | None = None,
|
||||||
sender_name: str | None = None,
|
sender_name: str | None = None,
|
||||||
|
bcc: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Multipart (text + HTML) ticket email through the instance SMTP
|
"""Multipart (text + HTML) ticket email through the instance SMTP
|
||||||
settings. Core's `send_email_notification` is plain-text only, which is
|
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:
|
for address in to_emails:
|
||||||
if not is_valid_email_address(address):
|
if not is_valid_email_address(address):
|
||||||
raise ValueError(f"Invalid 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(
|
msg = build_ticket_email(
|
||||||
from_email,
|
from_email,
|
||||||
|
|
@ -339,7 +352,7 @@ async def _send_ticket_email_notification(
|
||||||
username,
|
username,
|
||||||
settings.lnbits_email_notifications_password,
|
settings.lnbits_email_notifications_password,
|
||||||
from_email,
|
from_email,
|
||||||
to_emails,
|
envelope_recipients,
|
||||||
msg.as_string(),
|
msg.as_string(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,19 @@ window.PageEvents = {
|
||||||
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
{name: 'event', align: 'left', label: 'Event', field: 'event'},
|
||||||
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
{name: 'name', align: 'left', label: 'Name', field: 'name'},
|
||||||
{name: 'email', align: 'left', label: 'Email', field: 'email'},
|
{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',
|
name: 'registered',
|
||||||
align: 'left',
|
align: 'left',
|
||||||
|
|
@ -207,6 +220,7 @@ window.PageEvents = {
|
||||||
payment_methods: ['lightning'],
|
payment_methods: ['lightning'],
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
organizer_name: '',
|
organizer_name: '',
|
||||||
|
copy_to_organizer: false,
|
||||||
reply_to_email: '',
|
reply_to_email: '',
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
|
|
@ -585,6 +599,7 @@ window.PageEvents = {
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
organizer_name: '',
|
organizer_name: '',
|
||||||
|
copy_to_organizer: false,
|
||||||
reply_to_email: '',
|
reply_to_email: '',
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
|
|
@ -607,6 +622,7 @@ window.PageEvents = {
|
||||||
nostr_notifications: false,
|
nostr_notifications: false,
|
||||||
promo_codes: [],
|
promo_codes: [],
|
||||||
organizer_name: '',
|
organizer_name: '',
|
||||||
|
copy_to_organizer: false,
|
||||||
reply_to_email: '',
|
reply_to_email: '',
|
||||||
notification_subject: '',
|
notification_subject: '',
|
||||||
notification_body: ''
|
notification_body: ''
|
||||||
|
|
|
||||||
|
|
@ -804,6 +804,14 @@
|
||||||
label="Nostr notifications"
|
label="Nostr notifications"
|
||||||
left-label
|
left-label
|
||||||
></q-toggle>
|
></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-expansion-item>
|
||||||
|
|
||||||
<q-separator class="q-my-md"></q-separator>
|
<q-separator class="q-my-md"></q-separator>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
from lnbits.settings import settings
|
from lnbits.settings import settings
|
||||||
|
|
||||||
from ..models import Event, Ticket
|
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=" ").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"
|
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