diff --git a/server/core/internal/email/email.go b/server/core/internal/email/email.go index c78d5579..425f963d 100644 --- a/server/core/internal/email/email.go +++ b/server/core/internal/email/email.go @@ -345,7 +345,11 @@ type client interface { // Close waits for the deliveries still in flight and releases the sender. func (s *Sender) Close() error { - s.supv.CloseAndWait() + // the deliveries must finish on a live context, so the wait precedes + // the cancellation Close brings. No new sends can arrive here — the + // HTTP server shuts down first — and _sendTimeout bounds the wait. + s.supv.Wait() + s.supv.Close() return nil } diff --git a/server/core/internal/email/email_test.go b/server/core/internal/email/email_test.go index fe7c4c3b..9cf9b510 100644 --- a/server/core/internal/email/email_test.go +++ b/server/core/internal/email/email_test.go @@ -338,6 +338,30 @@ func Test_Sender_SendAccountExists(t *testing.T) { assert.Contains(t, msgBody(t, msg), "https://example.com/login") } +func Test_Sender_Close(t *testing.T) { + t.Parallel() + + var ctxErr error + + client := &clientMock{ + DialAndSendWithContextFunc: func(ctx context.Context, _ ...*mail.Msg) error { + ctxErr = ctx.Err() + + return nil + }, + } + s := stubSender(client) + + s.SendPasswordReset("user@example.com", "https://example.com/reset") + + require.NoError(t, s.Close()) + + // Close must drain the in-flight delivery before cancelling the + // supervisor context, so the send observed a live context. + require.Len(t, client.DialAndSendWithContextCalls(), 1) + assert.NoError(t, ctxErr) +} + // stubSender creates a sender backed by the provided mocked client. func stubSender(client *clientMock) *Sender { return &Sender{