Skip to content

Commit edf0c47

Browse files
expire_imminent: route resource.expiry_imminent through Go renderer
User got the broken "Your resource expires in 6 hours" email again after the first fix shipped. Worker log proved it: BOTH expiry kinds fired for the same recipient at 04:12:56 UTC: kind=anon.expiry_warning path=raw_html template_id=0 (new fix) kind=resource.expiry_imminent path=template template_id=6 (still broken) The first fix only registered renderAnonExpiryEmail for anon.expiry_warning. The sibling kind from expire_imminent.go (authenticated/paid resources) was still routed through Brevo's templateId=6 — the same template that hardcodes "6 hours" + reads param names we don't emit (empty Type/Token/Expires rows). Fix: - Register renderAnonExpiryEmail for resource.expiry_imminent in eventEmailBodyRenderers. Same renderer — both kinds have identical user-facing semantics ("your resource expires in N hours, click to keep") and the renderer treats missing optional params as empty. - Extend buildResourceExpiring to emit upgrade_url, resource_url, token_prefix, reminder_index="1" (single-fire — paid path has no stage concept) + audit_kind so the renderer view struct fills. - Extend expire_imminent.go's audit insert to populate the same metadata fields (token_prefix is first 8 chars of r.token only, never the full secret; upgrade_url carries source= resource_expiry_imminent for funnel attribution). Tests: - TestEventEmailBodyRenderers_CoversBothExpiryKinds: regression guard that fails if either expiry kind isn't registered. Catches the exact bug class that bit us today. - TestBuildResourceExpiring_EmitsAllRendererParams: asserts every renderer-required key is non-empty after the build. - All 9 targeted tests pass; ./internal/jobs/... ./internal/email/... full suite green (ok 22.533s + 0.544s). The Brevo template_id=6 fallback in BREVO_TEMPLATE_IDS is left in place — brevo_provider.go::SendEvent prefers HTMLBody != "" so any legacy events queued before this deploy still route through the new path; the templateId is harmless dead-code for new sends. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ad8e22 commit edf0c47

3 files changed

Lines changed: 110 additions & 3 deletions

File tree

internal/jobs/event_email_mapping.go

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,11 +200,21 @@ type eventEmailBodyRenderer func(params map[string]string) (subject, html, text
200200
//
201201
// Currently registered (2026-05-15):
202202
//
203-
// anon.expiry_warning — replaces a broken Brevo dashboard template
203+
// anon.expiry_warning — replaces a broken Brevo dashboard template
204204
// (hardcoded "6 hours" subject, empty body fields).
205205
// Renderer: renderAnonExpiryEmail in expiry_reminder_email.go.
206+
//
207+
// resource.expiry_imminent — same broken template (template_id=6) was
208+
// also wired for the paid/authenticated expiry path. Both audit kinds
209+
// fired for the same recipient on 2026-05-15 04:12 UTC and the
210+
// authenticated email was the broken one. Reuses renderAnonExpiryEmail
211+
// because the payload shape is identical (resource_type, hours_remaining,
212+
// expires_at, token_prefix, upgrade_url, resource_url) — see the
213+
// buildResourceExpiring/expire_imminent.go changes that emit the
214+
// matching params.
206215
var eventEmailBodyRenderers = map[string]eventEmailBodyRenderer{
207-
auditKindAnonExpiryWarning: renderAnonExpiryEmail,
216+
auditKindAnonExpiryWarning: renderAnonExpiryEmail,
217+
auditKindResourceExpiryImminent: renderAnonExpiryEmail,
208218
}
209219

210220
// eventEmailBuilders maps an audit_log.kind to the builder that produces
@@ -334,11 +344,27 @@ func buildResourceExpiring(row auditRow) (map[string]string, bool) {
334344
}
335345
meta := decodeMeta(row.Metadata)
336346
params := baseParams(row)
347+
params["audit_kind"] = row.Kind
337348
if row.ResourceType != "" {
338349
params["resource_type"] = row.ResourceType
339350
}
340351
copyMetaStr(params, meta, "expires_at", "expires_at")
341352
copyMetaStr(params, meta, "hours_remaining", "hours_remaining")
353+
// 2026-05-15 — the paid expiry path now shares the Go renderer
354+
// (renderAnonExpiryEmail) with the anon path. There is no multi-stage
355+
// reminder cadence for paid/authenticated resources (single-fire), but
356+
// the renderer reads reminder_index to decide between "Heads up" /
357+
// "Reminder" / "Final reminder" subject prefixes — pin to "1" so paid
358+
// emails read as "Heads up — your instanode <type> expires in Nh".
359+
// Also surface resource_id / token_prefix / upgrade_url / resource_url
360+
// so the body's Type/Token/Expires panel and CTA links render correctly
361+
// (the previous Brevo dashboard template referenced these but the
362+
// worker wasn't sending them — rendering empty cells in production).
363+
params["reminder_index"] = "1"
364+
copyMetaStr(params, meta, "resource_id", "resource_id")
365+
copyMetaStr(params, meta, "token_prefix", "token_prefix")
366+
copyMetaStr(params, meta, "upgrade_url", "upgrade_url")
367+
copyMetaStr(params, meta, "resource_url", "resource_url")
342368
return params, true
343369
}
344370

internal/jobs/expire_imminent.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -226,13 +226,29 @@ func (w *ExpireImminentWorker) Work(ctx context.Context, job *river.Job[ExpireIm
226226
// (loops_event_mapping.go::buildResourceExpiring). The
227227
// resource_id field is also the dedupe key the next sweep's
228228
// NOT IN subquery joins on, so it must be a parseable uuid.
229+
//
230+
// 2026-05-15: token_prefix / upgrade_url / resource_url were
231+
// added so the Go-rendered email body (renderAnonExpiryEmail,
232+
// shared with the anon expiry path) has the values it needs
233+
// to fill the Type/Token/Expires panel and the upgrade /
234+
// resource-detail CTAs. The previous Brevo dashboard template
235+
// referenced these but the worker wasn't emitting them, so the
236+
// rendered email had empty cells. token_prefix is the first 8
237+
// chars of the resource token (uuid.UUID is always 36 chars
238+
// so the min() is defensive — protects against future schema
239+
// changes where token isn't a uuid).
240+
tokenStr := r.token.String()
241+
tokenPrefix := tokenStr[:min(8, len(tokenStr))]
229242
meta := map[string]any{
230243
"resource_id": r.resourceID.String(),
231244
"resource_type": r.resourceType,
232245
"expires_at": r.expiresAt.UTC().Format(time.RFC3339),
233246
"hours_remaining": hoursRemaining,
234247
"email": r.ownerEmail.String,
235-
"token": r.token.String(),
248+
"token": tokenStr,
249+
"token_prefix": tokenPrefix,
250+
"upgrade_url": "https://instanode.dev/app/billing?upgrade=hobby&source=resource_expiry_imminent",
251+
"resource_url": "https://instanode.dev/app/resources/" + r.resourceID.String(),
236252
}
237253
metaBytes, mErr := json.Marshal(meta)
238254
if mErr != nil {

internal/jobs/expiry_reminder_email_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,71 @@ func TestEventEmailBodyRenderers_RegistersAnonExpiryWarning(t *testing.T) {
148148
}
149149
}
150150

151+
// TestEventEmailBodyRenderers_CoversBothExpiryKinds locks in the
152+
// 2026-05-15 follow-up fix: the broken Brevo dashboard template_id=6
153+
// was wired to BOTH anon.expiry_warning AND resource.expiry_imminent.
154+
// The first fix only registered the renderer for anon — the
155+
// authenticated paid path still routed through the broken template
156+
// and the user received the broken email at 04:12 UTC.
157+
//
158+
// This test FAILS on the post-first-fix master (only one kind
159+
// registered) and PASSES after the second fix registers both kinds.
160+
// Fail-fast in CI if anyone adds a third expiry kind and forgets to
161+
// register a renderer.
162+
func TestEventEmailBodyRenderers_CoversBothExpiryKinds(t *testing.T) {
163+
for _, kind := range []string{"anon.expiry_warning", "resource.expiry_imminent"} {
164+
if _, ok := eventEmailBodyRenderers[kind]; !ok {
165+
t.Errorf("eventEmailBodyRenderers missing renderer for %q — email would route through Brevo template_id and lose all the new params", kind)
166+
}
167+
}
168+
}
169+
170+
// TestBuildResourceExpiring_EmitsAllRendererParams asserts the paid
171+
// expiry builder emits every key the shared renderer reads. Caught the
172+
// production bug where buildResourceExpiring emitted only three keys
173+
// (resource_type / expires_at / hours_remaining) and the renderer's
174+
// Type/Token/Expires panel rendered empty cells.
175+
func TestBuildResourceExpiring_EmitsAllRendererParams(t *testing.T) {
176+
row := auditRow{
177+
ID: "x",
178+
TeamID: "t",
179+
Kind: auditKindResourceExpiryImminent,
180+
ResourceType: "postgres",
181+
OwnerEmail: "test@example.com",
182+
Metadata: []byte(`{
183+
"hours_remaining":"4",
184+
"expires_at":"2026-05-16T00:00:00Z",
185+
"resource_id":"abc-123",
186+
"token_prefix":"abc12345",
187+
"upgrade_url":"https://instanode.dev/app/billing?upgrade=hobby",
188+
"resource_url":"https://instanode.dev/app/resources/abc-123"
189+
}`),
190+
}
191+
params, ok := buildResourceExpiring(row)
192+
if !ok {
193+
t.Fatal("buildResourceExpiring returned ok=false unexpectedly")
194+
}
195+
for _, k := range []string{
196+
"resource_type",
197+
"hours_remaining",
198+
"expires_at",
199+
"reminder_index",
200+
"token_prefix",
201+
"upgrade_url",
202+
"resource_url",
203+
} {
204+
if params[k] == "" {
205+
t.Errorf("buildResourceExpiring must emit non-empty %q; got params=%v", k, params)
206+
}
207+
}
208+
// reminder_index must pin to "1" (paid path is single-fire — no
209+
// stage cadence) so the subject reads "Heads up", not "Reminder"
210+
// or "Final reminder".
211+
if params["reminder_index"] != "1" {
212+
t.Errorf("reminder_index = %q; want \"1\" (paid path is single-fire)", params["reminder_index"])
213+
}
214+
}
215+
151216
// TestRenderAnonExpiryEmail_NeverSaysSixHoursWhenItsNot is a guard
152217
// against the regression that prompted this whole change: the old
153218
// template hardcoded "6 hours" in the subject regardless of the actual

0 commit comments

Comments
 (0)