@@ -3,6 +3,7 @@ package main
33import (
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
146306func (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 ,
0 commit comments