From 70da5dedd11dfcfc2334b9aaf6c964a36211c732 Mon Sep 17 00:00:00 2001 From: aman Date: Fri, 30 Oct 2026 14:40:20 +0530 Subject: [PATCH 1/4] refactor(deleter): share the email template code of the delete notice parseMailTemplate picks the configured or built-in subject and body and parses them. sendMail fills them in and sends one mail. The org delete email uses both and sends the same message as before. --- core/deleter/delete_notice.go | 74 +++++++++++++++++------------- core/deleter/delete_notice_test.go | 64 ++++++++++++++++++++++++++ 2 files changed, 107 insertions(+), 31 deletions(-) diff --git a/core/deleter/delete_notice.go b/core/deleter/delete_notice.go index 3706a8c7f..102998c43 100644 --- a/core/deleter/delete_notice.go +++ b/core/deleter/delete_notice.go @@ -202,23 +202,11 @@ func (d Service) sendDeleteNotices(ctx context.Context, org organization.Organiz slog.WarnContext(ctx, "the organization was deleted but no owner could be notified", "org_id", org.ID, "amount", notice.Amount, "purchased", notice.Purchased) return } - subjectTpl := d.deleteNoticeConfig.Subject - if subjectTpl == "" { - subjectTpl = defaultDeleteNoticeSubject - } - bodyTpl := d.deleteNoticeConfig.Body - if bodyTpl == "" { - bodyTpl = defaultDeleteNoticeBody - } // the templates are the same for every owner; parse them once - subjectTmpl, err := texttemplate.New("subject").Parse(subjectTpl) - if err != nil { - slog.WarnContext(ctx, "failed to parse the delete notice subject template", "org_id", org.ID, "error", err) - return - } - bodyTmpl, err := htmltemplate.New("body").Parse(bodyTpl) + tmpl, err := parseMailTemplate(d.deleteNoticeConfig.Subject, d.deleteNoticeConfig.Body, + defaultDeleteNoticeSubject, defaultDeleteNoticeBody) if err != nil { - slog.WarnContext(ctx, "failed to parse the delete notice body template", "org_id", org.ID, "error", err) + slog.WarnContext(ctx, "failed to parse the delete notice template", "org_id", org.ID, "error", err) return } @@ -231,22 +219,7 @@ func (d Service) sendDeleteNotices(ctx context.Context, org organization.Organiz Org: org, DeletedBy: deletedBy, } - var subject, body bytes.Buffer - if err := subjectTmpl.Execute(&subject, data); err != nil { - slog.WarnContext(ctx, "failed to render the delete notice subject", "org_id", org.ID, "user_email", owner.Email, "error", err) - continue - } - if err := bodyTmpl.Execute(&body, data); err != nil { - slog.WarnContext(ctx, "failed to render the delete notice body", "org_id", org.ID, "user_email", owner.Email, "error", err) - continue - } - - msg := mail.NewMessage() - msg.SetHeader("From", d.mailDialer.FromHeader()) - msg.SetHeader("To", owner.Email) - msg.SetHeader("Subject", subject.String()) - msg.SetBody("text/html", body.String()) - if err := d.mailDialer.DialAndSend(msg); err != nil { + if err := d.sendMail(tmpl, owner.Email, data); err != nil { slog.WarnContext(ctx, "failed to send the delete notice", "org_id", org.ID, "user_email", owner.Email, "error", err) continue } @@ -254,6 +227,45 @@ func (d Service) sendDeleteNotices(ctx context.Context, org organization.Organiz } } +type mailTemplate struct { + subject *texttemplate.Template + body *htmltemplate.Template +} + +func parseMailTemplate(subject, body, defaultSubject, defaultBody string) (mailTemplate, error) { + if subject == "" { + subject = defaultSubject + } + if body == "" { + body = defaultBody + } + subjectTmpl, err := texttemplate.New("subject").Parse(subject) + if err != nil { + return mailTemplate{}, fmt.Errorf("failed to parse the subject template: %w", err) + } + bodyTmpl, err := htmltemplate.New("body").Parse(body) + if err != nil { + return mailTemplate{}, fmt.Errorf("failed to parse the body template: %w", err) + } + return mailTemplate{subject: subjectTmpl, body: bodyTmpl}, nil +} + +func (d Service) sendMail(tmpl mailTemplate, to string, data any) error { + var subject, body bytes.Buffer + if err := tmpl.subject.Execute(&subject, data); err != nil { + return fmt.Errorf("failed to render the subject: %w", err) + } + if err := tmpl.body.Execute(&body, data); err != nil { + return fmt.Errorf("failed to render the body: %w", err) + } + msg := mail.NewMessage() + msg.SetHeader("From", d.mailDialer.FromHeader()) + msg.SetHeader("To", to) + msg.SetHeader("Subject", subject.String()) + msg.SetBody("text/html", body.String()) + return d.mailDialer.DialAndSend(msg) +} + // deletedByFromContext names the caller who ran the delete, when the // context carries one. func deletedByFromContext(ctx context.Context) string { diff --git a/core/deleter/delete_notice_test.go b/core/deleter/delete_notice_test.go index 9d0a51686..a04ba1604 100644 --- a/core/deleter/delete_notice_test.go +++ b/core/deleter/delete_notice_test.go @@ -1,6 +1,7 @@ package deleter import ( + "bytes" "context" "errors" "testing" @@ -9,9 +10,11 @@ import ( "github.com/raystack/frontier/core/auditrecord" "github.com/raystack/frontier/core/deleter/mocks" pkgauditrecord "github.com/raystack/frontier/pkg/auditrecord" + mailermocks "github.com/raystack/frontier/pkg/mailer/mocks" "github.com/raystack/frontier/pkg/metadata" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "gopkg.in/mail.v2" ) func deletedRecord(accountID string, meta metadata.Metadata) auditrecord.AuditRecord { @@ -74,3 +77,64 @@ func TestRecoverForfeitFromAudit(t *testing.T) { assert.Equal(t, deleteNotice{}, got) }) } + +func TestParseMailTemplate(t *testing.T) { + render := func(t *testing.T, tmpl mailTemplate) (string, string) { + t.Helper() + var subject, body bytes.Buffer + assert.NoError(t, tmpl.subject.Execute(&subject, nil)) + assert.NoError(t, tmpl.body.Execute(&body, nil)) + return subject.String(), body.String() + } + + t.Run("an empty subject and body fall back to the defaults", func(t *testing.T) { + tmpl, err := parseMailTemplate("", "", "default subject", "default body") + assert.NoError(t, err) + subject, body := render(t, tmpl) + assert.Equal(t, "default subject", subject) + assert.Equal(t, "default body", body) + }) + + t.Run("a configured subject and body win over the defaults", func(t *testing.T) { + tmpl, err := parseMailTemplate("set subject", "set body", "default subject", "default body") + assert.NoError(t, err) + subject, body := render(t, tmpl) + assert.Equal(t, "set subject", subject) + assert.Equal(t, "set body", body) + }) + + t.Run("a broken subject fails", func(t *testing.T) { + _, err := parseMailTemplate("{{.Name", "", "", "default body") + assert.ErrorContains(t, err, "subject") + }) + + t.Run("a broken body fails", func(t *testing.T) { + _, err := parseMailTemplate("", "{{.Name", "default subject", "") + assert.ErrorContains(t, err, "body") + }) +} + +func TestSendMail(t *testing.T) { + tmpl, err := parseMailTemplate("Hi {{.Name}}", "{{.Name}}", "", "") + assert.NoError(t, err) + + t.Run("sends one mail to the address", func(t *testing.T) { + dialer := mailermocks.NewDialer(t) + dialer.EXPECT().FromHeader().Return("no-reply@frontier.test") + dialer.EXPECT().DialAndSend(mock.Anything).Run(func(msg *mail.Message) { + assert.Equal(t, []string{"no-reply@frontier.test"}, msg.GetHeader("From")) + assert.Equal(t, []string{"maya@example.com"}, msg.GetHeader("To")) + assert.Equal(t, []string{"Hi Maya"}, msg.GetHeader("Subject")) + }).Return(nil) + + err := Service{mailDialer: dialer}.sendMail(tmpl, "maya@example.com", struct{ Name string }{Name: "Maya"}) + assert.NoError(t, err) + }) + + t.Run("a template that fails to render sends nothing", func(t *testing.T) { + dialer := mailermocks.NewDialer(t) + + err := Service{mailDialer: dialer}.sendMail(tmpl, "maya@example.com", struct{}{}) + assert.ErrorContains(t, err, "subject") + }) +} From b6c8898ff09225ff18a02556377a136db1c123a9 Mon Sep 17 00:00:00 2001 From: aman Date: Fri, 30 Oct 2026 14:41:28 +0530 Subject: [PATCH 2/4] feat(deleter): email the user after their account is deleted DeleteUser reads the user first, disabled or not, and refuses an id that is not a UUID. A failed read stops the delete, and a user with no row still runs the cleanup before the row delete returns not found. Once the row is deleted, it sends the email from app.deletion.user_delete_notice, with plain built-in text when it is empty. The delete handler returns INVALID_ARGUMENT for an id that is not a UUID. --- config/config_test.go | 6 +- config/sample.config.yaml | 4 + config/testdata/deletion.yaml | 3 + core/deleter/config.go | 7 + core/deleter/delete_notice.go | 26 ++++ core/deleter/service.go | 25 +++- core/deleter/service_test.go | 139 +++++++++++++++++- .../content/docs/reference/configurations.mdx | 11 ++ internal/api/v1beta1connect/user.go | 2 +- internal/api/v1beta1connect/user_test.go | 11 ++ 10 files changed, 228 insertions(+), 6 deletions(-) diff --git a/config/config_test.go b/config/config_test.go index 10d55fdbb..d69e15bed 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -69,7 +69,7 @@ func TestLoad(t *testing.T) { } func TestLoadDeletion(t *testing.T) { - t.Run("reads namespace keys and rules", func(t *testing.T) { + t.Run("reads namespace keys, rules and the user delete email", func(t *testing.T) { want := deleter.Config{ Enabled: true, Resources: map[string]deleter.Rules{ @@ -85,6 +85,10 @@ func TestLoadDeletion(t *testing.T) { deleter.ParentOrganization: deleter.RuleAllow, }, }, + UserDeleteNotice: deleter.UserDeleteNoticeConfig{ + Subject: "Your account was deleted", + Body: "

Hi {{.User.Title}}

", + }, } got, err := Load("testdata/deletion.yaml") diff --git a/config/sample.config.yaml b/config/sample.config.yaml index 2a5e09083..4176f9819 100644 --- a/config/sample.config.yaml +++ b/config/sample.config.yaml @@ -243,6 +243,10 @@ app: compute/machine: { project: block, organization: allow } compute/order: { project: block_unless_deletable, organization: block_unless_deletable } storage/file: { organization: allow } + # emailed after an account delete, even when enabled is false + user_delete_notice: + subject: "Your account was deleted" + body: "Hi {{.User.Title}},

Your account {{.User.Email}} was deleted." # metaschema cache configuration metaschema: diff --git a/config/testdata/deletion.yaml b/config/testdata/deletion.yaml index 508d16308..da4620b5e 100644 --- a/config/testdata/deletion.yaml +++ b/config/testdata/deletion.yaml @@ -7,3 +7,6 @@ app: compute/machine: { project: block, organization: allow } compute/order: { project: block_unless_deletable, organization: block_unless_deletable } storage/file: { organization: allow } + user_delete_notice: + subject: "Your account was deleted" + body: "

Hi {{.User.Title}}

" diff --git a/core/deleter/config.go b/core/deleter/config.go index a361140b0..f7d9f990c 100644 --- a/core/deleter/config.go +++ b/core/deleter/config.go @@ -31,6 +31,13 @@ type Rules map[Parent]Rule type Config struct { Enabled bool `yaml:"enabled" mapstructure:"enabled" default:"false"` Resources map[string]Rules `yaml:"resources" mapstructure:"resources"` + // sent whether or not Enabled is set + UserDeleteNotice UserDeleteNoticeConfig `yaml:"user_delete_notice" mapstructure:"user_delete_notice"` +} + +type UserDeleteNoticeConfig struct { + Subject string `yaml:"subject" mapstructure:"subject"` + Body string `yaml:"body" mapstructure:"body"` } func (c Config) Validate() error { diff --git a/core/deleter/delete_notice.go b/core/deleter/delete_notice.go index 102998c43..4744f4822 100644 --- a/core/deleter/delete_notice.go +++ b/core/deleter/delete_notice.go @@ -28,8 +28,15 @@ const forfeitRecordsLimit = 1000 const ( defaultDeleteNoticeSubject = "Your organization was deleted" defaultDeleteNoticeBody = `{{if .User.Title}}Hi {{.User.Title}},{{else}}Hi,{{end}}

Your organization {{if .Org.Title}}{{.Org.Title}}{{else}}{{.Org.Name}}{{end}} was deleted{{if .DeletedBy}} by {{.DeletedBy}}{{end}}.{{if .Purchased}} Unused purchased tokens will be settled by the support team.{{end}}` + + defaultUserDeleteNoticeSubject = "Your account was deleted" + defaultUserDeleteNoticeBody = `{{if .User.Title}}Hi {{.User.Title}},{{else}}Hi,{{end}}

Your account {{.User.Email}} was deleted.` ) +type userDeleteNoticeData struct { + User user.User +} + type deleteNoticeData struct { // Amount is the total number of tokens the delete forfeited. Amount int64 @@ -227,6 +234,25 @@ func (d Service) sendDeleteNotices(ctx context.Context, org organization.Organiz } } +func (d Service) sendUserDeleteNotice(ctx context.Context, usr user.User) { + if d.mailDialer == nil { + slog.WarnContext(ctx, "no mail dialer configured, skipping the user delete notice", "user_id", usr.ID) + return + } + notice := d.deletionConfig.UserDeleteNotice + tmpl, err := parseMailTemplate(notice.Subject, notice.Body, + defaultUserDeleteNoticeSubject, defaultUserDeleteNoticeBody) + if err != nil { + slog.WarnContext(ctx, "failed to parse the user delete notice template", "user_id", usr.ID, "error", err) + return + } + if err := d.sendMail(tmpl, usr.Email, userDeleteNoticeData{User: usr}); err != nil { + slog.WarnContext(ctx, "failed to send the user delete notice", "user_id", usr.ID, "error", err) + return + } + slog.InfoContext(ctx, "sent the user delete notice", "user_id", usr.ID) +} + type mailTemplate struct { subject *texttemplate.Template body *htmltemplate.Template diff --git a/core/deleter/service.go b/core/deleter/service.go index f0e267cc3..e20659606 100644 --- a/core/deleter/service.go +++ b/core/deleter/service.go @@ -652,6 +652,11 @@ func isOrgOwner(m membership.Member) bool { // DeleteUser stops on any blocker the check finds. Otherwise it deletes the orgs // where the user is the only member and leaves the others, disabled orgs too. func (d Service) DeleteUser(ctx context.Context, userID string) error { + usr, err := d.getUser(ctx, userID) + // a missing user still goes through the cleanup below; the row delete then answers not found + if err != nil && !errors.Is(err, user.ErrNotExist) { + return err + } check, err := d.checkUser(ctx, userID, true) if err != nil { return err @@ -675,7 +680,25 @@ func (d Service) DeleteUser(ctx context.Context, userID string) error { if err := d.userPATService.DeleteAllByUser(ctx, userID); err != nil { return fmt.Errorf("failed to delete user PATs: %w", err) } - return d.userService.Delete(ctx, userID) + if err := d.userService.Delete(ctx, userID); err != nil { + return err + } + d.sendUserDeleteNotice(ctx, usr) + return nil +} + +func (d Service) getUser(ctx context.Context, userID string) (user.User, error) { + if !utils.IsValidUUID(userID) { + return user.User{}, user.ErrInvalidUUID + } + users, err := d.userService.GetByIDsIncludingDisabled(ctx, []string{userID}) + if err != nil { + return user.User{}, err + } + if len(users) == 0 { + return user.User{}, user.ErrNotExist + } + return users[0], nil } // leaveOrg runs after the check, and the org can change in between. diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go index fb42d2988..a16e24f79 100644 --- a/core/deleter/service_test.go +++ b/core/deleter/service_test.go @@ -1840,15 +1840,37 @@ func expectEmptyOrgDelete(m deleterMocks, steps *[]string) { expectEmptyOrgTail(m, steps) } +// The check reads org members with this same call, so register it before +// expectUserOrg. Once makes it answer only the first read. +func expectUserRead(m deleterMocks, userID string) { + m.usrSvc.EXPECT().GetByIDsIncludingDisabled(mock.Anything, []string{userID}). + Return([]user.User{{ID: userID, Email: "maya@example.com"}}, nil).Once() +} + +func expectUserNotice(m deleterMocks, userID string) { + expectUserRead(m, userID) + m.dialer.EXPECT().FromHeader().Return("no-reply@frontier.test") + m.dialer.EXPECT().DialAndSend(mock.Anything).Return(nil) +} + +func expectUserTeardown(m deleterMocks, userID string) { + m.mbrSvc.EXPECT().RemovePrincipalPolicies(mock.Anything, userID, schema.UserPrincipal).Return(nil) + m.patSvc.EXPECT().DeleteAllByUser(mock.Anything, userID).Return(nil) + m.usrSvc.EXPECT().Delete(mock.Anything, userID).Return(nil) +} + func TestDeleteUser(t *testing.T) { - const userID = "user-1" + const userID = "6f1c9e2a-3b4d-4e5f-8a6b-7c8d9e0f1a2b" solo := organization.Organization{ID: "org-1", Name: "solo", State: organization.Disabled} shared := organization.Organization{ID: "org-2", Name: "shared"} - t.Run("deletes the orgs that go with the user, leaves the others, then removes every policy it still holds, its PATs, and the user", func(t *testing.T) { + t.Run("deletes the orgs that go with the user, leaves the others, then removes every policy it still holds, its PATs, and the user, then emails the user", func(t *testing.T) { m := newMocks(t) var steps []string + m.usrSvc.EXPECT().GetByIDsIncludingDisabled(mock.Anything, []string{userID}). + Run(func(context.Context, []string) { steps = append(steps, "read") }). + Return([]user.User{{ID: userID, Email: "maya@example.com", Title: "Maya"}}, nil).Once() expectUserOrgs(m, solo.ID, shared.ID) expectUserOrg(m, solo, []membership.Member{orgMember(userID, orgOwner)}, userID) expectNoBillingAccounts(m, solo.ID) @@ -1862,16 +1884,29 @@ func TestDeleteUser(t *testing.T) { Run(func(context.Context, string) { steps = append(steps, "pats") }).Return(nil) m.usrSvc.EXPECT().Delete(mock.Anything, userID). Run(func(context.Context, string) { steps = append(steps, "user") }).Return(nil) + m.dialer.EXPECT().FromHeader().Return("no-reply@frontier.test") + m.dialer.EXPECT().DialAndSend(mock.Anything).Run(func(msg *mail.Message) { + steps = append(steps, "mail") + assert.Equal(t, []string{"maya@example.com"}, msg.GetHeader("To")) + assert.Equal(t, []string{"Your account was deleted"}, msg.GetHeader("Subject")) + var raw bytes.Buffer + _, err := msg.WriteTo(&raw) + assert.NoError(t, err) + body := strings.ReplaceAll(raw.String(), "=\r\n", "") + assert.Contains(t, body, "Hi Maya,") + assert.Contains(t, body, "maya@example.com was deleted") + }).Return(nil) err := m.build().DeleteUser(context.Background(), userID) assert.NoError(t, err) - assert.Equal(t, []string{"org", "leave:org-2", "policies", "pats", "user"}, steps) + assert.Equal(t, []string{"read", "org", "leave:org-2", "policies", "pats", "user", "mail"}, steps) }) t.Run("refuses with every blocker, judging invoices on provider data, before changing anything", func(t *testing.T) { m := newMocks(t) c := customer.Customer{ID: "cust-1", OrgID: solo.ID, ProviderID: "stripe-1"} + expectUserRead(m, userID) expectUserOrgs(m, solo.ID, shared.ID) expectUserOrg(m, solo, []membership.Member{orgMember(userID, orgOwner)}, userID) m.custSvc.EXPECT().List(mock.Anything, customer.Filter{OrgID: solo.ID}).Return([]customer.Customer{c}, nil) @@ -1904,6 +1939,7 @@ func TestDeleteUser(t *testing.T) { m.deletion = blockerConfig() other := organization.Organization{ID: "org-3", Name: "other"} + expectUserRead(m, userID) expectUserOrgs(m, solo.ID, other.ID) for _, org := range []organization.Organization{solo, other} { expectUserOrg(m, org, []membership.Member{orgMember(userID, orgOwner)}, userID) @@ -1925,6 +1961,7 @@ func TestDeleteUser(t *testing.T) { t.Run("a failed org delete stops before the user leaves the other orgs", func(t *testing.T) { m := newMocks(t) + expectUserRead(m, userID) expectUserOrgs(m, solo.ID, shared.ID) expectUserOrg(m, solo, []membership.Member{orgMember(userID, orgOwner)}, userID) expectNoBillingAccounts(m, solo.ID) @@ -1943,6 +1980,7 @@ func TestDeleteUser(t *testing.T) { m := newMocks(t) var steps []string + expectUserNotice(m, userID) expectUserOrgs(m, solo.ID) expectUserOrg(m, solo, []membership.Member{orgMember(userID, orgOwner)}, userID) expectNoBillingAccounts(m, solo.ID) @@ -1962,6 +2000,7 @@ func TestDeleteUser(t *testing.T) { t.Run("a user removed from an org since the check does not stop the delete", func(t *testing.T) { m := newMocks(t) + expectUserNotice(m, userID) expectUserOrgs(m, shared.ID) expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgViewer), orgMember("user-2", orgOwner)}, userID, "user-2") m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal). @@ -1976,6 +2015,7 @@ func TestDeleteUser(t *testing.T) { t.Run("an org the user leaves that was deleted since the check does not stop the delete", func(t *testing.T) { m := newMocks(t) + expectUserNotice(m, userID) expectUserOrgs(m, shared.ID) expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgViewer), orgMember("user-2", orgOwner)}, userID, "user-2") m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal). @@ -1990,6 +2030,7 @@ func TestDeleteUser(t *testing.T) { t.Run("an org that lost its other owner since the check refuses the delete with the blocker", func(t *testing.T) { m := newMocks(t) + expectUserRead(m, userID) expectUserOrgs(m, shared.ID) expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgOwner), orgMember("user-2", orgOwner)}, userID, "user-2") m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal). @@ -2004,6 +2045,7 @@ func TestDeleteUser(t *testing.T) { t.Run("an org removal failure stops the delete before the remaining policies", func(t *testing.T) { m := newMocks(t) + expectUserRead(m, userID) expectUserOrgs(m, shared.ID) expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgViewer), orgMember("user-2", orgOwner)}, userID, "user-2") m.mbrSvc.EXPECT().RemoveOrganizationMemberOnDelete(mock.Anything, shared.ID, userID, schema.UserPrincipal). @@ -2016,6 +2058,7 @@ func TestDeleteUser(t *testing.T) { t.Run("a policy removal failure stops the delete before the PATs and the user", func(t *testing.T) { m := newMocks(t) + expectUserRead(m, userID) expectUserOrgs(m) m.mbrSvc.EXPECT().RemovePrincipalPolicies(mock.Anything, userID, schema.UserPrincipal). Return(errors.New("spicedb unavailable")) @@ -2027,6 +2070,7 @@ func TestDeleteUser(t *testing.T) { t.Run("aborts before userService.Delete when PAT cleanup fails", func(t *testing.T) { m := newMocks(t) + expectUserRead(m, userID) expectUserOrgs(m) m.mbrSvc.EXPECT().RemovePrincipalPolicies(mock.Anything, userID, schema.UserPrincipal).Return(nil) m.patSvc.EXPECT().DeleteAllByUser(mock.Anything, userID).Return(errors.New("pat cleanup boom")) @@ -2039,12 +2083,101 @@ func TestDeleteUser(t *testing.T) { t.Run("a failed check stops the delete before anything changes", func(t *testing.T) { m := newMocks(t) + expectUserRead(m, userID) m.mbrSvc.EXPECT().ListResourcesByPrincipal(mock.Anything, mock.Anything, schema.OrganizationNamespace, mock.Anything). Return(nil, errors.New("read failed")) err := m.build().DeleteUser(context.Background(), userID) assert.EqualError(t, err, "read failed") }) + + t.Run("the email can use the fields of the deleted user", func(t *testing.T) { + m := newMocks(t) + m.deletion.UserDeleteNotice = deleter.UserDeleteNoticeConfig{ + Subject: "Goodbye {{.User.Name}}", + Body: "{{.User.ID}} {{.User.Title}} {{.User.Email}} {{.User.State}}", + } + + m.usrSvc.EXPECT().GetByIDsIncludingDisabled(mock.Anything, []string{userID}).Return([]user.User{{ + ID: userID, + Name: "maya", + Email: "maya@example.com", + Title: "Maya", + State: user.Enabled, + }}, nil) + expectUserOrgs(m) + expectUserTeardown(m, userID) + m.dialer.EXPECT().FromHeader().Return("no-reply@frontier.test") + m.dialer.EXPECT().DialAndSend(mock.Anything).Run(func(msg *mail.Message) { + assert.Equal(t, []string{"Goodbye maya"}, msg.GetHeader("Subject")) + var raw bytes.Buffer + _, err := msg.WriteTo(&raw) + assert.NoError(t, err) + body := strings.ReplaceAll(raw.String(), "=\r\n", "") + assert.Contains(t, body, userID+" Maya maya@example.com enabled") + }).Return(nil) + + assert.NoError(t, m.build().DeleteUser(context.Background(), userID)) + }) + + t.Run("a failed email does not fail the delete", func(t *testing.T) { + m := newMocks(t) + + expectUserRead(m, userID) + expectUserOrgs(m) + expectUserTeardown(m, userID) + m.dialer.EXPECT().FromHeader().Return("no-reply@frontier.test") + m.dialer.EXPECT().DialAndSend(mock.Anything).Return(errors.New("smtp down")) + + assert.NoError(t, m.build().DeleteUser(context.Background(), userID)) + }) + + t.Run("a disabled user gets the email too", func(t *testing.T) { + m := newMocks(t) + + m.usrSvc.EXPECT().GetByIDsIncludingDisabled(mock.Anything, []string{userID}). + Return([]user.User{{ID: userID, Email: "maya@example.com", State: user.Disabled}}, nil) + expectUserOrgs(m) + expectUserTeardown(m, userID) + m.dialer.EXPECT().FromHeader().Return("no-reply@frontier.test") + m.dialer.EXPECT().DialAndSend(mock.Anything).Run(func(msg *mail.Message) { + assert.Equal(t, []string{"maya@example.com"}, msg.GetHeader("To")) + }).Return(nil) + + assert.NoError(t, m.build().DeleteUser(context.Background(), userID)) + }) + + t.Run("a failed user read stops the delete before anything changes", func(t *testing.T) { + m := newMocks(t) + + m.usrSvc.EXPECT().GetByIDsIncludingDisabled(mock.Anything, []string{userID}). + Return(nil, errors.New("read failed")) + + err := m.build().DeleteUser(context.Background(), userID) + assert.EqualError(t, err, "read failed") + }) + + t.Run("an id that is not a uuid is refused before anything changes", func(t *testing.T) { + m := newMocks(t) + + err := m.build().DeleteUser(context.Background(), "user-1") + assert.ErrorIs(t, err, user.ErrInvalidUUID) + }) + + t.Run("a user that is already deleted, or never existed, still has its leftovers cleared and answers not found", func(t *testing.T) { + m := newMocks(t) + + m.usrSvc.EXPECT().GetByIDsIncludingDisabled(mock.Anything, []string{userID}).Return(nil, nil) + // the user has no row, so the check skips the org it still holds a policy in + expectUserOrgs(m, shared.ID) + expectUserOrg(m, shared, []membership.Member{orgMember(userID, orgOwner), orgMember("user-2", orgViewer)}, "user-2") + m.mbrSvc.EXPECT().RemovePrincipalPolicies(mock.Anything, userID, schema.UserPrincipal).Return(nil) + m.patSvc.EXPECT().DeleteAllByUser(mock.Anything, userID).Return(nil) + m.usrSvc.EXPECT().Delete(mock.Anything, userID).Return(user.ErrNotExist) + + err := m.build().DeleteUser(context.Background(), userID) + assert.ErrorIs(t, err, user.ErrNotExist) + }) } func TestCheckUserDelete(t *testing.T) { diff --git a/docs/content/docs/reference/configurations.mdx b/docs/content/docs/reference/configurations.mdx index beb784378..aa9431dec 100644 --- a/docs/content/docs/reference/configurations.mdx +++ b/docs/content/docs/reference/configurations.mdx @@ -174,6 +174,10 @@ app: compute/machine: { project: block, organization: allow } compute/order: { project: block_unless_deletable, organization: block_unless_deletable } storage/file: { organization: allow } + # emailed after an account delete, even when enabled is false + user_delete_notice: + subject: "Your account was deleted" + body: "Hi {{.User.Title}},

Your account {{.User.Email}} was deleted." # metaschema cache configuration metaschema: # how often each server reloads the metaschema cache from the database, so a @@ -336,6 +340,13 @@ An unknown key or rule, a namespace with no rules, or a namespace not shaped lik | **app.deletion.resources.\.project** | Rule for a project delete. Missing, it stops the delete. | `block` | No | | **app.deletion.resources.\.organization** | Rule for an organization delete. Missing, it stops the delete. | `allow` | No | +`user_delete_notice` sets the email a user gets after their account is deleted, by the user or by an admin. It is sent whether or not `enabled` is set, and a disabled user gets it too. The subject and body are Go templates. `.User` holds the deleted user's `ID`, `Name`, `Email`, `Title`, `Avatar` and `State`, as in `{{.User.Title}}`. If sending fails, the delete still succeeds. + +| **Field** | **Description** | **Example** | **Required** | +| --------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------- | ------------ | +| **app.deletion.user_delete_notice.subject** | Subject of the email. Empty, it is `Your account was deleted`. | `"Your account was deleted"` | No | +| **app.deletion.user_delete_notice.body** | HTML body of the email. Empty, it is a short built-in text with the user's email. | `"Hi {{.User.Title}}, ..."` | No | + ### Admin Configurations | **Field** | **Description** | **Example** | **Required** | diff --git a/internal/api/v1beta1connect/user.go b/internal/api/v1beta1connect/user.go index c20d4ea30..c11f02eae 100644 --- a/internal/api/v1beta1connect/user.go +++ b/internal/api/v1beta1connect/user.go @@ -457,7 +457,7 @@ func (h *ConnectHandler) deleteUser(ctx context.Context, request connect.AnyRequ switch { case errors.Is(err, user.ErrNotExist): return connect.NewError(connect.CodeNotFound, ErrUserNotExist) - case errors.Is(err, user.ErrInvalidID): + case errors.Is(err, user.ErrInvalidID), errors.Is(err, user.ErrInvalidUUID): return connect.NewError(connect.CodeInvalidArgument, ErrBadRequest) default: return connect.NewError(connect.CodeInternal, fmt.Errorf("%s: user_id=%s: %w", operation, userID, err)) diff --git a/internal/api/v1beta1connect/user_test.go b/internal/api/v1beta1connect/user_test.go index 635648cd6..97b2ca7dc 100644 --- a/internal/api/v1beta1connect/user_test.go +++ b/internal/api/v1beta1connect/user_test.go @@ -866,6 +866,17 @@ func TestConnectHandler_DeleteUser(t *testing.T) { want: nil, err: connect.CodeInvalidArgument, }, + { + title: "should return invalid argument error for a user ID that is not a uuid", + setup: func(cd *mocks.CascadeDeleter) { + cd.EXPECT().DeleteUser(mock.Anything, "not-a-uuid").Return(user.ErrInvalidUUID) + }, + req: &frontierv1beta1.DeleteUserRequest{ + Id: "not-a-uuid", + }, + want: nil, + err: connect.CodeInvalidArgument, + }, { title: "should return internal error if deleter service returns unexpected error", setup: func(cd *mocks.CascadeDeleter) { From 033dc90f820356b20f2525b2efe5ed3c9c269f56 Mon Sep 17 00:00:00 2001 From: aman Date: Mon, 2 Nov 2026 14:17:52 +0530 Subject: [PATCH 3/4] test(deleter): cover a broken user delete template and a body that fails to render --- core/deleter/delete_notice_test.go | 11 ++++++++++- core/deleter/service_test.go | 11 +++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/core/deleter/delete_notice_test.go b/core/deleter/delete_notice_test.go index a04ba1604..c40a6183e 100644 --- a/core/deleter/delete_notice_test.go +++ b/core/deleter/delete_notice_test.go @@ -131,10 +131,19 @@ func TestSendMail(t *testing.T) { assert.NoError(t, err) }) - t.Run("a template that fails to render sends nothing", func(t *testing.T) { + t.Run("a subject that fails to render sends nothing", func(t *testing.T) { dialer := mailermocks.NewDialer(t) err := Service{mailDialer: dialer}.sendMail(tmpl, "maya@example.com", struct{}{}) assert.ErrorContains(t, err, "subject") }) + + t.Run("a body that fails to render sends nothing", func(t *testing.T) { + bodyFails, err := parseMailTemplate("Hi", "{{.Name}}", "", "") + assert.NoError(t, err) + dialer := mailermocks.NewDialer(t) + + err = Service{mailDialer: dialer}.sendMail(bodyFails, "maya@example.com", struct{}{}) + assert.ErrorContains(t, err, "body") + }) } diff --git a/core/deleter/service_test.go b/core/deleter/service_test.go index a16e24f79..79d9b307c 100644 --- a/core/deleter/service_test.go +++ b/core/deleter/service_test.go @@ -2132,6 +2132,17 @@ func TestDeleteUser(t *testing.T) { assert.NoError(t, m.build().DeleteUser(context.Background(), userID)) }) + t.Run("a broken configured template still deletes the user, without an email", func(t *testing.T) { + m := newMocks(t) + m.deletion.UserDeleteNotice = deleter.UserDeleteNoticeConfig{Body: "{{.User.Email"} + + expectUserRead(m, userID) + expectUserOrgs(m) + expectUserTeardown(m, userID) + + assert.NoError(t, m.build().DeleteUser(context.Background(), userID)) + }) + t.Run("a disabled user gets the email too", func(t *testing.T) { m := newMocks(t) From 0b5270d0cf265d605b83e8af035897cad406a373 Mon Sep 17 00:00:00 2001 From: aman Date: Mon, 2 Nov 2026 14:17:52 +0530 Subject: [PATCH 4/4] docs: say the user delete email needs app.mailer.smtp_host --- docs/content/docs/reference/configurations.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/content/docs/reference/configurations.mdx b/docs/content/docs/reference/configurations.mdx index aa9431dec..41fd25b97 100644 --- a/docs/content/docs/reference/configurations.mdx +++ b/docs/content/docs/reference/configurations.mdx @@ -340,7 +340,7 @@ An unknown key or rule, a namespace with no rules, or a namespace not shaped lik | **app.deletion.resources.\.project** | Rule for a project delete. Missing, it stops the delete. | `block` | No | | **app.deletion.resources.\.organization** | Rule for an organization delete. Missing, it stops the delete. | `allow` | No | -`user_delete_notice` sets the email a user gets after their account is deleted, by the user or by an admin. It is sent whether or not `enabled` is set, and a disabled user gets it too. The subject and body are Go templates. `.User` holds the deleted user's `ID`, `Name`, `Email`, `Title`, `Avatar` and `State`, as in `{{.User.Title}}`. If sending fails, the delete still succeeds. +`user_delete_notice` sets the email a user gets after their account is deleted, by the user or by an admin. It is sent whether or not `enabled` is set, and a disabled user gets it too. The subject and body are Go templates. `.User` holds the deleted user's `ID`, `Name`, `Email`, `Title`, `Avatar` and `State`, as in `{{.User.Title}}`. If sending fails, the delete still succeeds. It only goes out when `app.mailer.smtp_host` is set. Without it, Frontier uses a stand-in mailer that sends nothing, and the log still says the email was sent. | **Field** | **Description** | **Example** | **Required** | | --------------------------------------------- | ---------------------------------------------------------------------------------- | ---------------------------- | ------------ |