carlos / movement Public

Clone
git clone https://amadan.net/carlos/movement

Plain git — no account needed to clone.

Download

Download this file

1package movement
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/carlosframework/rastrillo/mail"
9 "gorm.io/gorm"
10)
11
12// sendTimeout bounds a send we start after the response has gone out.
13//
14// It has to be well under the platform's idle window. A goroutine outliving
15// its response is exactly the shape the scheduled tick refuses, and it is
16// only safe here because it is short: seconds against minutes. A SIGTERM
17// landing mid-send loses that one message after its budget has been spent,
18// and re-submitting is the recovery path.
19const sendTimeout = 15 * time.Second
20
21// sendAfterCommit delivers a confirmation once the write transaction has
22// committed.
23//
24// Never inside the transaction. mail.Sender.Send takes a context and discards
25// it, smtp.SendMail underneath dials with no deadline, and rastrillo.Serve
26// sets no write timeout — so a hung SES connection inside the transaction
27// would hold the single writer connection and stall every other signup and
28// the tick behind it.
29func (a *app) sendAfterCommit(m pendingMail) {
30 go func() {
31 ctx, cancel := context.WithTimeout(context.Background(), sendTimeout)
32 defer cancel()
33
34 link := a.env.Origin + "/confirm?token=" + m.token
35 body := fmt.Sprintf("%s\n\n%s\n", T("mail.confirm.body"), link)
36
37 if err := a.mailer.Send(ctx, m.to, T("mail.confirm.subject"), body); err != nil {
38 // The error is dropped on purpose. rastrillo/mail formats its
39 // failures as "send to <address>", so logging it here would print
40 // the address beside a request that carries the stance — the
41 // sensitive pair, assembled by accident.
42 Rejected(ReasonMailFailed)
43 }
44 }()
45}
46
47// budgetedSender wraps the sender handed to rastrillo/auth so that sign-in
48// links draw on the same global budget as confirmations. A budget that counts
49// only the messages we remember sending is not a budget.
50type budgetedSender struct {
51 inner mail.Sender
52 db *gorm.DB
53}
54
55func BudgetedSender(inner mail.Sender, db *gorm.DB) mail.Sender {
56 return &budgetedSender{inner: inner, db: db}
57}
58
59func (s *budgetedSender) Send(ctx context.Context, to, subject, body string) error {
60 now := time.Now()
61
62 // The count is committed before the send is attempted, so a hung
63 // connection cannot leave the budget unspent while holding a slot.
64 var allowed bool
65 err := s.db.Transaction(func(tx *gorm.DB) error {
66 var err error
67 allowed, err = globalAllowed(tx, now)
68 return err
69 })
70 if err != nil {
71 return err
72 }
73 if !allowed {
74 Rejected(ReasonMailBudget)
75 // nil, not an error. auth.Begin turns an error into a different
76 // redirect — ?err= rather than ?sent=1 — and logs the failure with
77 // the address attached. Both of those are visible differences between
78 // one address and another, which is the thing this app must not have.
79 return nil
80 }
81
82 // auth calls this synchronously and applies no deadline of its own.
83 ctx, cancel := context.WithTimeout(ctx, sendTimeout)
84 defer cancel()
85 return s.inner.Send(ctx, to, subject, body)
86}
87