oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendar
2
3import (
4 "context"
5 "embed"
6 "errors"
7 "fmt"
8 "strings"
9 "text/template"
10 "time"
11)
12
13// Booking mail (booking spec §6): four kinds, plain text, rendered at
14// send time from the booking's CURRENT state and the public view —
15// nothing a booker may not see reaches a template here.
16
17//go:embed templates/mail/*.txt
18var mailFS embed.FS
19
20var mailTemplates = template.Must(template.ParseFS(mailFS, "templates/mail/*.txt"))
21
22// mailData is what every mail template receives.
23type mailData struct {
24 publicBooking
25 ManageURL string
26 EventURL string
27 // Note is the booker's note, for HOST mail only: the host is on the
28 // team and this is delivery (spec §6). Never set on a booker's mail.
29 Note string
30 // Stale is set on a "confirmed" mail whose booking is no longer
31 // confirmed when it finally renders, so the first line says so
32 // rather than announcing a meeting that was since cancelled (B19).
33 Stale bool
34}
35
36// renderBookingMail produces the subject and body for one queue row.
37func (h *handlers) renderBookingMail(row BookingMail, v BookingView, now time.Time) (to, subject, body string, err error) {
38 pb := publicOf(v, now)
39 d := mailData{publicBooking: pb, EventURL: h.eventURL(v.EventID)}
40 if v.Token != "" {
41 d.ManageURL = h.manageURL(v.Token)
42 }
43 if row.Recipient == ToHost {
44 to = v.Host.Address
45 d.Note = v.Note
46 } else {
47 to = v.Address
48 }
49 switch row.Kind {
50 case MailHold:
51 subject = "Confirm your booking: " + pb.Title
52 case MailConfirmed:
53 subject = pb.Name + " booked " + pb.Title
54 d.Stale = !pb.Live
55 case MailRescheduled:
56 subject = "Moved: " + pb.Title
57 case MailCancelled:
58 subject = "Cancelled: " + pb.Title
59 default:
60 return "", "", "", fmt.Errorf("calendar: unknown mail kind")
61 }
62 name := row.Kind + "_" + row.Recipient + ".txt"
63 if t := mailTemplates.Lookup(name); t == nil {
64 name = row.Kind + ".txt"
65 }
66 var b strings.Builder
67 if err := mailTemplates.ExecuteTemplate(&b, name, d); err != nil {
68 return "", "", "", fmt.Errorf("calendar: render mail %s: %w", row.Kind, err)
69 }
70 return to, subject, b.String(), nil
71}
72
73// sendBookingMail delivers one claimed row and records the outcome:
74// acknowledged, deferred (the sender was busy — no attempt), or failed.
75// Nothing is concluded from an error except "try again later" (B16).
76func (h *handlers) sendBookingMail(ctx context.Context, row BookingMail, now time.Time) error {
77 v, err := h.store.BookingByID(ctx, row.BookingID, true)
78 if err != nil {
79 return err
80 }
81 // A row the booking's CURRENT state makes meaningless is retired
82 // unsent (B19): a hold mail after the hold was confirmed or
83 // expired, a rescheduled mail after a cancellation. Mail says the
84 // current state or says nothing.
85 if obsolete(row, v, now) {
86 if _, err := h.store.MarkBookingMailSent(keep(ctx), row.ID, row.ClaimID, now.Unix()); err != nil {
87 return err
88 }
89 return errMailRetired
90 }
91 to, subject, body, err := h.renderBookingMail(row, v, now)
92 if err != nil {
93 return err
94 }
95 sendCtx, cancel := context.WithTimeout(ctx, maxSendWait)
96 defer cancel()
97 err = h.mailer.Send(sendCtx, to, subject, body)
98 switch {
99 case err == nil:
100 ok, err := h.store.MarkBookingMailSent(keep(ctx), row.ID, row.ClaimID, now.Unix())
101 if err == nil && !ok {
102 return errClaimLost
103 }
104 return err
105 case errors.Is(err, ErrMailBusy):
106 if _, err := h.store.DeferBookingMail(keep(ctx), row.ID, row.ClaimID, now.Unix()); err != nil {
107 return err
108 }
109 return errMailDeferred
110 default:
111 gaveUp, merr := h.store.MarkBookingMailFailed(keep(ctx), row.ID, row.ClaimID)
112 if merr != nil {
113 return merr
114 }
115 code := "retry"
116 if gaveUp {
117 code = "gave_up"
118 }
119 // No address, no token, no title, and NOT the sender's text —
120 // it quotes the subject when it refuses a header: a stage and
121 // two codes.
122 h.logger.Error("calendar: booking mail", "stage", "booking.mail", "code", code, "why", mailCode(err))
123 return err
124 }
125}
126
127// The two outcomes that are neither a send nor a failure. Both are
128// errors to the caller so a deferral is never counted as delivery and a
129// hold page never says "sent" about a row that went back in the queue.
130var (
131 errMailDeferred = errors.New("calendar: mail deferred")
132 errMailRetired = errors.New("calendar: mail retired unsent")
133)
134
135// obsolete says whether a queued row has been overtaken by the state
136// it would describe.
137func obsolete(row BookingMail, v BookingView, now time.Time) bool {
138 switch row.Kind {
139 case MailHold:
140 return v.State != BookingHeld || now.Unix() >= v.HoldExpiresAt
141 case MailRescheduled:
142 return v.Cancelled()
143 }
144 return false
145}
146
147// trySendNow is the immediate attempt after a transition: claim and
148// send THIS booking's owed rows, once each, inside a bounded budget.
149// Everybody else's mail is the drain's; a stranger's POST must not wait
150// through it. Failures are the drain's to retry; the caller learns only
151// whether everything went.
152func (h *handlers) trySendNow(ctx context.Context, bookingID int64, now time.Time) (allSent bool) {
153 ctx, cancel := context.WithTimeout(ctx, 2*maxSendWait)
154 defer cancel()
155 sctx, scancel := sendBudget(ctx)
156 defer scancel()
157 for i := 0; i < 4; i++ {
158 row, ok, err := h.store.ClaimBookingMailFor(ctx, bookingID, now.Unix())
159 if err != nil {
160 return false
161 }
162 if !ok {
163 break
164 }
165 _ = h.sendBookingMail(sctx, row, now)
166 }
167 // Nothing claimable is not everything sent: a row another worker
168 // holds a lease on, or one this loop deferred, is still owed. The
169 // page says "sent" only when the booking owes nothing.
170 owes, err := h.store.OwesMail(keep(ctx), bookingID)
171 return err == nil && !owes
172}
173