Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -85,6 +85,10 @@ func TestLoadDeletion(t *testing.T) {
deleter.ParentOrganization: deleter.RuleAllow,
},
},
UserDeleteNotice: deleter.UserDeleteNoticeConfig{
Subject: "Your account was deleted",
Body: "<p>Hi {{.User.Title}}</p>",
},
}

got, err := Load("testdata/deletion.yaml")
Expand Down
4 changes: 4 additions & 0 deletions config/sample.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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}},<br><br>Your account <b>{{.User.Email}}</b> was deleted."

# metaschema cache configuration
metaschema:
Expand Down
3 changes: 3 additions & 0 deletions config/testdata/deletion.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: "<p>Hi {{.User.Title}}</p>"
7 changes: 7 additions & 0 deletions core/deleter/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
100 changes: 69 additions & 31 deletions core/deleter/delete_notice.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ const forfeitRecordsLimit = 1000
const (
defaultDeleteNoticeSubject = "Your organization was deleted"
defaultDeleteNoticeBody = `{{if .User.Title}}Hi {{.User.Title}},{{else}}Hi,{{end}}<br><br>Your organization <b>{{if .Org.Title}}{{.Org.Title}}{{else}}{{.Org.Name}}{{end}}</b> was deleted{{if .DeletedBy}} by <b>{{.DeletedBy}}</b>{{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}}<br><br>Your account <b>{{.User.Email}}</b> was deleted.`
)

type userDeleteNoticeData struct {
User user.User
}

type deleteNoticeData struct {
// Amount is the total number of tokens the delete forfeited.
Amount int64
Expand Down Expand Up @@ -202,23 +209,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)
tmpl, err := parseMailTemplate(d.deleteNoticeConfig.Subject, d.deleteNoticeConfig.Body,
defaultDeleteNoticeSubject, defaultDeleteNoticeBody)
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)
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
}

Expand All @@ -231,29 +226,72 @@ 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
}
slog.InfoContext(ctx, "sent the organization delete notice", "org_id", org.ID, "user_email", owner.Email, "amount", notice.Amount)
}
}

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
}

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 {
Expand Down
73 changes: 73 additions & 0 deletions core/deleter/delete_notice_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package deleter

import (
"bytes"
"context"
"errors"
"testing"
Expand All @@ -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 {
Expand Down Expand Up @@ -74,3 +77,73 @@ 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}}", "<b>{{.Name}}</b>", "", "")
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 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")
Comment thread
AmanGIT07 marked this conversation as resolved.
Resolved
})

t.Run("a body that fails to render sends nothing", func(t *testing.T) {
bodyFails, err := parseMailTemplate("Hi", "<b>{{.Name}}</b>", "", "")
assert.NoError(t, err)
dialer := mailermocks.NewDialer(t)

err = Service{mailDialer: dialer}.sendMail(bodyFails, "maya@example.com", struct{}{})
assert.ErrorContains(t, err, "body")
})
}
25 changes: 24 additions & 1 deletion core/deleter/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
}

Comment thread
AmanGIT07 marked this conversation as resolved.
Resolved
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.
Expand Down
Loading
Loading