Skip to content

feat(deleter): email the user after their account is deleted - #2034

Open
AmanGIT07 wants to merge 4 commits into
mainfrom
feat/user-delete-email
Open

AmanGIT07 wants to merge 4 commits into
mainfrom
feat/user-delete-email

Conversation

@AmanGIT07

@AmanGIT07 AmanGIT07 commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A user delete, by an admin or by the user, now emails the deleted user once the user row is gone. The subject and body come from the new app.deletion.user_delete_notice config. When it is empty, a plain built-in text is used. The org delete email now uses the same template code and is otherwise unchanged.

Changes

  • core/deleter/service.go: DeleteUser reads the user first, disabled or not. Like getProject, it refuses an id that is not a UUID. A failed read stops the delete before anything changes. A user with no row still goes through the cleanup, and the row delete returns not found. After the row delete, it sends the email.
  • core/deleter/delete_notice.go: parseMailTemplate and sendMail hold the template and send code that both emails use. sendUserDeleteNotice logs a failure and never returns one.
  • core/deleter/config.go: user_delete_notice with a subject and a body, both Go templates. It is sent whether or not enabled is set.
  • internal/api/v1beta1connect/user.go: a user id that is not a UUID returns INVALID_ARGUMENT instead of INTERNAL.
  • config/sample.config.yaml, docs/content/docs/reference/configurations.mdx: the new key.

Technical Details

  • The email goes out only after the user row is deleted. A failed step stops the delete before the row, so no email goes out and a retry can still send it. A failed send is logged, and the delete still succeeds.
  • Like the org email, it is sent before the RPC returns.
  • The admin DeleteUser and DeleteCurrentUser both send it, because both run the deleter's DeleteUser. A disabled user gets it too.
  • In the template, .User has ID, Name, Email, Title, Avatar and State.
  • An org deleted with the user still sends its own org email when its delete finishes. The user email does not list these orgs, because a retry cannot see the orgs an earlier attempt deleted.
  • A user that does not exist, or is already deleted, still returns NOT_FOUND. Anything it left behind (policies, PATs, relations) is still cleared on the way, as before.
  • The org email keeps its config at billing.org_delete_notice.
  • e2e cannot check the email, because the e2e server runs without a mail server.

Test Plan

  • core/deleter/service_test.go TestDeleteUser: the email goes to the user's address, after the row delete, with the built-in subject and body; a configured template can use the user's fields; a disabled user gets the email; a failed send does not fail the delete; a broken configured template still deletes the user, without an email; a failed read, or an id that is not a UUID, stops the delete before anything changes; a user with no row still has its leftovers cleared and returns not found; no failure case sends an email.
  • core/deleter/delete_notice_test.go: TestParseMailTemplate (fallbacks, configured values, broken templates) and TestSendMail (headers; nothing sent when the subject or the body fails to render). TestDeleteOrganization passes unchanged on the shared code.
  • internal/api/v1beta1connect/user_test.go TestConnectHandler_DeleteUser: an id that is not a UUID returns INVALID_ARGUMENT.
  • config/config_test.go TestLoadDeletion: the new key loads from YAML.
  • go vet ./..., the unit tests of the changed packages with -race, and make lint pass.

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.
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.
@vercel

vercel Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
frontier Ready Ready Preview Nov 2, 2026 8:48am UTC

@coderabbitai

coderabbitai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Summary

Summary by CodeRabbit

  • New Features
    • User account deletion can send a configurable email notice with a personalized subject and message. Notices can use user details, and email delivery failures do not prevent deletion.
  • Bug Fixes
    • Invalid user IDs now consistently return a bad-request response when deletion is requested.
  • Documentation
    • Configuration guidance now covers deletion notice templates, available user details, default content, and delivery behavior.

The change adds configurable email notices for deleted user accounts. Deletion retrieves the user before cleanup and sends the notice after teardown. The Connect handler also maps invalid UUID errors to invalid-argument responses.

Changes: User deletion flow

Layer / File(s) Summary
Configure and render deletion notices
core/deleter/config.go, config/*, core/deleter/delete_notice.go, core/deleter/delete_notice_test.go, docs/content/docs/reference/configurations.mdx
Deletion configuration adds notice subject and body templates. Shared helpers parse, render, and send organization and user notices. Examples, documentation, and tests cover configuration and mail behavior.
Retrieve, delete, and notify
core/deleter/service.go, core/deleter/service_test.go
DeleteUser retrieves the user before cleanup, continues cleanup when the user is missing, deletes the user record after resource cleanup, and sends a notice after deletion. Tests cover lookup and cleanup outcomes, notice delivery, disabled users, and operation ordering.
Map invalid UUID errors
internal/api/v1beta1connect/user.go, internal/api/v1beta1connect/user_test.go
The Connect delete-user handler maps user.ErrInvalidUUID to connect.CodeInvalidArgument. A test covers the invalid UUID response.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Suggested reviewers: whoabhisheksah, rohilsurana

Merge Risk: Low · up to 0b527

When SMTP is not configured, deletion notices are silently undelivered while logs report success; correct the mailer wiring before relying on those logs.

Pre-merge checks: Passed 2
Check name Status Explanation
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@coveralls

coveralls commented Oct 30, 2026 •

Copy link
Copy Markdown

Coverage Report for CI Build 41415687973

Coverage increased (+0.06%) to 56.053%

Details

  • Coverage increased (+0.06%) from the base build.
  • Patch coverage: 4 uncovered changes across 1 file (70 of 74 lines covered, 94.59%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
core/deleter/delete_notice.go 51 47 92.16%
Total (3 files) 74 70 94.59%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
core/deleter/delete_notice.go 1 82.32%

Coverage Stats

Coverage Status
Relevant Lines: 42189
Covered Lines: 23648
Line Coverage: 56.05%
Coverage Strength: 19.7 hits per line

💛 - Coveralls

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 8190c1f and b6c8898.

Files selected for processing: 11
  • config/config_test.go
  • config/sample.config.yaml
  • config/testdata/deletion.yaml
  • core/deleter/config.go
  • core/deleter/delete_notice.go
  • core/deleter/delete_notice_test.go
  • core/deleter/service.go
  • core/deleter/service_test.go
  • docs/content/docs/reference/configurations.mdx
  • internal/api/v1beta1connect/user.go
  • internal/api/v1beta1connect/user_test.go

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

Comment thread core/deleter/service.go
Comment on lines +683 to +689
if err := d.userService.Delete(ctx, userID); err != nil {
return err
}
d.sendUserDeleteNotice(ctx, usr)
return nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Major · Stability & Availability · Heavy lift

Supported by static analysis

Script executed:

sed -n '630,710p' core/deleter/service.go
sed -n '235,300p' core/deleter/delete_notice.go
rg -n 'DialAndSend|NewDialer|Timeout|SetDeadline' core/deleter internal config

Repository: raystack/frontier

Length of output: 42446


Script executed:

set -o pipefail
printf '%s\n' '--- mail dialer bindings and configuration ---'
rg -n -F --glob '*.go' -e 'mailDialer' -e 'gomail.NewDialer' -e 'mail.NewDialer' -e 'Dialer{' -e 'SMTP' core internal config cmd | head -240
printf '%s\n' '--- dependency declarations ---'
rg -n -F --glob 'go.mod' -e 'gomail' -e 'go-mail' -e 'smtp' .
printf '%s\n' '--- DeleteUser RPC bindings and context deadlines ---'
rg -n -F --glob '*.go' -e 'DeleteUser' -e 'context.WithTimeout' -e 'context.WithDeadline' -e 'Timeout:' internal core | head -300

Repository: raystack/frontier

Length of output: 10313


Script executed:

printf '%s\n' '--- all mail dialer bindings ---'
rg -n -F --glob '*.go' -e 'mailDialer' -e 'NewDialer' -e 'Dialer{' -e 'SMTP' .
printf '%s\n' '--- mail dependencies ---'
rg -n -F --glob 'go.mod' -e 'gomail' -e 'go-mail' -e 'smtp' .
printf '%s\n' '--- DeleteUser entrypoints and deadlines ---'
rg -n -F --glob '*.go' -e 'DeleteUser' -e 'context.WithTimeout' -e 'context.WithDeadline' -e 'grpc.Timeout' -e 'Timeout:' internal core cmd

Repository: raystack/frontier

Length of output: 13103


Script executed:

printf '%s\n' '--- concrete mailer implementation ---'
nl -ba pkg/mailer/dialer.go
nl -ba pkg/mailer/mailer.go
printf '%s\n' '--- mail module declaration ---'
sed -n '1,90p' go.mod
printf '%s\n' '--- server construction and timeout ---'
nl -ba cmd/serve.go | sed -n '700,755p'
printf '%s\n' '--- mailer interface ---'
rg -n -F --glob '*.go' -e 'type Dialer interface' -e 'DialAndSend' -e 'FromHeader' pkg/mailer

Repository: raystack/frontier

Length of output: 12314


Script executed:

nl -ba pkg/mailer/dialer.go
nl -ba pkg/mailer/mailer.go
sed -n '1,90p' go.mod
nl -ba cmd/serve.go | sed -n '710,750p'
rg -n -F --glob '*.go' -e 'type Dialer interface' -e 'func (.*DialAndSend' -e 'FromHeader' pkg/mailer

Repository: raystack/frontier

Length of output: 10391


Script executed:

nl -ba pkg/mailer/dialer.go
nl -ba pkg/mailer/mailer.go
sed -n '1,90p' go.mod
nl -ba cmd/serve.go | sed -n '725,745p'
rg -n -F --glob '*.go' -e 'type Dialer interface' -e 'DialAndSend' -e 'FromHeader' pkg/mailer

Repository: raystack/frontier

Length of output: 10995


Web query:

gopkg.in/mail.v2 v2.3.1 Dialer DialAndSend Timeout source net.DialTimeout SMTP greeting deadline context

Result:

Sources consulted:
- mail/smtp.go at v2.3.1 · go-mail/mail · GitHub — https://github.com/go-mail/mail/blob/v2.3.1/smtp.go
- mail/send.go at v2.3.1 · go-mail/mail · GitHub — https://github.com/go-mail/mail/blob/v2.3.1/send.go
- Releases · go-mail/mail · GitHub — https://github.com/go-mail/mail/releases
- mail package - github.com/go-mail/mail - Go Packages — https://pkg.go.dev/github.com/go-mail/mail%40v2.3.1%2Bincompatible
- …and 13 more

Apply the SMTP deadline before the greeting.

DeleteUser sends the notice synchronously after deleting the row. gopkg.in/mail.v2 v2.3.1 applies its default 10-second deadline only after smtp.NewClient returns. If the SMTP server accepts the connection but sends no greeting, smtp.NewClient can block without that deadline, and it does not consume the RPC context. The row is already deleted, but the DeleteUser RPC can remain blocked.

Set the connection deadline before creating the SMTP client, or use a context-aware SMTP implementation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AmanGIT07 The missing deadline is real: in gopkg.in/mail.v2 v2.3.1, Dial reads the server greeting (smtp.go:91) before it sets the deadline (smtp.go:96-98). Every email Frontier sends goes through the same pkg/mailer path: login OTP and magic link, invitations, PAT alerts, and the org delete email. So the fix belongs in pkg/mailer, for all of them, and not in this PR. Tracked in #2039.

assert.NoError(t, m.build().DeleteUser(context.Background(), userID))
})

t.Run("a failed email does not fail the delete", func(t *testing.T) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we add a case where the configured template is broken? The delete should still pass and no email should go out.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a broken configured template still deletes the user, without an email in 033dc90.

dialer := mailermocks.NewDialer(t)

err := Service{mailDialer: dialer}.sendMail(tmpl, "maya@example.com", struct{}{})
assert.ErrorContains(t, err, "subject")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this only covers a subject that fails to render. Can we add one where the subject renders and the body fails?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a body that fails to render sends nothing in 033dc90, and renamed the existing case to a subject that fails to render sends nothing.

| **app.deletion.resources.\<namespace\>.project** | Rule for a project delete. Missing, it stops the delete. | `block` | No |
| **app.deletion.resources.\<namespace\>.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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we say here that this email needs app.mailer set? Without it a mock sender is used, and the log still says the email was sent.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 0b5270d: it only goes out when app.mailer.smtp_host is set, and without it the log still says the email was sent.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to GitHub limitations.

⚠️ Outside diff range comments (1)

Minor · Pass a disabled mailer to the deleter. · delete_notice.go:238-253

core/deleter/delete_notice.go:238-253
Minor · Functional Correctness · Quick win

Pass a disabled mailer to the deleter.

DeleteUser is reachable through the authenticated DeleteUser endpoint. Without app.mailer.smtp_host, the server passes a non-nil mock dialer to the deleter. The mock returns nil from DialAndSend without delivery, so sendUserDeleteNotice logs sent the user delete notice. This violates the documented contract that the notice is sent only when SMTP is configured.

Suggested fix
 var mailDialer mailer.Dialer = mailer.NewMockDialer()
+var deletionMailDialer mailer.Dialer
 if cfg.App.Mailer.SMTPHost != "" && cfg.App.Mailer.SMTPHost != "smtp.example.com" {
 	mailDialer = mailer.NewDialerImpl(cfg.App.Mailer.SMTPHost,
 		cfg.App.Mailer.SMTPPort,
 		cfg.App.Mailer.SMTPUsername,
 		cfg.App.Mailer.SMTPPassword,
 		cfg.App.Mailer.SMTPInsecure,
 		cfg.App.Mailer.Headers,
 		cfg.App.Mailer.TLSPolicy(),
 	)
+	deletionMailDialer = mailDialer
 	logger.Info("mailer enabled", "host", cfg.App.Mailer.SMTPHost, "port", cfg.App.Mailer.SMTPPort)
 }
 
 // ...
-	mailDialer, cfg.Billing.OrgDeleteNotice, cfg.App.Deletion,
+	deletionMailDialer, cfg.Billing.OrgDeleteNotice, cfg.App.Deletion,

Review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between b6c8898 and 0b5270d.

Files selected for processing: 3
  • core/deleter/delete_notice_test.go
  • core/deleter/service_test.go
  • docs/content/docs/reference/configurations.mdx

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

This branch was successfully deployed

1 active deployment
Preview — 0b5270d0 Deployed Nov 2, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants