Skip to content

Commit 46f9300

Browse files
committed
fix(paid-events): email tickets after payment and enrich purchase email
Paid orders never delivered the ticket email promised by the purchase confirmation. After payment, send the buyer one email with every ticket (inline QR + attached PDF) and an ICS invite. Align the free registration flow to send to the buyer only, since a QR is an entry token that must not be sent to attendee-typed emails. Enrich the purchase email with an order summary and the community seller/legal details. Also: render plain-text mail templates with text/template (no HTML escaping); send the ticket email off the request path since PDF generation can be slow for large orders; skip inline QR above 25 tickets to keep message size sane; encode each QR once and reuse it for the PDF and the inline image.
1 parent 3bb8094 commit 46f9300

15 files changed

Lines changed: 636 additions & 129 deletions

backend/broadcast_event.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ func (s *ZenaoServer) BroadcastEvent(
100100
attachments := make([]*resend.Attachment, 0, len(tickets))
101101
if req.Msg.AttachTicket {
102102
for i, ticket := range tickets[authParticipant.ID] {
103-
pdfData, err := GeneratePDFTicket(evt, ticket.Ticket.Secret(), ticket.User.DisplayName, authParticipant.Email, ticket.CreatedAt, s.Logger)
103+
pdfData, err := GeneratePDFTicket(evt, ticket.Ticket.Secret(), ticket.User.DisplayName, authParticipant.Email, ticket.CreatedAt, nil, s.Logger)
104104
if err != nil {
105105
s.Logger.Error("generate-ticket-pdf", zap.Error(err), zap.String("ticket-id", ticket.Ticket.Secret()))
106106
return nil, err

backend/confirm_ticket_payment.go

Lines changed: 165 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"context"
55
"errors"
6+
"fmt"
67
"strings"
78
"time"
89

@@ -141,6 +142,165 @@ func (s *ZenaoServer) issueTicketsAfterConfirmation(ctx context.Context, order *
141142
zap.String("order-id", order.ID),
142143
zap.Int("issued-count", issuedCount),
143144
)
145+
146+
// Tickets just transitioned to "issued": this block runs exactly once per
147+
// order (the early-return above guards re-entry), so it is safe to deliver
148+
// the ticket email here without resending it on every confirm poll.
149+
//
150+
// Building the email generates a PDF per ticket and can be slow for large
151+
// orders, so it runs off the request path. The issued transition is already
152+
// committed, so the confirm endpoint can return immediately. A detached
153+
// context keeps the send alive after the request context is cancelled.
154+
emailCtx := context.WithoutCancel(issueCtx)
155+
go func() {
156+
if err := s.sendOrderTicketsEmail(emailCtx, order); err != nil {
157+
s.Logger.Error("send-order-tickets-email", zap.Error(err), zap.String("order-id", order.ID))
158+
}
159+
}()
160+
}
161+
162+
// sendOrderTicketsEmail delivers a single email to the buyer with every ticket
163+
// of the order: each one as an inline QR code in the body and as an attached
164+
// PDF, plus an ICS calendar invite. The QR codes (entry tokens) go only to the
165+
// buyer, never to attendee-provided emails, following standard ticketing
166+
// practice (the buyer paid and controls distribution).
167+
func (s *ZenaoServer) sendOrderTicketsEmail(ctx context.Context, order *zeni.Order) error {
168+
if s.MailClient == nil || s.Auth == nil || order == nil {
169+
return nil
170+
}
171+
172+
buyerEmail, err := s.userEmail(ctx, order.BuyerID)
173+
if err != nil {
174+
return err
175+
}
176+
177+
tickets, err := s.DB.WithContext(ctx).GetOrderTickets(order.ID)
178+
if err != nil {
179+
return err
180+
}
181+
if len(tickets) == 0 {
182+
return errors.New("order has no tickets")
183+
}
184+
185+
evt, err := s.DB.WithContext(ctx).GetEvent(order.EventID)
186+
if err != nil {
187+
return err
188+
}
189+
if evt == nil {
190+
return errors.New("event not found")
191+
}
192+
193+
// Resolve attendee emails to label each ticket inside the buyer's own email.
194+
authIDs := make([]string, 0, len(tickets))
195+
for _, ticket := range tickets {
196+
if ticket == nil || ticket.User == nil || strings.TrimSpace(ticket.User.AuthID) == "" {
197+
continue
198+
}
199+
authIDs = append(authIDs, ticket.User.AuthID)
200+
}
201+
authUsers, err := s.Auth.GetUsersFromIDs(ctx, authIDs)
202+
if err != nil {
203+
return err
204+
}
205+
emailByAuthID := make(map[string]string, len(authUsers))
206+
for _, user := range authUsers {
207+
if user != nil {
208+
emailByAuthID[user.ID] = user.Email
209+
}
210+
}
211+
212+
items := make([]ticketEmailItem, 0, len(tickets))
213+
for _, ticket := range tickets {
214+
if ticket == nil || ticket.Ticket == nil {
215+
continue
216+
}
217+
email := ""
218+
displayName := ""
219+
if ticket.User != nil {
220+
email = emailByAuthID[ticket.User.AuthID]
221+
displayName = ticket.User.DisplayName
222+
}
223+
items = append(items, ticketEmailItem{
224+
Secret: ticket.Ticket.Secret(),
225+
DisplayName: displayName,
226+
Email: email,
227+
})
228+
}
229+
230+
qrs, attachments, err := buildTicketEmailAttachments(evt, items, s.MailSender, s.Logger)
231+
if err != nil {
232+
return err
233+
}
234+
235+
htmlStr, text, err := ticketsConfirmationMailContent(evt, "Your tickets are attached and shown below.", qrs)
236+
if err != nil {
237+
return err
238+
}
239+
240+
tracer := otel.Tracer("mail")
241+
mailCtx, span := tracer.Start(ctx, "mail.OrderTickets", trace.WithSpanKind(trace.SpanKindClient))
242+
defer span.End()
243+
244+
// XXX: Replace sender name with organizer name
245+
_, err = s.MailClient.Emails.SendWithContext(mailCtx, &resend.SendEmailRequest{
246+
From: fmt.Sprintf("Zenao <%s>", s.MailSender),
247+
To: []string{buyerEmail},
248+
Subject: fmt.Sprintf("%s - Your tickets", evt.Title),
249+
Html: htmlStr,
250+
Text: text,
251+
Attachments: attachments,
252+
})
253+
return err
254+
}
255+
256+
// resolvePaymentSeller builds the merchant-of-record details shown to the buyer.
257+
// It prefers the Stripe business profile mirrored on the payment account and
258+
// falls back to the community display name.
259+
func (s *ZenaoServer) resolvePaymentSeller(ctx context.Context, order *zeni.Order) paymentSeller {
260+
seller := paymentSeller{}
261+
262+
if communities, err := s.DB.WithContext(ctx).CommunitiesByEvent(order.EventID); err != nil {
263+
s.Logger.Error("resolve-payment-seller", zap.Error(err), zap.String("order-id", order.ID))
264+
} else if len(communities) > 0 && communities[0] != nil {
265+
seller.Name = communities[0].DisplayName
266+
}
267+
268+
account, err := s.DB.WithContext(ctx).GetOrderPaymentAccount(order.ID)
269+
if err != nil {
270+
s.Logger.Error("resolve-payment-seller", zap.Error(err), zap.String("order-id", order.ID))
271+
return seller
272+
}
273+
if account != nil {
274+
if name := strings.TrimSpace(account.BusinessName); name != "" {
275+
seller.Name = name
276+
} else if name := strings.TrimSpace(account.LegalName); name != "" {
277+
seller.Name = name
278+
}
279+
seller.SupportEmail = strings.TrimSpace(account.SupportEmail)
280+
seller.Address = strings.TrimSpace(account.BusinessAddress)
281+
}
282+
283+
return seller
284+
}
285+
286+
// userEmail resolves a Zenao user ID to its authenticated email address.
287+
func (s *ZenaoServer) userEmail(ctx context.Context, userID string) (string, error) {
288+
users, err := s.DB.WithContext(ctx).GetUsersByIDs([]string{userID})
289+
if err != nil {
290+
return "", err
291+
}
292+
if len(users) == 0 || users[0] == nil || strings.TrimSpace(users[0].AuthID) == "" {
293+
return "", errors.New("user auth id not found")
294+
}
295+
296+
authUsers, err := s.Auth.GetUsersFromIDs(ctx, []string{users[0].AuthID})
297+
if err != nil {
298+
return "", err
299+
}
300+
if len(authUsers) == 0 || authUsers[0] == nil || strings.TrimSpace(authUsers[0].Email) == "" {
301+
return "", errors.New("user email not found")
302+
}
303+
return authUsers[0].Email, nil
144304
}
145305

146306
func (s *ZenaoServer) issueOrderTickets(ctx context.Context, order *zeni.Order) (int, error) {
@@ -257,21 +417,10 @@ func (s *ZenaoServer) sendPurchaseConfirmationEmail(ctx context.Context, order *
257417
return nil
258418
}
259419

260-
users, err := s.DB.WithContext(ctx).GetUsersByIDs([]string{order.BuyerID})
420+
buyerEmail, err := s.userEmail(ctx, order.BuyerID)
261421
if err != nil {
262422
return err
263423
}
264-
if len(users) == 0 || users[0] == nil || strings.TrimSpace(users[0].AuthID) == "" {
265-
return errors.New("buyer auth id not found")
266-
}
267-
268-
authUsers, err := s.Auth.GetUsersFromIDs(ctx, []string{users[0].AuthID})
269-
if err != nil {
270-
return err
271-
}
272-
if len(authUsers) == 0 || authUsers[0] == nil || strings.TrimSpace(authUsers[0].Email) == "" {
273-
return errors.New("buyer email not found")
274-
}
275424

276425
evt, err := s.DB.WithContext(ctx).GetEvent(order.EventID)
277426
if err != nil {
@@ -281,7 +430,9 @@ func (s *ZenaoServer) sendPurchaseConfirmationEmail(ctx context.Context, order *
281430
return errors.New("event not found")
282431
}
283432

284-
htmlStr, text, err := purchaseConfirmationMailContent(evt, "Purchase confirmed! Your tickets will arrive in a separate email.")
433+
seller := s.resolvePaymentSeller(ctx, order)
434+
435+
htmlStr, text, err := purchaseConfirmationMailContent(evt, order, seller, "Purchase confirmed! Your tickets will arrive in a separate email.")
285436
if err != nil {
286437
return err
287438
}
@@ -293,7 +444,7 @@ func (s *ZenaoServer) sendPurchaseConfirmationEmail(ctx context.Context, order *
293444
_, err = s.MailClient.Emails.SendWithContext(mailCtx, &resend.SendEmailRequest{
294445
// XXX: Replace sender name with organizer name
295446
From: "Zenao <" + s.MailSender + ">",
296-
To: []string{authUsers[0].Email},
447+
To: []string{buyerEmail},
297448
Subject: evt.Title + " - Purchase confirmed",
298449
Html: htmlStr,
299450
Text: text,

backend/confirm_ticket_payment_test.go

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"net/http/httptest"
99
"net/url"
1010
"sync"
11+
"sync/atomic"
1112
"testing"
1213
"time"
1314

@@ -166,10 +167,11 @@ func setupPaymentConfirmationFixtureWithAttendees(t *testing.T, attendeeEmails [
166167
return db, sqlDB, orderID, sessionID.String, checkoutAuth
167168
}
168169

169-
func newTestResendClient(t *testing.T) (*resend.Client, *int) {
170-
count := 0
170+
func newTestResendClient(t *testing.T) (*resend.Client, *atomic.Int64) {
171+
// atomic because ticket emails are sent from a background goroutine.
172+
count := &atomic.Int64{}
171173
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
172-
count++
174+
count.Add(1)
173175
w.Header().Set("Content-Type", "application/json")
174176
_, _ = w.Write([]byte(`{"id":"email_123"}`))
175177
}))
@@ -180,7 +182,7 @@ func newTestResendClient(t *testing.T) (*resend.Client, *int) {
180182
require.NoError(t, err)
181183
client.BaseURL = baseURL
182184

183-
return client, &count
185+
return client, count
184186
}
185187

186188
func TestConfirmTicketPaymentSuccessUpdatesOrderAndSendsEmailOnce(t *testing.T) {
@@ -229,7 +231,9 @@ func TestConfirmTicketPaymentSuccessUpdatesOrderAndSendsEmailOnce(t *testing.T)
229231
require.Equal(t, string(zeni.OrderStatusSuccess), status)
230232
require.Equal(t, "pi_test_123", intent.String)
231233
require.True(t, confirmed.Valid)
232-
require.Equal(t, 1, *sendCount)
234+
// 1 purchase confirmation email (sent synchronously) + 1 ticket email
235+
// bundling the order (sent from a background goroutine, hence Eventually).
236+
require.Eventually(t, func() bool { return sendCount.Load() == 2 }, 2*time.Second, 10*time.Millisecond)
233237

234238
_, err = server.ConfirmTicketPayment(
235239
context.Background(),
@@ -239,7 +243,8 @@ func TestConfirmTicketPaymentSuccessUpdatesOrderAndSendsEmailOnce(t *testing.T)
239243
}),
240244
)
241245
require.NoError(t, err)
242-
require.Equal(t, 1, *sendCount)
246+
// Re-confirming must not resend the purchase or ticket emails.
247+
require.Never(t, func() bool { return sendCount.Load() != 2 }, 200*time.Millisecond, 20*time.Millisecond)
243248
}
244249

245250
func TestConfirmTicketPaymentPendingSkipsEmail(t *testing.T) {
@@ -283,7 +288,7 @@ func TestConfirmTicketPaymentPendingSkipsEmail(t *testing.T) {
283288
require.NoError(t, row.Scan(&status, &confirmed))
284289
require.Equal(t, string(zeni.OrderStatusPending), status)
285290
require.False(t, confirmed.Valid)
286-
require.Equal(t, 0, *sendCount)
291+
require.Equal(t, int64(0), sendCount.Load())
287292
}
288293

289294
func TestConfirmTicketPaymentStripeErrorDoesNotUpdateOrder(t *testing.T) {

backend/create_event.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,7 @@ func (s *ZenaoServer) CreateEvent(
172172
webhook.TrySendDiscordMessage(s.Logger, s.DiscordToken, evt)
173173

174174
if s.MailClient != nil {
175-
htmlStr, text, err := ticketsConfirmationMailContent(evt, "Event created!")
175+
htmlStr, text, err := ticketsConfirmationMailContent(evt, "Event created!", nil)
176176
if err != nil {
177177
s.Logger.Error("generate-event-email-content", zap.Error(err), zap.String("event-id", evt.ID))
178178
} else {

backend/mail.go

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ package main
33
import (
44
"context"
55
_ "embed"
6+
"encoding/base64"
67
"fmt"
8+
"html/template"
9+
"os"
710
"strings"
811
"time"
912

@@ -43,12 +46,57 @@ func execMail() error {
4346
}
4447
evt.ID = "10"
4548

46-
str, _, err := ticketsConfirmationMailContent(evt, "Welcome! Tickets will be sent in a few weeks!")
49+
// For the browser preview, inline the QR codes as data URIs (real emails use
50+
// "cid:" references resolved from attachments, which a standalone HTML file
51+
// cannot display).
52+
previewTickets := make([]ticketQR, 0, 2)
53+
for i, secret := range []string{"PREVIEW-TICKET-SECRET-1", "PREVIEW-TICKET-SECRET-2"} {
54+
png, err := qrCodePNG(secret, ticketEmailQRSize)
55+
if err != nil {
56+
return err
57+
}
58+
previewTickets = append(previewTickets, ticketQR{
59+
Src: template.URL("data:image/png;base64," + base64.StdEncoding.EncodeToString(png)),
60+
Label: fmt.Sprintf("attendee%d@example.com", i+1),
61+
})
62+
}
63+
64+
ticketsHTML, _, err := ticketsConfirmationMailContent(evt, "Your tickets are attached and shown below.", previewTickets)
4765
if err != nil {
4866
return err
4967
}
5068

51-
fmt.Println(str)
69+
order := &zeni.Order{
70+
ID: "ord_9f3a1c7e",
71+
EventID: evt.ID,
72+
AmountMinor: 5000,
73+
CurrencyCode: "EUR",
74+
}
75+
purchaseHTML, purchaseText, err := purchaseConfirmationMailContent(
76+
evt,
77+
order,
78+
paymentSeller{
79+
Name: "Ground Control Collective",
80+
SupportEmail: "support@groundcontrol.example",
81+
Address: "12 Rue du Charolais, 75012, Paris, FR",
82+
},
83+
"Purchase confirmed! Your tickets will arrive in a separate email.",
84+
)
85+
if err != nil {
86+
return err
87+
}
88+
89+
previews := map[string]string{
90+
"/tmp/zenao-mail-tickets.html": ticketsHTML,
91+
"/tmp/zenao-mail-purchase.html": purchaseHTML,
92+
"/tmp/zenao-mail-purchase.txt": purchaseText,
93+
}
94+
for path, content := range previews {
95+
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
96+
return err
97+
}
98+
fmt.Println("wrote", path)
99+
}
52100

53101
return nil
54102
}

0 commit comments

Comments
 (0)