Skip to content

Commit 2ad8e22

Browse files
worker(email): Go-render anon.expiry_warning + raw-HTML Brevo path
Production bug: anon.expiry_warning emails were sending with a hardcoded "Your resource expires in 6 hours" subject (regardless of actual hours_remaining), empty Type/Token/Expires body fields, and a personal "From:" address inherited from the Brevo dashboard template's sender field. The dashboard-template path required out-of-band Brevo UI edits that never happened. Fix: render the email body in Go for anon.expiry_warning and send via a new BrevoProvider raw-HTML path (POST /v3/smtp/email with htmlContent + subject + sender, no templateId). Sender identity comes from BREVO_SENDER_EMAIL / BREVO_SENDER_NAME with safe defaults (noreply@instanode.dev / "instanode") so a misconfigured worker cannot inherit a personal email. The change is additive — every other audit_log.kind continues to use the existing template-id path. Only kinds registered in eventEmailBodyRenderers take the raw-HTML path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 69da31d commit 2ad8e22

9 files changed

Lines changed: 775 additions & 17 deletions

internal/config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,14 @@ type Config struct {
3333
EmailProvider string // EMAIL_PROVIDER
3434
BrevoAPIKey string // BREVO_API_KEY
3535
BrevoTemplateIDs map[string]int // BREVO_TEMPLATE_IDS (JSON object: audit_log.kind → numeric template id)
36+
// Sender identity for the raw-HTML send path (EventEmail.HTMLBody non-empty).
37+
// Defaults are applied inside email.NewBrevoProvider — env unset is safe.
38+
// BREVO_SENDER_EMAIL — defaults to noreply@instanode.dev
39+
// BREVO_SENDER_NAME — defaults to "instanode"
40+
// These exist as code-controlled config so a rendered email cannot inherit
41+
// a personal address left in the Brevo dashboard sender field.
42+
BrevoSenderEmail string // BREVO_SENDER_EMAIL
43+
BrevoSenderName string // BREVO_SENDER_NAME
3644

3745
// SES_* env vars — populated only when EMAIL_PROVIDER=ses. SES_AWS_*
3846
// names are scoped (not bare AWS_*) so they can't be confused with
@@ -138,6 +146,8 @@ func Load() *Config {
138146
EmailProvider: os.Getenv("EMAIL_PROVIDER"),
139147
BrevoAPIKey: os.Getenv("BREVO_API_KEY"),
140148
BrevoTemplateIDs: parseBrevoTemplateIDs(os.Getenv("BREVO_TEMPLATE_IDS")),
149+
BrevoSenderEmail: os.Getenv("BREVO_SENDER_EMAIL"),
150+
BrevoSenderName: os.Getenv("BREVO_SENDER_NAME"),
141151
SESAWSRegion: os.Getenv("SES_AWS_REGION"),
142152
SESAWSAccessKey: os.Getenv("SES_AWS_ACCESS_KEY_ID"),
143153
SESAWSSecretKey: os.Getenv("SES_AWS_SECRET_ACCESS_KEY"),

internal/email/brevo_provider.go

Lines changed: 179 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,32 @@ const (
7676
type BrevoConfig struct {
7777
APIKey string
7878
TemplateIDs map[string]int
79+
80+
// SenderEmail / SenderName are the "From:" identity used by the
81+
// raw-render path (EventEmail.HTMLBody non-empty). The dashboard-
82+
// template path ignores these because Brevo's template editor has
83+
// its own sender field. Both come from env at boot:
84+
// BREVO_SENDER_EMAIL — defaults to noreply@instanode.dev
85+
// BREVO_SENDER_NAME — defaults to "instanode"
86+
//
87+
// They live in code-controlled config (not the Brevo dashboard) so a
88+
// rendered email cannot silently inherit a personal email left in
89+
// the dashboard sender field. The defaults are intentionally
90+
// production-safe — a worker that boots with the secret absent
91+
// still sends from noreply@instanode.dev, not someone's gmail.
92+
SenderEmail string
93+
SenderName string
7994
}
8095

96+
// Default sender identity used when BREVO_SENDER_EMAIL / BREVO_SENDER_NAME
97+
// are absent from BrevoConfig. Kept here (not in config.Load) so a test
98+
// or in-process caller that constructs BrevoConfig directly gets the
99+
// same safe defaults the production code path uses.
100+
const (
101+
defaultBrevoSenderEmail = "noreply@instanode.dev"
102+
defaultBrevoSenderName = "instanode"
103+
)
104+
81105
// BrevoProvider is the live implementation. Constructed once at boot via
82106
// NewBrevoProvider and reused across every forwarder tick. http.Client
83107
// is goroutine-safe; templates is read-only after construction.
@@ -88,6 +112,13 @@ type BrevoProvider struct {
88112
// url is overridable so tests can point at an httptest.Server. Always
89113
// brevoSendURL in production.
90114
url string
115+
116+
// senderEmail / senderName are the From identity for the raw-render
117+
// path (EventEmail.HTMLBody non-empty). The template path leaves
118+
// these unused — Brevo's template carries its own sender. Both are
119+
// populated by NewBrevoProvider from BrevoConfig (with defaults).
120+
senderEmail string
121+
senderName string
91122
}
92123

93124
// NewBrevoProvider validates BrevoConfig and returns the live provider.
@@ -106,11 +137,21 @@ func NewBrevoProvider(cfg BrevoConfig) (*BrevoProvider, error) {
106137
if tmpls == nil {
107138
tmpls = map[string]int{}
108139
}
140+
senderEmail := cfg.SenderEmail
141+
if senderEmail == "" {
142+
senderEmail = defaultBrevoSenderEmail
143+
}
144+
senderName := cfg.SenderName
145+
if senderName == "" {
146+
senderName = defaultBrevoSenderName
147+
}
109148
return &BrevoProvider{
110-
apiKey: cfg.APIKey,
111-
templates: tmpls,
112-
httpc: &http.Client{Timeout: brevoHTTPTimeout},
113-
url: brevoSendURL,
149+
apiKey: cfg.APIKey,
150+
templates: tmpls,
151+
httpc: &http.Client{Timeout: brevoHTTPTimeout},
152+
url: brevoSendURL,
153+
senderEmail: senderEmail,
154+
senderName: senderName,
114155
}, nil
115156
}
116157

@@ -134,10 +175,62 @@ type brevoSendRequest struct {
134175
Params map[string]string `json:"params,omitempty"`
135176
}
136177

137-
// SendEvent implements EmailProvider.SendEvent. Maps EventEmail.Kind to a
138-
// Brevo templateId via p.templates, builds the JSON body, POSTs, and
139-
// classifies the response per the table at the top of this file.
178+
// brevoSender mirrors the Brevo `sender` object used by the raw-HTML
179+
// path. Brevo accepts {"email": "...", "name": "..."}; the template path
180+
// doesn't send this object because the template carries its own sender.
181+
type brevoSender struct {
182+
Email string `json:"email"`
183+
Name string `json:"name,omitempty"`
184+
}
185+
186+
// brevoRawSendRequest is the wire payload for the raw-HTML path. Used
187+
// when EventEmail.HTMLBody is non-empty — we send subject + htmlContent +
188+
// textContent + sender directly and DO NOT include templateId. This
189+
// bypasses the Brevo dashboard template entirely so the email body is
190+
// fully controlled by Go code at deploy time.
191+
type brevoRawSendRequest struct {
192+
To []brevoRecipient `json:"to"`
193+
Sender brevoSender `json:"sender"`
194+
Subject string `json:"subject"`
195+
HTMLContent string `json:"htmlContent"`
196+
TextContent string `json:"textContent,omitempty"`
197+
}
198+
199+
// SendEvent implements EmailProvider.SendEvent. Two paths:
200+
//
201+
// 1. Raw-render path (preferred for new kinds) — when EventEmail.HTMLBody
202+
// is non-empty, we send Subject + HTMLBody + TextBody + Sender directly.
203+
// The template id is NOT consulted; p.templates can lack an entry for
204+
// this Kind without producing SkippedNoTemplate. This is the path used
205+
// for "anon.expiry_warning" so the email body is controlled entirely
206+
// by worker code (no out-of-band Brevo dashboard edit required).
207+
//
208+
// 2. Template path (legacy / dashboard-controlled) — when HTMLBody is
209+
// empty, look up EventEmail.Kind in p.templates and POST with
210+
// templateId + params. Kinds with no entry produce SkippedNoTemplate.
211+
//
212+
// Both paths classify the HTTP response identically per the table at the
213+
// top of this file.
140214
func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
215+
if evt.Recipient == "" {
216+
// Defensive — the forwarder filters orphan rows before reaching
217+
// here, but a future caller path might not. Permanent because
218+
// the row will never sprout an email retroactively. Checked
219+
// before the template lookup so a raw-render event with empty
220+
// recipient short-circuits the same way.
221+
return &SendError{
222+
Class: SendClassPermanent,
223+
Message: "brevo: empty recipient",
224+
}
225+
}
226+
227+
// Raw-render path takes precedence — explicit HTML body means the
228+
// caller already rendered the email and wants us to send those bytes
229+
// verbatim. We do NOT consult p.templates in this branch.
230+
if evt.HTMLBody != "" {
231+
return p.sendRaw(ctx, evt)
232+
}
233+
141234
tmplID, ok := p.templates[evt.Kind]
142235
if !ok {
143236
// Operator hasn't mapped this kind to a Brevo template yet —
@@ -149,16 +242,6 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
149242
}
150243
}
151244

152-
if evt.Recipient == "" {
153-
// Defensive — the forwarder filters orphan rows before reaching
154-
// here, but a future caller path might not. Permanent because
155-
// the row will never sprout an email retroactively.
156-
return &SendError{
157-
Class: SendClassPermanent,
158-
Message: "brevo: empty recipient",
159-
}
160-
}
161-
162245
body, err := json.Marshal(brevoSendRequest{
163246
To: []brevoRecipient{{Email: evt.Recipient, Name: evt.RecipientName}},
164247
TemplateID: tmplID,
@@ -193,12 +276,88 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
193276
req.Header.Set(headerIdempotency, evt.IdempotencyKey)
194277
}
195278

279+
return p.doRequest(ctx, evt, body, brevoSendPathTemplate, tmplID)
280+
}
281+
282+
// brevoSendPath enumerates which SendEvent branch we took, used only for
283+
// log labels so an operator can tell "we sent via template id 6" from
284+
// "we sent the rendered HTML" at a glance.
285+
type brevoSendPath string
286+
287+
const (
288+
brevoSendPathTemplate brevoSendPath = "template"
289+
brevoSendPathRaw brevoSendPath = "raw_html"
290+
)
291+
292+
// sendRaw is the raw-HTML send path. The caller (typically a per-kind
293+
// builder) has already rendered the subject + html + plain-text body in
294+
// Go; we POST them verbatim with the configured sender identity. The
295+
// dashboard-template path is bypassed entirely — Brevo just relays the
296+
// bytes. This is how anon.expiry_warning escapes the broken dashboard
297+
// template that hardcoded "6 hours" and rendered empty fields.
298+
func (p *BrevoProvider) sendRaw(ctx context.Context, evt EventEmail) error {
299+
if evt.Subject == "" {
300+
// Subject is mandatory in the raw path — a Brevo POST with an
301+
// empty subject string still delivers, but the recipient sees
302+
// "(no subject)" which is its own bug. Permanent so we advance
303+
// past the row; the caller is supposed to render a subject.
304+
slog.Error("email.brevo.raw_missing_subject",
305+
"kind", evt.Kind,
306+
"recipient", evt.Recipient,
307+
)
308+
return &SendError{
309+
Class: SendClassPermanent,
310+
Message: "brevo: raw send missing subject",
311+
}
312+
}
313+
body, err := json.Marshal(brevoRawSendRequest{
314+
To: []brevoRecipient{{Email: evt.Recipient, Name: evt.RecipientName}},
315+
Sender: brevoSender{Email: p.senderEmail, Name: p.senderName},
316+
Subject: evt.Subject,
317+
HTMLContent: evt.HTMLBody,
318+
TextContent: evt.TextBody,
319+
})
320+
if err != nil {
321+
slog.Error("email.brevo.raw_marshal_failed",
322+
"kind", evt.Kind,
323+
"recipient", evt.Recipient,
324+
"error", err,
325+
)
326+
return &SendError{Class: SendClassPermanent, Cause: err, Message: "brevo: raw marshal"}
327+
}
328+
return p.doRequest(ctx, evt, body, brevoSendPathRaw, 0)
329+
}
330+
331+
// doRequest is the shared HTTP send + response classify path used by
332+
// both template and raw branches. Identical wire-level behavior — the
333+
// only difference is the log label and the absence of a template id in
334+
// the raw path.
335+
func (p *BrevoProvider) doRequest(ctx context.Context, evt EventEmail, body []byte, path brevoSendPath, tmplID int) error {
336+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url, bytes.NewReader(body))
337+
if err != nil {
338+
// Request construction failure is almost certainly a malformed URL —
339+
// a programming bug. Transient so the operator sees it on every tick
340+
// until they fix it, instead of advancing past silently.
341+
slog.Error("email.brevo.request_build_failed",
342+
"kind", evt.Kind,
343+
"path", string(path),
344+
"error", err,
345+
)
346+
return &SendError{Class: SendClassTransient, Cause: err, Message: "brevo: build request"}
347+
}
348+
req.Header.Set(headerAPIKey, p.apiKey)
349+
req.Header.Set(headerContentType, contentTypeJSON)
350+
if evt.IdempotencyKey != "" {
351+
req.Header.Set(headerIdempotency, evt.IdempotencyKey)
352+
}
353+
196354
resp, err := p.httpc.Do(req)
197355
if err != nil {
198356
// Network error, timeout, dns failure. Transient by definition.
199357
slog.Warn("email.brevo.http_failed",
200358
"kind", evt.Kind,
201359
"recipient", evt.Recipient,
360+
"path", string(path),
202361
"error", err,
203362
)
204363
return &SendError{Class: SendClassTransient, Cause: err, Message: "brevo: http"}
@@ -212,6 +371,7 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
212371
"kind", evt.Kind,
213372
"recipient", evt.Recipient,
214373
"status", resp.StatusCode,
374+
"path", string(path),
215375
"template_id", tmplID,
216376
)
217377
return nil
@@ -225,6 +385,7 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
225385
"kind", evt.Kind,
226386
"recipient", evt.Recipient,
227387
"status", resp.StatusCode,
388+
"path", string(path),
228389
"body", string(respBody),
229390
)
230391
return &SendError{
@@ -238,6 +399,7 @@ func (p *BrevoProvider) SendEvent(ctx context.Context, evt EventEmail) error {
238399
"kind", evt.Kind,
239400
"recipient", evt.Recipient,
240401
"status", resp.StatusCode,
402+
"path", string(path),
241403
"body", string(respBody),
242404
)
243405
return &SendError{

0 commit comments

Comments
 (0)