From f835766935808257c6b4b05477eaed569111f50e Mon Sep 17 00:00:00 2001 From: Padreug Date: Thu, 10 Sep 2026 13:43:53 +0200 Subject: [PATCH 1/2] feat(admin): event form parity with the webapp (1.6.1-aio.13) The LNbits admin form lagged the webapp's CreateEventDialog: - Payment methods never rendered. c2d9a96 wired the template to `paymentMethodOptions` / `acceptsFiat` but never defined them, so the q-option-group got `options=undefined` and the fiat-currency select was gated on `undefined`. Rails are now two q-checkboxes; Card is disabled with an explanatory tooltip when `g.user.fiat_providers` is empty (same rule as the webapp) and names the providers otherwise. - Location (NIP-52 `location` tag) and Categories (NIP-52 `t` tags, same 25-item list as the webapp's category.ts) were missing from the form even though the model, CRUD and publisher already carry them. - Datetimes are stamped with the browser's UTC offset on submit, as the webapp does; `_to_unix` treats naive values as UTC, so 18:00 CEST entered here went out on Nostr as 18:00 UTC. Table columns render "YYYY-MM-DD HH:MM" instead of the raw ISO string. - Validation: title + start date required, end >= start on the folded date+time, fiat currency required when a sat-priced event accepts card. Create is enabled once wallet + title + start are set; info, closing date, tickets and price were all effectively required before because the disable check compared undefined fields to null. - Labels follow the payment-rails vocabulary: "Unit" -> "Price currency", "Fiat checkout currency" -> "Fiat currency"; ticket closing date and end date explain their defaults. - A fiat-priced event mirrors `fiat_currency = currency` on save so the payload and the `tickets_fiat_currency` tag stay coherent. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_018b1bDExMX7W3a47wcgFUjb --- config.json | 2 +- static/js/index.js | 136 +++++++++++++++++++++++++++++++++++++---- static/js/index.vue | 143 ++++++++++++++++++++++++++++++-------------- 3 files changed, 222 insertions(+), 59 deletions(-) diff --git a/config.json b/config.json index 68e908a..6240778 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.10", + "version": "1.6.1-aio.13", "name": "Events", "repo": "https://git.atitlan.io/aiolabs/events", "short_description": "Sell and register event tickets", diff --git a/static/js/index.js b/static/js/index.js index 646b37b..40f98a4 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -67,13 +67,15 @@ window.PageEvents = { name: 'event_start_date', align: 'left', label: 'Start date', - field: 'event_start_date' + field: 'event_start_date', + format: val => this.formatEventDate(val) }, { name: 'event_end_date', align: 'left', label: 'End date', - field: 'event_end_date' + field: 'event_end_date', + format: val => this.formatEventDate(val) }, { name: 'closing_date', @@ -149,12 +151,58 @@ window.PageEvents = { rowsPerPage: 10 } }, + // Rails an organizer can enable per event. Mirrors the webapp's + // CreateEventDialog; `fiat` is rendered disabled when the LNbits user + // has no fiat provider (see `hasFiatProvider`). + paymentMethodOptions: [ + { + value: 'lightning', + label: 'Lightning', + hint: 'Pay with any Lightning wallet' + }, + { + value: 'fiat', + label: 'Card', + hint: 'Card or bank through your configured fiat provider' + } + ], + // Same list the webapp offers (src/modules/events/types/category.ts); + // published as NIP-52 `t` tags so both clients filter on one vocabulary. + categoryOptions: [ + 'concert', + 'workshop', + 'market', + 'festival', + 'exhibition', + 'sport', + 'theater', + 'cinema', + 'party', + 'talk', + 'conference', + 'meetup', + 'food', + 'outdoor', + 'kids', + 'wellness', + 'technology', + 'art', + 'music', + 'dance', + 'literature', + 'comedy', + 'charity', + 'tradition', + 'other' + ].map(c => ({label: c.charAt(0).toUpperCase() + c.slice(1), value: c})), formDialog: { show: false, data: { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', + location: '', + categories: [], extra: { payment_methods: ['lightning'], promo_codes: [], @@ -165,6 +213,27 @@ window.PageEvents = { } } }, + computed: { + hasFiatProvider() { + return (this.g.user?.fiat_providers || []).length > 0 + }, + fiatProviderNames() { + return (this.g.user?.fiat_providers || []) + .map(p => p.charAt(0).toUpperCase() + p.slice(1)) + .join(', ') + }, + acceptsFiat() { + return (this.formDialog.data.extra?.payment_methods || []).includes( + 'fiat' + ) + }, + isSatPrice() { + return !this.isFiatCurrency(this.formDialog.data.currency) + }, + fiatCurrencyOptions() { + return this.currencies.filter(c => this.isFiatCurrency(c)) + } + }, methods: { isFiatCurrency(currency) { return !['sat', 'sats'].includes((currency || '').toLowerCase()) @@ -382,29 +451,62 @@ window.PageEvents = { }, splitDateTime(value) { // Inverse of foldDateTime: split a stored string back into the - // day/time pieces the form inputs bind to. + // day/time pieces the form inputs bind to. Slicing to HH:MM also + // drops the seconds + offset suffix withLocalTzOffset stamps on + // submit, so the organizer sees the wall-clock they entered. if (!value) return {day: '', time: ''} const [day, time = ''] = value.split('T') // Time inputs only accept HH:MM, drop any seconds we stored. return {day, time: time.slice(0, 5)} }, + withLocalTzOffset(value) { + // Stamp the browser's UTC offset on a "YYYY-MM-DDTHH:MM" value. + // The publisher's `_to_unix` treats a naive datetime as UTC, so an + // event entered as 18:00 in CEST would otherwise go out on Nostr + // as 18:00 UTC. Same transform the webapp applies; date-only + // values pass through unchanged (they map to NIP-52 kind 31922). + if (!value || !value.includes('T')) return value + const offMin = -new Date(value).getTimezoneOffset() + const sign = offMin >= 0 ? '+' : '-' + const abs = Math.abs(offMin) + const hh = String(Math.floor(abs / 60)).padStart(2, '0') + const mm = String(abs % 60).padStart(2, '0') + return `${value}:00${sign}${hh}:${mm}` + }, + formatEventDate(value) { + // Table display: "YYYY-MM-DD" or "YYYY-MM-DD HH:MM". + if (!value) return '' + const {day, time} = this.splitDateTime(value) + return time ? `${day} ${time}` : day + }, + validateEndDate() { + // Cross-field rule for the end-day input: end >= start, compared + // on the folded date+time so an equal-day earlier time is caught. + const d = this.formDialog.data + const start = this.foldDateTime(d.event_start_day, d.event_start_time) + const end = this.foldDateTime(d.event_end_day, d.event_end_time) + if (!start || !end) return true + return end >= start || 'End must be on or after start' + }, sendEventData() { const wallet = _.findWhere(this.g.user.wallets, { id: this.formDialog.data.wallet }) const data = {...this.formDialog.data} - data.event_start_date = this.foldDateTime( - data.event_start_day, - data.event_start_time + data.event_start_date = this.withLocalTzOffset( + this.foldDateTime(data.event_start_day, data.event_start_time) ) - data.event_end_date = this.foldDateTime( - data.event_end_day, - data.event_end_time + data.event_end_date = this.withLocalTzOffset( + this.foldDateTime(data.event_end_day, data.event_end_time) ) delete data.event_start_day delete data.event_start_time delete data.event_end_day delete data.event_end_time + // Optional NIP-52 fields: blank location is "unset", not "". + data.location = (data.location || '').trim() || null + data.categories = data.categories || [] + data.closing_date = data.closing_date || null if (data.extra?.promo_codes) { data.extra.promo_codes = data.extra.promo_codes @@ -425,10 +527,12 @@ window.PageEvents = { // allow_fiat stays the fiat-currency carrier the backend and the // NIP-52 tags read; keep it in lockstep with the checkbox list. data.allow_fiat = methods.includes('fiat') - if (!this.isFiatCurrency(data.currency)) { - if (!data.allow_fiat) { - data.fiat_currency = 'GBP' - } + if (this.isFiatCurrency(data.currency)) { + // A fiat-priced event settles in its price currency; mirror it so + // the payload (and the tickets_fiat_currency tag) stay coherent. + data.fiat_currency = data.currency + } else if (!data.allow_fiat) { + data.fiat_currency = 'GBP' } if (data.id) { @@ -453,6 +557,8 @@ window.PageEvents = { ? data.extra.payment_methods : ['lightning', ...(data.allow_fiat ? ['fiat'] : [])] }, + location: data.location || '', + categories: [...(data.categories || [])], event_start_day: start.day, event_start_time: start.time, event_end_day: end.day, @@ -463,6 +569,8 @@ window.PageEvents = { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', + location: '', + categories: [], event_start_day: '', event_start_time: '', event_end_day: '', @@ -487,6 +595,8 @@ window.PageEvents = { currency: 'sats', allow_fiat: false, fiat_currency: 'GBP', + location: '', + categories: [], extra: { payment_methods: ['lightning'], email_notifications: false, diff --git a/static/js/index.vue b/static/js/index.vue index 502d9ad..6e7ec28 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -479,8 +479,10 @@ filled dense v-model.trim="formDialog.data.name" - type="name" - label="Title of event " + type="text" + label="Title of event *" + lazy-rules + :rules="[val => !!val || 'Title is required']" >
@@ -504,6 +506,30 @@ label="Info about the event" hint="Markdown supported" > + + + + -
-
Ticket closing date
-
- -
-
-
Event begins
+
Event begins *
@@ -552,6 +569,10 @@ dense v-model.trim="formDialog.data.event_end_day" type="date" + hint="Defaults to the start date" + lazy-rules + reactive-rules + :rules="[validateEndDate]" >
@@ -564,6 +585,18 @@ >
+
+
Ticket sales close
+
+ +
+
@@ -581,7 +614,9 @@ dense v-model.number="formDialog.data.amount_tickets" type="number" - label="Amount of tickets " + min="0" + label="Amount of tickets" + hint="0 = unlimited" >
@@ -600,34 +635,57 @@
-
Payment methods *
- +
Payment methods *
- Card / fiat checkout goes through the fiat provider configured on - this LNbits instance. Untick Lightning for a card-only event. + Pick the rails buyers can pay with. Untick Lightning for a + card-only sale. +
+
+
+ + + +
+ + +
+ + Your LNbits user has no fiat provider configured. Ask the + instance admin to enable Stripe, PayPal or Square (Admin → + Fiat providers) to accept card payments. + +
+
Create Event Date: Wed, 9 Sep 2026 12:34:19 +0200 Subject: [PATCH 2/2] feat: per-event organizer name + reply-to on ticket emails (1.6.1-aio.14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each organizer is different, so the sender identity of ticket emails is now per event rather than per instance: - `extra.organizer_name` → From display name "Organizer via " (site title alone when unset). The From address stays the instance mailbox — that is what DKIM signs — so this costs nothing in mail auth. - `extra.reply_to_email` → Reply-To; blank falls back to the email on the LNbits account that owns the event wallet; no header when neither exists. When a reply-to exists the body says "Questions? Reply to this email and it reaches ", and "Organizer: " is listed with the ticket details. Both fields sit next to the existing per-event subject/body in the admin dialog. The organizer's own wording (in whatever language) remains `notification_body`. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EYwoAkZZmXMMmaBp4WGUBo --- README.md | 5 ++- config.json | 2 +- docs/upstream-candidates.md | 27 ++++++------ models.py | 14 ++++++ services.py | 88 ++++++++++++++++++++++++++++++++----- static/js/index.js | 6 +++ static/js/index.vue | 22 ++++++++++ tests/test_ticket_email.py | 56 +++++++++++++++++++++++ 8 files changed, 194 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index cd02134..9aa5b4d 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,10 @@ Events includes a shareable ticket scanner, which can be used to register attend instance logo, name on ticket, ticket id) also served at `GET /events/api/v1/ticket-card/{ticket_id}`; the bare QR stays at `GET /events/api/v1/qr/{ticket_id}`. Headers carry Date, Message-ID and a - From display name (site title). `POST /events/api/v1/tickets/{ticket_id}/resend-email` + From display name — `extra.organizer_name` gives "Organizer via ", + otherwise the site title. Replies go to `extra.reply_to_email`, falling back + to the event owner's account email (no Reply-To when neither exists). The + From _address_ is always the instance mailbox, which is what DKIM signs. `POST /events/api/v1/tickets/{ticket_id}/resend-email` returns a `TicketResendResult` with per-channel outcome. ## Powered by LNbits diff --git a/config.json b/config.json index 6240778..d2d5cb7 100644 --- a/config.json +++ b/config.json @@ -1,6 +1,6 @@ { "id": "events", - "version": "1.6.1-aio.13", + "version": "1.6.1-aio.14", "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 93e6c2f..b5b06d3 100644 --- a/docs/upstream-candidates.md +++ b/docs/upstream-candidates.md @@ -4,16 +4,17 @@ 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 | -| `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 | -| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.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 | +| Ticket card PNG (event/when/where/QR/name/id) attached to the ticket email instead of a remote `` (`qr.py`, `GET /api/v1/ticket-card/{id}`) | `qr.py`, `services.py` | lnbits/events (their "ticket image" compositing could reuse the renderer) | standalone; mail-tester: removes HTML_IMAGE_ONLY (1.8) | +| Per-event sender identity: `extra.organizer_name` (From display name "X via ") + `extra.reply_to_email` (Reply-To, falls back to owner account email) | `models.py`, `services.py`, admin dialog | lnbits/events | small, standalone | diff --git a/models.py b/models.py index e36c60a..9a24822 100644 --- a/models.py +++ b/models.py @@ -31,6 +31,20 @@ class EventExtra(BaseModel): nostr_notifications: bool = False notification_subject: str = "" notification_body: str = "" + # Sender identity for ticket emails, per event: the From display name + # becomes " via " and replies go to + # `reply_to_email` (falls back to the event owner's account email). + # The From *address* stays the instance mailbox — that is what DKIM + # signs — so organizers get a human sender without touching mail auth. + organizer_name: str = "" + reply_to_email: EmailStr | None = None + + @validator("reply_to_email", pre=True) + def blank_reply_to_is_none(cls, v): + if isinstance(v, str) and not v.strip(): + return None + return v + # Rails the organizer accepts for this event. Empty = legacy rule # ("lightning" always, "fiat" when allow_fiat) — see # `effective_payment_methods`. Same field name/shape as upstream v2 so the diff --git a/services.py b/services.py index 9e6d1ce..2d2644b 100644 --- a/services.py +++ b/services.py @@ -10,6 +10,8 @@ from email.mime.text import MIMEText from email.utils import formataddr, formatdate, make_msgid from html import escape +from lnbits.core.crud import get_user +from lnbits.core.crud.wallets import get_wallet from lnbits.core.models.users import UserNotifications from lnbits.core.services.nostr import send_nostr_dm from lnbits.core.services.notifications import send_user_notification @@ -138,22 +140,38 @@ def _ticket_details(ticket: Ticket, event: Event) -> str: "or open the link above on your phone, and show the QR code at the " "door to be scanned in." ) + if event.extra.organizer_name: + lines.append(f"Organizer: {event.extra.organizer_name}") return "\n".join(lines) -def _ticket_delivery_message(ticket: Ticket, event: Event, base_message: str) -> str: +def _ticket_details_with_reply_hint( + ticket: Ticket, event: Event, reply_to: str | None +) -> str: + details = _ticket_details(ticket, event) + if reply_to: + who = event.extra.organizer_name or "the organizer" + details += f"\nQuestions? Reply to this email and it reaches {who}." + return details + + +def _ticket_delivery_message( + ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None +) -> str: return ( - f"{base_message}\n\n{_ticket_details(ticket, event)}" + f"{base_message}\n\n{_ticket_details_with_reply_hint(ticket, event, reply_to)}" f"\n\nTicket image: {_ticket_image_url(ticket)}" ) -def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) -> str: +def _ticket_email_html_message( + ticket: Ticket, event: Event, base_message: str, reply_to: str | None = None +) -> str: """HTML twin of the text part. Deliberately no : the card travels as an attachment (renders inline in most clients, works offline, and keeps SpamAssassin's HTML_IMAGE_ONLY rules quiet), and URLs become links.""" - text_message = _ticket_delivery_message(ticket, event, base_message) + text_message = _ticket_delivery_message(ticket, event, base_message, reply_to) html = escape(text_message) html = re.sub( r"(https?://[^\s<]+)", @@ -163,13 +181,41 @@ def _ticket_email_html_message(ticket: Ticket, event: Event, base_message: str) return f"

{html.replace(chr(10), '
')}

" -def _ticket_notification_payload(ticket: Ticket, event: Event) -> tuple[str, str, str]: +def _ticket_notification_payload( + ticket: Ticket, event: Event, reply_to: str | None = None +) -> tuple[str, str, str]: subject, base_message = _ticket_notification_message(ticket, event) - text_message = _ticket_delivery_message(ticket, event, base_message) - html_message = _ticket_email_html_message(ticket, event, base_message) + text_message = _ticket_delivery_message(ticket, event, base_message, reply_to) + html_message = _ticket_email_html_message(ticket, event, base_message, reply_to) return subject, text_message, html_message +async def organizer_reply_to(event: Event) -> str | None: + """Where replies to a ticket email should go: the event's explicit + `reply_to_email`, else the email on the LNbits account that owns the + event wallet, else nothing (no Reply-To header).""" + if event.extra.reply_to_email: + return str(event.extra.reply_to_email) + try: + wallet = await get_wallet(event.wallet) + if not wallet: + return None + user = await get_user(wallet.user) + email = (user.email or "").strip() if user else "" + return email if is_valid_email_address(email) else None + except Exception as exc: + logger.warning(f"Could not resolve organizer email for {event.id}: {exc}") + return None + + +def organizer_sender_name(event: Event) -> str: + """From display name: " via " when the event + names an organizer, else the site title.""" + site = (settings.lnbits_site_title or "").strip() or "Tickets" + organizer = (event.extra.organizer_name or "").strip() + return f"{organizer} via {site}" if organizer else site + + async def _deliver_ticket_notifications( ticket: Ticket, event: Event, @@ -180,7 +226,10 @@ async def _deliver_ticket_notifications( """Send the ticket by every configured channel and report per-channel outcome (upstream v1.6.8 shape). `email` / `nostr` override the event's opt-ins when not None; the instance-level prerequisites always apply.""" - subject, text_message, html_message = _ticket_notification_payload(ticket, event) + reply_to = await organizer_reply_to(event) + subject, text_message, html_message = _ticket_notification_payload( + ticket, event, reply_to + ) updated = False email_wanted = event.extra.email_notifications if email is None else email @@ -220,6 +269,8 @@ async def _deliver_ticket_notifications( attachments=[ (ticket_card_filename(ticket, event), image_png_bytes(card)) ], + reply_to=reply_to, + sender_name=organizer_sender_name(event), ) ticket.extra.email_notification_sent = True result.email.sent = True @@ -251,6 +302,8 @@ async def _send_ticket_email_notification( subject: str, html_message: str | None = None, attachments: list[tuple[str, bytes]] | None = None, + reply_to: str | None = None, + sender_name: 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 @@ -269,7 +322,14 @@ async def _send_ticket_email_notification( raise ValueError(f"Invalid email address: {address}") msg = build_ticket_email( - from_email, to_emails, subject, message, html_message, attachments + from_email, + to_emails, + subject, + message, + html_message, + attachments, + reply_to=reply_to, + sender_name=sender_name, ) username = settings.lnbits_email_notifications_username or from_email await asyncio.to_thread( @@ -291,6 +351,8 @@ def build_ticket_email( message: str, html_message: str | None = None, attachments: list[tuple[str, bytes]] | None = None, + reply_to: str | None = None, + sender_name: str | None = None, ) -> MIMEMultipart: """Assemble the ticket email: text + HTML alternatives, PNG attachments (the ticket card), and the headers receivers score on — a Date and a @@ -312,9 +374,13 @@ def build_ticket_email( else: msg = body - sender_name = (settings.lnbits_site_title or "").strip() or "Tickets" - msg["From"] = formataddr((sender_name, from_email)) + display_name = ( + sender_name or settings.lnbits_site_title or "" + ).strip() or "Tickets" + msg["From"] = formataddr((display_name, from_email)) msg["To"] = ", ".join(to_emails) + if reply_to and is_valid_email_address(reply_to): + msg["Reply-To"] = reply_to msg["Subject"] = subject msg["Date"] = formatdate(localtime=True) msg["Message-ID"] = make_msgid(domain=from_email.rsplit("@", 1)[-1]) diff --git a/static/js/index.js b/static/js/index.js index 40f98a4..b882497 100644 --- a/static/js/index.js +++ b/static/js/index.js @@ -206,6 +206,8 @@ window.PageEvents = { extra: { payment_methods: ['lightning'], promo_codes: [], + organizer_name: '', + reply_to_email: '', notification_subject: '', notification_body: '' } @@ -582,6 +584,8 @@ window.PageEvents = { email_notifications: false, nostr_notifications: false, promo_codes: [], + organizer_name: '', + reply_to_email: '', notification_subject: '', notification_body: '' } @@ -602,6 +606,8 @@ window.PageEvents = { email_notifications: false, nostr_notifications: false, promo_codes: [], + organizer_name: '', + reply_to_email: '', notification_subject: '', notification_body: '' } diff --git a/static/js/index.vue b/static/js/index.vue index 6e7ec28..19b2227 100644 --- a/static/js/index.vue +++ b/static/js/index.vue @@ -823,6 +823,28 @@ label="Ticket notification body" hint="Shown before the ticket link in the paid ticket notification." > +
+
+ +
+
+ +
+
' 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! " + ) + + 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"