From 6e2d7eadcf64302ec17d63ea8197f23c6716081e Mon Sep 17 00:00:00 2001 From: Mansur Akhmadi <10739460+mansur-akhmadi@users.noreply.github.com> Date: Fri, 6 Nov 2026 16:54:03 +0700 Subject: [PATCH] fix(mailer): retry the configured template after a default fallback When the first load of a template fails and there is no previous entry, the cache stores the default template. Cache.get treated that entry like any other and served it until TemplateMaxAge (10m by default), so a short outage of the template endpoint at the wrong moment meant default emails for the next ten minutes. The background reloader already skips the max age check for default entries and retries them after TemplateRetryInterval. Do the same in get, which is the only path when template reloading is disabled (the default). --- internal/mailer/templatemailer/template.go | 5 +- .../mailer/templatemailer/template_test.go | 174 ++++++++++++++++++ 2 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 internal/mailer/templatemailer/template_test.go diff --git a/internal/mailer/templatemailer/template.go b/internal/mailer/templatemailer/template.go index a0de7f4f20..353df756eb 100644 --- a/internal/mailer/templatemailer/template.go +++ b/internal/mailer/templatemailer/template.go @@ -310,8 +310,11 @@ func (o *Cache) get( return o.load(ctx, cfg, typ, nil) } + // The def flag signals that the entry is the default template we fell + // back to after a failed load. Like in reloadAt, it never counts as fresh, + // so the configured template is retried after the retry interval below. maxAge := cfg.Mailer.TemplateMaxAge - if now.Sub(ent.createdAt) < maxAge { + if !ent.def && now.Sub(ent.createdAt) < maxAge { // Cache hit and the entry is not expired, return it. return ent, nil } diff --git a/internal/mailer/templatemailer/template_test.go b/internal/mailer/templatemailer/template_test.go new file mode 100644 index 0000000000..33a6b004c7 --- /dev/null +++ b/internal/mailer/templatemailer/template_test.go @@ -0,0 +1,174 @@ +package templatemailer + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/require" + "github.com/supabase/auth/internal/conf" +) + +func TestCacheGet(t *testing.T) { + const ( + maxAge = time.Minute * 10 + retryIval = time.Second * 10 + custom = "custom template 123456" + ) + + type testServer struct { + failing atomic.Bool + hits atomic.Int64 + } + + setup := func(t *testing.T) (*testServer, *Cache, *conf.GlobalConfiguration, func(time.Duration)) { + srv := &testServer{} + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + srv.hits.Add(1) + if srv.failing.Load() { + w.WriteHeader(http.StatusServiceUnavailable) + return + } + _, _ = w.Write([]byte("custom template {{ .Token }}")) + })) + t.Cleanup(ts.Close) + + cfg := &conf.GlobalConfiguration{} + cfg.Mailer.Templates.Confirmation = ts.URL + "/confirmation.html" + cfg.Mailer.TemplateMaxSize = 1_000_000 + cfg.Mailer.TemplateMaxAge = maxAge + cfg.Mailer.TemplateRetryInterval = retryIval + + now := time.Now() + tc := NewCache() + tc.now = func() time.Time { return now } + advance := func(d time.Duration) { now = now.Add(d) } + return srv, tc, cfg, advance + } + + get := func(t *testing.T, tc *Cache, cfg *conf.GlobalConfiguration) (string, bool) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + defer cancel() + + ent, err := tc.get(ctx, cfg, ConfirmationTemplate) + require.NoError(t, err) + + var buf bytes.Buffer + _, body, err := ent.execute(&buf, map[string]any{"Token": "123456"}) + require.NoError(t, err) + return body, ent.def + } + + t.Run("CustomTemplateIsCachedForMaxAge", func(t *testing.T) { + srv, tc, cfg, advance := setup(t) + + body, def := get(t, tc, cfg) + require.False(t, def) + require.Equal(t, custom, body) + + advance(maxAge - time.Second) + body, def = get(t, tc, cfg) + require.False(t, def) + require.Equal(t, custom, body) + require.Equal(t, int64(1), srv.hits.Load()) + + advance(time.Second) + get(t, tc, cfg) + require.Equal(t, int64(2), srv.hits.Load()) + }) + + t.Run("CustomTemplateIsKeptWhenReloadFails", func(t *testing.T) { + srv, tc, cfg, advance := setup(t) + + get(t, tc, cfg) + srv.failing.Store(true) + + advance(maxAge) + body, def := get(t, tc, cfg) + require.False(t, def) + require.Equal(t, custom, body) + require.Equal(t, int64(2), srv.hits.Load()) + + // The failed reload is not retried before the retry interval. + advance(retryIval - time.Second) + body, def = get(t, tc, cfg) + require.False(t, def) + require.Equal(t, custom, body) + require.Equal(t, int64(2), srv.hits.Load()) + + advance(time.Second) + body, def = get(t, tc, cfg) + require.False(t, def) + require.Equal(t, custom, body) + require.Equal(t, int64(3), srv.hits.Load()) + }) + + t.Run("DefaultFallbackIsRetriedAfterRetryInterval", func(t *testing.T) { + srv, tc, cfg, advance := setup(t) + srv.failing.Store(true) + + // Cold cache and the template endpoint is down: default template. + body, def := get(t, tc, cfg) + require.True(t, def) + require.NotEqual(t, custom, body) + require.Equal(t, int64(1), srv.hits.Load()) + + // The fallback is not retried before the retry interval. + advance(retryIval - time.Second) + _, def = get(t, tc, cfg) + require.True(t, def) + require.Equal(t, int64(1), srv.hits.Load()) + + // Still failing: the default template is kept and retried again later. + advance(time.Second) + _, def = get(t, tc, cfg) + require.True(t, def) + require.Equal(t, int64(2), srv.hits.Load()) + + // The endpoint recovers well within TemplateMaxAge. + srv.failing.Store(false) + + advance(retryIval - time.Second) + _, def = get(t, tc, cfg) + require.True(t, def) + require.Equal(t, int64(2), srv.hits.Load()) + + advance(time.Second) + body, def = get(t, tc, cfg) + require.False(t, def, "default template still served after the retry interval") + require.Equal(t, custom, body) + require.Equal(t, int64(3), srv.hits.Load()) + + // From now on the custom template is cached for TemplateMaxAge. + srv.failing.Store(true) + advance(maxAge - time.Second) + body, def = get(t, tc, cfg) + require.False(t, def) + require.Equal(t, custom, body) + require.Equal(t, int64(3), srv.hits.Load()) + }) + + // With TemplateReloadingEnabled the background reloader already retries + // the default fallback on the same interval. + t.Run("BackgroundReloadRetriesDefaultFallback", func(t *testing.T) { + srv, tc, cfg, advance := setup(t) + cfg.Mailer.TemplateReloadingEnabled = true + cfg.Mailer.TemplateReloadingMaxIdle = time.Minute * 20 + srv.failing.Store(true) + + _, def := get(t, tc, cfg) + require.True(t, def) + + srv.failing.Store(false) + advance(retryIval) + tc.Reload(context.Background(), cfg) + + body, def := get(t, tc, cfg) + require.False(t, def) + require.Equal(t, custom, body) + }) +}