From 3a8e0ff59168d0b2320ddaa02d347b96acaf67ed Mon Sep 17 00:00:00 2001 From: Padreug Date: Tue, 8 Sep 2026 16:19:38 +0200 Subject: [PATCH] fix: Date/Message-ID/From-name on ticket emails, richer body (1.6.1-aio.9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A tester's ticket email landed in spam. A mail-tester run against demo scored 8.3/10 with SPF, DKIM and DMARC all passing through the VPS relay, so the deductions were all in the message: MISSING_DATE (1.4), HTML_IMAGE_ONLY_04 (0.3), MISSING_MID (0.1) — and Gmail/Outlook weigh a missing Date/Message-ID as "machine-generated" far more than that. - `build_ticket_email` sets Date, a Message-ID under the sender domain, and a From display name from `lnbits_site_title`. - The body now carries the event name, dates, location, name on ticket, ticket id and the door instruction, so the HTML part is no longer a QR with a handful of words. Upstream candidate: lnbits core `send_email` has the same omissions. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- config.json | 2 +- docs/upstream-candidates.md | 23 +++++++------ services.py | 69 +++++++++++++++++++++++++++++-------- tests/test_ticket_email.py | 69 +++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 26 deletions(-) create mode 100644 tests/test_ticket_email.py diff --git a/config.json b/config.json index 330240f..46be592 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.8", + "version": "1.6.1-aio.9", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", diff --git a/docs/upstream-candidates.md b/docs/upstream-candidates.md index b3246c2..539fbef 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -4,14 +4,15 @@ Running log of fork features that are shaped so they could be offered to `lnbits/events` (or `lnbits/lnbits`). Add a row whenever a change lands here 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 | -| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | -| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | -| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | -| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | -| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | -| NIP-52 publishing + approval workflow | `nostr_*.py`, `views_api.py` | lnbits/events #46 | open; rebase onto v1.6.8 | +| 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 | +| `extra.payment_methods` per event + `tickets_payment_methods` NIP-52 tag | `models.py`, `nostr_publisher.py` | lnbits/events (v2 PR #64 introduces the same field) | offer as review input on #64 | +| `asyncio.to_thread` around the smtplib send | `services.py` `_send_ticket_email_notification` | lnbits/events | trivial, standalone | +| QR logo overlay in `make_qr_png` (instance `lnbits_qr_logo`) | `views_api.py` | lnbits/events | standalone | +| Multi-ticket purchase as N rows on one `payment_hash` | `api_ticket_create`, `crud.py` | lnbits/events | overlaps v2 baskets; review input on #64 | +| Free tickets without minting an invoice | `_issue_free_tickets` | lnbits/events | small, standalone | +| 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 | diff --git a/services.py b/services.py index 83ac5c6..8476798 100644 --- a/services.py +++ b/services.py @@ -5,6 +5,7 @@ import smtplib from asyncio.tasks import create_task from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText +from email.utils import formataddr, formatdate, make_msgid from html import escape from lnbits.core.models.users import UserNotifications @@ -114,12 +115,34 @@ def _ticket_notification_message(ticket: Ticket, event: Event) -> tuple[str, str return subject, f"{body}\n\nOpen it here: {ticket_url}" -def _ticket_delivery_message(ticket: Ticket, base_message: str) -> str: - return f"{base_message}\n\nTicket image: {_ticket_image_url(ticket)}" +def _ticket_details(ticket: Ticket, event: Event) -> str: + """Human-readable ticket facts for the email body. Also what keeps the + mail from being an image with no words (SpamAssassin HTML_IMAGE_ONLY).""" + when = event.event_start_date + if event.event_end_date and event.event_end_date != event.event_start_date: + when = f"{when} to {event.event_end_date}" + lines = [f"Event: {event.name}", f"When: {when}"] + if event.location: + lines.append(f"Where: {event.location}") + if ticket.name: + lines.append(f"Name on ticket: {ticket.name}") + lines.append(f"Ticket ID: {ticket.id}") + lines.append( + "Show the QR code below (or open the link above on your phone) at the " + "door to be scanned in." + ) + return "\n".join(lines) -def _ticket_email_html_message(ticket: Ticket, base_message: str) -> str: - text_message = _ticket_delivery_message(ticket, base_message) +def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: + return ( + f"{base_message}\n\n{_ticket_details(ticket, event)}" + f"\n\nTicket image: {_ticket_image_url(ticket)}" + ) + + +def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: + text_message = _ticket_delivery_message(ticket, event, base_message) html_message = f"

{escape(text_message).replace(chr(10), '
')}

" image_url = escape(_ticket_image_url(ticket), quote=True) return ( @@ -131,8 +154,8 @@ def _ticket_email_html_message(ticket: Ticket, base_message: str) -> str: def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: subject, base_message = _ticket_notification_message(ticket, event) - text_message = _ticket_delivery_message(ticket, base_message) - html_message = _ticket_email_html_message(ticket, base_message) + text_message = _ticket_delivery_message(ticket, event, base_message) + html_message = _ticket_email_html_message(ticket, event, base_message) return subject, text_message, html_message @@ -221,14 +244,7 @@ async def _send_ticket_email_notification( if not is_valid_email_address(address): raise ValueError(f"Invalid email address: {address}") - msg = MIMEMultipart("alternative") - msg["From"] = from_email - msg["To"] = ", ".join(to_emails) - msg["Subject"] = subject - msg.attach(MIMEText(message, "plain")) - if html_message: - msg.attach(MIMEText(html_message, "html")) - + msg = build_ticket_email(from_email, to_emails, subject, message, html_message) username = settings.lnbits_email_notifications_username or from_email await asyncio.to_thread( _smtp_send, @@ -242,6 +258,31 @@ async def _send_ticket_email_notification( ) +def build_ticket_email( + from_email: str, + to_emails: list[str], + subject: str, + message: str, + html_message: str | None = None, +) -> MIMEMultipart: + """Assemble the multipart ticket email with the headers receivers score + on: a Date and a Message-ID (their absence is what SpamAssassin's + MISSING_DATE / MISSING_MID flag, and what Gmail/Outlook read as + machine-generated), and a display name on From so the sender is not a + bare address.""" + msg = MIMEMultipart("alternative") + sender_name = (settings.lnbits_site_title or "").strip() or "Tickets" + msg["From"] = formataddr((sender_name, from_email)) + msg["To"] = ", ".join(to_emails) + msg["Subject"] = subject + msg["Date"] = formatdate(localtime=True) + msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1]) + msg.attach(MIMEText(message, "plain")) + if html_message: + msg.attach(MIMEText(html_message, "html")) + return msg + + def _smtp_send( server: str, port: int, diff --git a/tests/test_ticket_email.py b/tests/test_ticket_email.py new file mode 100644 index 0000000..f33d670 --- /dev/null +++ b/tests/test_ticket_email.py @@ -0,0 +1,69 @@ +from datetime import datetime, timezone + +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", "

html

" + ) + assert msg["Date"] + assert msg["Message-ID"].endswith("@example.org>") + assert msg["From"] == "Oyez! " + parts = [p.get_content_type() for p in msg.get_payload()] + assert parts == ["text/plain", "text/html"] + + +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/qr/tkt1" in text + assert "