feat: notify user on account deletion
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m45s

This commit is contained in:
juancwu 2026-05-17 15:40:03 +00:00
commit 43e6f76c01
12 changed files with 294 additions and 19 deletions

View file

@ -224,6 +224,56 @@ func (s *EmailService) SendInvitationEmail(email, spaceName, inviterName, token
return err
}
func (s *EmailService) SendAccountDeletionRequestedEmail(email, name, requestID string) error {
trackURL := fmt.Sprintf("%s/account-deletion-status/%s", s.appURL, requestID)
subject, body := accountDeletionRequestedEmailTemplate(name, trackURL, s.appName)
if !s.isProd {
slog.Info("email sent (dev mode)", "type", "account_deletion_requested", "to", email, "subject", subject, "url", trackURL)
return nil
}
if s.client == nil {
return fmt.Errorf("email service not configured")
}
params := &EmailParams{
From: s.fromEmail,
To: []string{email},
Subject: subject,
Text: body,
}
_, err := s.client.SendWithContext(context.Background(), params)
if err == nil {
slog.Info("email sent", "type", "account_deletion_requested", "to", email)
}
return err
}
func accountDeletionRequestedEmailTemplate(name, trackURL, appName string) (string, string) {
greeting := "Hi,"
if name != "" {
greeting = fmt.Sprintf("Hi %s,", name)
}
subject := fmt.Sprintf("Your %s account deletion request was received", appName)
body := fmt.Sprintf(`%s
We received your request to permanently delete your %s account. The deletion is now in progress and typically finishes within a few minutes.
You can track the status of your request here:
%s
This link is the only way to check on the request keep it somewhere safe if you want to confirm completion later. Once the deletion finishes, your data is gone for good and cannot be recovered.
If you did NOT request this, contact our support team immediately. We may still be able to halt the deletion before it completes.
Best,
The %s Team`, greeting, appName, trackURL, appName)
return subject, body
}
func magicLinkEmailTemplate(magicURL, appName string) (string, string) {
subject := fmt.Sprintf("Sign in to %s", appName)
body := fmt.Sprintf(`Click this link to sign in to your account:

View file

@ -26,6 +26,7 @@ type UserService struct {
db *sqlx.DB
userRepository repository.UserRepository
deletionRequestRepo repository.AccountDeletionRequestRepository
emailService *EmailService
// triggerDeletion is set by the worker so that handlers can wake the
// worker up immediately after enqueueing a new request, instead of
// waiting for the next periodic tick.
@ -36,14 +37,29 @@ func NewUserService(
db *sqlx.DB,
userRepository repository.UserRepository,
deletionRequestRepo repository.AccountDeletionRequestRepository,
emailService *EmailService,
) *UserService {
return &UserService{
db: db,
userRepository: userRepository,
deletionRequestRepo: deletionRequestRepo,
emailService: emailService,
}
}
// GetDeletionRequest fetches a deletion request by ID. Returns
// repository.ErrAccountDeletionRequestNotFound when the ID is unknown.
func (s *UserService) GetDeletionRequest(id string) (*model.AccountDeletionRequest, error) {
return s.deletionRequestRepo.ByID(id)
}
// LatestDeletionRequestForUser returns the most recent deletion request for
// the given user, or repository.ErrAccountDeletionRequestNotFound if there
// is none.
func (s *UserService) LatestDeletionRequestForUser(userID string) (*model.AccountDeletionRequest, error) {
return s.deletionRequestRepo.LatestForUser(userID)
}
func (s *UserService) SetDeletionTrigger(ch chan<- struct{}) {
s.triggerDeletion = ch
}
@ -114,6 +130,18 @@ func (s *UserService) RequestAccountDeletion(input RequestAccountDeletionInput)
return err
}
// Confirmation email — best-effort. The deletion proceeds regardless so
// a transient mail outage doesn't trap the user with a flagged account.
if s.emailService != nil {
name := ""
if user.Name != nil {
name = *user.Name
}
if err := s.emailService.SendAccountDeletionRequestedEmail(user.Email, name, req.ID); err != nil {
slog.Error("failed to send account deletion confirmation email", "error", err, "user_id", user.ID, "request_id", req.ID)
}
}
// Wake the worker so it picks up immediately rather than waiting for the
// next tick. Non-blocking so a busy/unbuffered channel never stalls the
// HTTP request.

View file

@ -12,7 +12,7 @@ import (
func TestUserService_ByID(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
userRepo := repository.NewUserRepository(dbi.DB)
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
user := testutil.CreateTestUser(t, dbi.DB, "test@example.com", nil)
@ -26,7 +26,7 @@ func TestUserService_ByID(t *testing.T) {
func TestUserService_ByID_NotFound(t *testing.T) {
testutil.ForEachDB(t, func(t *testing.T, dbi testutil.DBInfo) {
userRepo := repository.NewUserRepository(dbi.DB)
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB))
svc := NewUserService(dbi.DB, userRepo, repository.NewAccountDeletionRequestRepository(dbi.DB), nil)
_, err := svc.ByID("nonexistent-id")
assert.Error(t, err)