fix: Date/Message-ID/From-name on ticket emails, richer body (v1.6.1-aio.9) #42
4 changed files with 137 additions and 26 deletions
fix: Date/Message-ID/From-name on ticket emails, richer body (1.6.1-aio.9)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo
commit
3a8e0ff591
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"id": "events",
|
"id": "events",
|
||||||
"version": "1.6.1-aio.8",
|
"version": "1.6.1-aio.9",
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
`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.
|
in an upstream-compatible form; strike it when the PR merges upstream.
|
||||||
|
|
||||||
| Feature | Where | Upstream target | Readiness |
|
| 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 |
|
| `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 |
|
| 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.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 |
|
| `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 |
|
| `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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
|
||||||
69
services.py
69
services.py
|
|
@ -5,6 +5,7 @@ import smtplib
|
||||||
from asyncio.tasks import create_task
|
from asyncio.tasks import create_task
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
from email.utils import formataddr, formatdate, make_msgid
|
||||||
from html import escape
|
from html import escape
|
||||||
|
|
||||||
from lnbits.core.models.users import UserNotifications
|
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}"
|
return subject, f"{body}\n\nOpen it here: {ticket_url}"
|
||||||
|
|
||||||
|
|
||||||
def _ticket_delivery_message(ticket: Ticket, base_message: str) -> str:
|
def _ticket_details(ticket: Ticket, event: Event) -> str:
|
||||||
return f"{base_message}\n\nTicket image: {_ticket_image_url(ticket)}"
|
"""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:
|
def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str:
|
||||||
text_message = _ticket_delivery_message(ticket, base_message)
|
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"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
|
html_message = f"<p>{escape(text_message).replace(chr(10), '<br />')}</p>"
|
||||||
image_url = escape(_ticket_image_url(ticket), quote=True)
|
image_url = escape(_ticket_image_url(ticket), quote=True)
|
||||||
return (
|
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]:
|
def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]:
|
||||||
subject, base_message = _ticket_notification_message(ticket, event)
|
subject, base_message = _ticket_notification_message(ticket, event)
|
||||||
text_message = _ticket_delivery_message(ticket, base_message)
|
text_message = _ticket_delivery_message(ticket, event, base_message)
|
||||||
html_message = _ticket_email_html_message(ticket, base_message)
|
html_message = _ticket_email_html_message(ticket, event, base_message)
|
||||||
return subject, text_message, html_message
|
return subject, text_message, html_message
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -221,14 +244,7 @@ async def _send_ticket_email_notification(
|
||||||
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}")
|
||||||
|
|
||||||
msg = MIMEMultipart("alternative")
|
msg = build_ticket_email(from_email, to_emails, subject, message, html_message)
|
||||||
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"))
|
|
||||||
|
|
||||||
username = settings.lnbits_email_notifications_username or from_email
|
username = settings.lnbits_email_notifications_username or from_email
|
||||||
await asyncio.to_thread(
|
await asyncio.to_thread(
|
||||||
_smtp_send,
|
_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(
|
def _smtp_send(
|
||||||
server: str,
|
server: str,
|
||||||
port: int,
|
port: int,
|
||||||
|
|
|
||||||
69
tests/test_ticket_email.py
Normal file
69
tests/test_ticket_email.py
Normal file
|
|
@ -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", "<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_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 "<img" in html and "Ticket ID: tkt1" in html
|
||||||
Loading…
Add table
Add a link
Reference in a new issue