carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// The console's own email, for when Keymail cannot carry a message (today:
2// invites to someone with no Keymail mailbox). The From identity is made by
3// the platform's own mail layer, mail.EnsureDomain, owned by the ops
4// account under the reserved referrer app consoleMailApp. Sends go out on
5// the console's own AWS role, so no SMTP credential exists to store.
6package console
7
8import (
9 "context"
10 "errors"
11 "fmt"
12 "log"
13 "strings"
14 "time"
15
16 "github.com/carlosframework/platform/internal/dnsrec"
17 "github.com/carlosframework/platform/internal/mail"
18 "github.com/carlosframework/platform/internal/release"
19)
20
21// consoleMailApp is the referrer name on the console's domain record.
22// reservedAppName refuses it in the ops account, and the mail layer refuses
23// to tear down its domain, so no app delete can take the identity with it.
24const consoleMailApp = mail.ConsoleMailApp
25
26// consoleMailPendingRecheck is how long a not-yet-verified answer is trusted.
27const consoleMailPendingRecheck = 5 * time.Minute
28
29// ErrConsoleMailNotReady: the identity is not verified (or could not be
30// made). The caller falls back to the next channel.
31var ErrConsoleMailNotReady = errors.New("console mail: sending identity not verified yet")
32
33type ConsoleMailer interface {
34 Send(ctx context.Context, to, subject, body string) error
35 // Ready reports whether a Send would go out now: the sending identity is
36 // verified. It makes no SES call, and gives up after two seconds.
37 Ready(ctx context.Context) bool
38}
39
40// ConsoleMailFrom resolves the From address: the operator's explicit choice,
41// else noreply at console-mail under the ops account's platform name, else
42// "" (no email). Not "console.bab": that is also the platform mail name of
43// an ops app called "console".
44func ConsoleMailFrom(explicit, platformSuffix string) string {
45 if e := strings.ToLower(strings.TrimSpace(explicit)); e != "" {
46 return e
47 }
48 if platformSuffix == "" {
49 return ""
50 }
51 return "noreply@" + mail.ConsoleMailApp + "." + release.OpsAccount + "." + platformSuffix
52}
53
54type sesConsoleMailer struct {
55 from, domain string
56 store release.Store
57 prov mail.Provisioner
58 dns dnsrec.Writer
59 dep mail.Deployment
60 ses mail.EmailSender
61 now func() time.Time
62
63 // lock is a one-slot mutex a waiter can give up on: an ensure stalled
64 // inside an AWS call must not hold a Send past its own deadline.
65 // Everything below it is guarded by it.
66 lock chan struct{}
67 ensured bool
68 verified bool
69 configSet string
70 checkedAt time.Time
71}
72
73func newSESConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) *sesConsoleMailer {
74 _, domain, _ := strings.Cut(from, "@")
75 return &sesConsoleMailer{from: from, domain: domain, store: s, prov: p, dns: w, dep: d, ses: ses, now: time.Now,
76 lock: make(chan struct{}, 1)}
77}
78
79// NewConsoleMailer builds the console's mailer, or nil when this deployment
80// has no From address or no mail provisioner. The returned func runs the
81// identity ensure; serve() starts it in the background.
82func NewConsoleMailer(from string, s release.Store, p mail.Provisioner, w dnsrec.Writer, d mail.Deployment, ses mail.EmailSender) (ConsoleMailer, func(context.Context)) {
83 if from == "" || p == nil || ses == nil || s == nil {
84 return nil, func(context.Context) {}
85 }
86 m := newSESConsoleMailer(from, s, p, w, d, ses)
87 return m, func(ctx context.Context) {
88 if err := m.Ensure(ctx); err != nil {
89 log.Printf("console mail: %v (retried on the next send)", err)
90 }
91 }
92}
93
94// Ensure makes (or re-reads) the identity. Idempotent. Pending DNS records
95// for a custom domain are logged for the operator to publish.
96func (m *sesConsoleMailer) Ensure(ctx context.Context) error {
97 if err := m.acquire(ctx); err != nil {
98 return err
99 }
100 defer m.unlock()
101 return m.ensureLocked(ctx)
102}
103
104// acquire takes m.lock, or gives up with ErrConsoleMailNotReady when ctx
105// ends first.
106func (m *sesConsoleMailer) acquire(ctx context.Context) error {
107 select {
108 case m.lock <- struct{}{}:
109 return nil
110 case <-ctx.Done():
111 return fmt.Errorf("%w: %w", ErrConsoleMailNotReady, ctx.Err())
112 }
113}
114
115func (m *sesConsoleMailer) unlock() { <-m.lock }
116
117func (m *sesConsoleMailer) ensureLocked(ctx context.Context) error {
118 if m.ensured {
119 return nil
120 }
121 _, pending, err := mail.EnsureDomain(ctx, m.store, m.prov, m.dns, m.dep,
122 release.OpsAccount, consoleMailApp, m.domain, m.now())
123 if err != nil {
124 return fmt.Errorf("console mail: ensure %s: %w", m.domain, err)
125 }
126 for _, r := range pending {
127 log.Printf("console mail: publish %s %s %s so %s can verify", r.Type, r.Name, strings.Join(r.Values, " "), m.domain)
128 }
129 m.ensured = true
130 return nil
131}
132
133func (m *sesConsoleMailer) ready(ctx context.Context) (string, error) {
134 if err := m.acquire(ctx); err != nil {
135 return "", err
136 }
137 defer m.unlock()
138 if m.verified {
139 return m.configSet, nil
140 }
141 if !m.checkedAt.IsZero() && m.now().Sub(m.checkedAt) < consoleMailPendingRecheck {
142 return "", ErrConsoleMailNotReady
143 }
144 m.checkedAt = m.now()
145 if err := m.ensureLocked(ctx); err != nil {
146 return "", fmt.Errorf("%w: %v", ErrConsoleMailNotReady, err)
147 }
148 rec, err := mail.VerifyDomain(ctx, m.store, m.prov, m.dep, release.OpsAccount, m.domain, m.dep.Region, m.now())
149 if err != nil {
150 // The domain record (or its claim) is gone — e.g. an unverified
151 // claim lapsed (mail.ClaimLapse) and was taken, or the record was
152 // otherwise removed from under this mailer. m.ensured must not
153 // stay true: left set, every future call would short-circuit
154 // ensureLocked and this mailer would refuse forever, with no
155 // recheck ever able to repair it. Clearing it here means the NEXT
156 // recheck (after consoleMailPendingRecheck) re-runs EnsureDomain
157 // instead.
158 m.ensured = false
159 return "", fmt.Errorf("%w: %v", ErrConsoleMailNotReady, err)
160 }
161 st := rec.Regions[m.dep.Region]
162 if st == nil || st.Status != mail.StatusVerified {
163 return "", ErrConsoleMailNotReady
164 }
165 m.verified, m.configSet = true, st.ConfigSet
166 return m.configSet, nil
167}
168
169// Ready is the check a page runs before it promises an email: it shares
170// ready's cache and its pending-recheck window, and returns false rather
171// than waiting on a stalled ensure.
172func (m *sesConsoleMailer) Ready(ctx context.Context) bool {
173 if ctx.Err() != nil {
174 // acquire picks at random when the lock is free and ctx is over.
175 return false
176 }
177 ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
178 defer cancel()
179 _, err := m.ready(ctx)
180 return err == nil
181}
182
183func (m *sesConsoleMailer) Send(ctx context.Context, to, subject, body string) error {
184 configSet, err := m.ready(ctx)
185 if err != nil {
186 return err
187 }
188 _, err = m.ses.SendEmail(ctx, mail.SimpleEmailInput(m.from, to, subject, body,
189 mail.TenantName(release.OpsAccount), configSet))
190 if err != nil {
191 return fmt.Errorf("console mail: send to %s: %w", to, err)
192 }
193 return nil
194}
195