Skip to content

Commit

Permalink
satellite/console: add free trial notices to emailreminders chore
Browse files Browse the repository at this point in the history
add logic to emailreminders.Chore to send emails to free tier users
whose trials have expired or will expire within a configured period. To
accomplish this, add two new configs:
email-reminders.enable-trial-expiration-reminders, a feature flag, and
email-reminders.trial-expiration-reminder, the amount of time before a
trial expires to send an expiration warning. Also add config
console.schedule-meeting-url to configure a URL to add to emails to
schedule a meeting with a storj rep and remove an unnecessary error
check in core.go setup email reminders block.

updates storj/storj-private#608

Change-Id: Iafdaa4f2e2b485504815bea6a0231416670521f8
  • Loading branch information
cam-a authored and Storj Robot committed Mar 18, 2024
1 parent fddfdd6 commit d3bf12f
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 68 deletions.
1 change: 1 addition & 0 deletions satellite/console/consoleweb/server.go
Expand Up @@ -72,6 +72,7 @@ type Config struct {
AuthCookieDomain string `help:"optional domain for cookies to use" default:""`

ContactInfoURL string `help:"url link to contacts page" default:"https://forum.storj.io"`
ScheduleMeetingURL string `help:"url link to schedule a meeting with a storj representative" default:"https://meetings.hubspot.com/tom144/meeting-with-tom-troy"`
LetUsKnowURL string `help:"url link to let us know page" default:"https://storjlabs.atlassian.net/servicedesk/customer/portals"`
SEO string `help:"used to communicate with web crawlers and other web robots" default:"User-agent: *\nDisallow: \nDisallow: /cgi-bin/"`
SatelliteName string `help:"used to display at web satellite console" default:"Storj"`
Expand Down
220 changes: 155 additions & 65 deletions satellite/console/emailreminders/chore.go
Expand Up @@ -9,6 +9,7 @@ import (
"time"

"github.com/spacemonkeygo/monkit/v3"
"github.com/zeebo/errs"
"go.uber.org/zap"

"storj.io/common/sync2"
Expand All @@ -23,10 +24,12 @@ var mon = monkit.Package()

// Config contains configurations for email reminders.
type Config struct {
FirstVerificationReminder time.Duration `help:"amount of time before sending first reminder to users who need to verify their email" default:"24h"`
SecondVerificationReminder time.Duration `help:"amount of time before sending second reminder to users who need to verify their email" default:"120h"`
ChoreInterval time.Duration `help:"how often to send reminders to users who need to verify their email" default:"24h"`
Enable bool `help:"enable sending emails reminding users to verify their email" default:"true"`
FirstVerificationReminder time.Duration `help:"amount of time before sending first reminder to users who need to verify their email" default:"24h"`
SecondVerificationReminder time.Duration `help:"amount of time before sending second reminder to users who need to verify their email" default:"120h"`
TrialExpirationReminder time.Duration `help:"amount of time before trial expiration to send trial expiration reminder" default:"72h"`
ChoreInterval time.Duration `help:"how often to send reminders to users who need to verify their email" default:"24h"`
EnableTrialExpirationReminders bool `help:"enable sending emails about trial expirations" default:"false"`
Enable bool `help:"enable sending emails reminding users to verify their email" default:"true"`
}

// Chore checks whether any emails need to be re-sent.
Expand All @@ -36,28 +39,32 @@ type Chore struct {
log *zap.Logger
Loop *sync2.Cycle

tokens *consoleauth.Service
usersDB console.Users
mailService *mailservice.Service
config Config
address string
useBlockingSend bool
tokens *consoleauth.Service
usersDB console.Users
mailService *mailservice.Service
config Config
address string
supportURL string
scheduleMeetingURL string
useBlockingSend bool
}

// NewChore instantiates Chore.
func NewChore(log *zap.Logger, tokens *consoleauth.Service, usersDB console.Users, mailservice *mailservice.Service, config Config, address string) *Chore {
func NewChore(log *zap.Logger, tokens *consoleauth.Service, usersDB console.Users, mailservice *mailservice.Service, config Config, address, supportURL, scheduleMeetingURL string) *Chore {
if !strings.HasSuffix(address, "/") {
address += "/"
}
return &Chore{
log: log,
Loop: sync2.NewCycle(config.ChoreInterval),
tokens: tokens,
usersDB: usersDB,
config: config,
mailService: mailservice,
address: address,
useBlockingSend: false,
log: log,
Loop: sync2.NewCycle(config.ChoreInterval),
tokens: tokens,
usersDB: usersDB,
config: config,
mailService: mailservice,
address: address,
supportURL: supportURL,
scheduleMeetingURL: scheduleMeetingURL,
useBlockingSend: false,
}
}

Expand All @@ -67,60 +74,120 @@ func (chore *Chore) Run(ctx context.Context) (err error) {
return chore.Loop.Run(ctx, func(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)

now := time.Now()
err = chore.sendVerificationReminders(ctx)
if err != nil {
chore.log.Error("sending email verification reminders", zap.Error(err))
}
if chore.config.EnableTrialExpirationReminders {
err = chore.sendExpirationNotifications(ctx)
if err != nil {
chore.log.Error("sending trial expiration notices", zap.Error(err))
}
}
return nil
})
}

func (chore *Chore) sendVerificationReminders(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)

now := time.Now()

// cutoff to avoid emailing users multiple times due to email duplicates in the DB.
// TODO: remove cutoff once duplicates are removed.
cutoff := now.Add(30 * (-24 * time.Hour))

users, err := chore.usersDB.GetUnverifiedNeedingReminder(ctx, now.Add(-chore.config.FirstVerificationReminder), now.Add(-chore.config.SecondVerificationReminder), cutoff)
if err != nil {
return errs.New("error getting users in need of verification reminder: %w", err)
}
mon.IntVal("unverified_needing_reminder").Observe(int64(len(users)))

// cutoff to avoid emailing users multiple times due to email duplicates in the DB.
// TODO: remove cutoff once duplicates are removed.
cutoff := now.Add(30 * (-24 * time.Hour))
for _, u := range users {
token, err := chore.tokens.CreateToken(ctx, u.ID, u.Email)

users, err := chore.usersDB.GetUnverifiedNeedingReminder(ctx, now.Add(-chore.config.FirstVerificationReminder), now.Add(-chore.config.SecondVerificationReminder), cutoff)
if err != nil {
chore.log.Error("error getting users in need of reminder", zap.Error(err))
return nil
return errs.New("error generating activation token: %w", err)
}
mon.IntVal("unverified_needing_reminder").Observe(int64(len(users)))
authController := consoleapi.NewAuth(chore.log, nil, nil, nil, nil, nil, "", chore.address, "", "", "", "", false, nil)

for _, u := range users {
token, err := chore.tokens.CreateToken(ctx, u.ID, u.Email)
link := authController.ActivateAccountURL + "?token=" + token

if err != nil {
chore.log.Error("error generating activation token", zap.Error(err))
return nil
}
authController := consoleapi.NewAuth(chore.log, nil, nil, nil, nil, nil, "", chore.address, "", "", "", "", false, nil)

link := authController.ActivateAccountURL + "?token=" + token

// blocking send allows us to verify that links are clicked in tests.
if chore.useBlockingSend {
err = chore.mailService.SendRendered(
ctx,
[]post.Address{{Address: u.Email}},
&console.AccountActivationEmail{
ActivationLink: link,
Origin: authController.ExternalAddress,
},
)
if err != nil {
chore.log.Error("error sending email reminder", zap.Error(err))
continue
}
} else {
chore.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: u.Email}},
&console.AccountActivationEmail{
ActivationLink: link,
Origin: authController.ExternalAddress,
},
)
}
if err = chore.usersDB.UpdateVerificationReminders(ctx, u.ID); err != nil {
chore.log.Error("error updating user's last email verifcation reminder", zap.Error(err))
}
err = chore.sendEmail(ctx, u.Email, &console.AccountActivationEmail{
ActivationLink: link,
Origin: authController.ExternalAddress,
})
if err != nil {
chore.log.Error("error sending verification reminder", zap.Error(err))
continue
}
if err = chore.usersDB.UpdateVerificationReminders(ctx, u.ID); err != nil {
chore.log.Error("error updating user's last email verifcation reminder", zap.Error(err))
}
}
return nil
}

func (chore *Chore) sendExpirationNotifications(ctx context.Context) (err error) {
mon.Task()(&ctx)(&err)

now := time.Now()

expiring := console.TrialExpirationReminder

// get free trial users needing reminder expiration is approaching.
users, err := chore.usersDB.GetExpiresBeforeWithStatus(ctx, console.NoTrialNotification, now.Add(chore.config.TrialExpirationReminder))
if err != nil {
chore.log.Error("error getting users in need of upcoming expiration warning", zap.Error(err))
return nil
})
}
mon.IntVal("expiring_needing_reminder").Observe(int64(len(users)))

expirationWarning := &console.TrialExpirationReminderEmail{
SignInLink: chore.address + "login?source=trial_expiring_notice",
Origin: chore.address,
ContactInfoURL: chore.supportURL,
ScheduleMeetingLink: chore.scheduleMeetingURL,
}

for _, u := range users {
if err := chore.sendEmail(ctx, u.Email, expirationWarning); err != nil {
chore.log.Error("error sending trial expiration reminder", zap.Error(err))
continue
}
if err = chore.usersDB.Update(ctx, u.ID, console.UpdateUserRequest{TrialNotifications: &expiring}); err != nil {
chore.log.Error("error updating user's trial_notifications", zap.Error(err))
}
}

expired := console.TrialExpired

// get free trial users needing notification that trial is expired
users, err = chore.usersDB.GetExpiresBeforeWithStatus(ctx, console.TrialExpirationReminder, now)
if err != nil {
chore.log.Error("error getting users in need of expiration notice", zap.Error(err))
return nil
}
mon.IntVal("expired_needing_notice").Observe(int64(len(users)))

expirationNotice := &console.TrialExpiredEmail{
SignInLink: chore.address + "login?source=trial_expired_notice",
Origin: chore.address,
ContactInfoURL: chore.supportURL,
ScheduleMeetingLink: chore.scheduleMeetingURL,
}
for _, u := range users {
if err := chore.sendEmail(ctx, u.Email, expirationNotice); err != nil {
chore.log.Error("error sending trial expiration reminder", zap.Error(err))
continue
}

if err = chore.usersDB.Update(ctx, u.ID, console.UpdateUserRequest{TrialNotifications: &expired}); err != nil {
chore.log.Error("error updating user's trial_notifications", zap.Error(err))
}
}

return nil
}

// Close closes chore.
Expand All @@ -129,6 +196,29 @@ func (chore *Chore) Close() error {
return nil
}

func (chore *Chore) sendEmail(ctx context.Context, email string, msg mailservice.Message) (err error) {
defer mon.Task()(&ctx)(&err)

// blocking send allows us to verify that links are clicked in tests.
if chore.useBlockingSend {
err = chore.mailService.SendRendered(
ctx,
[]post.Address{{Address: email}},
msg,
)
if err != nil {
return err
}
} else {
chore.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: email}},
msg,
)
}
return nil
}

// TestSetLinkAddress allows the email link address to be reconfigured.
// The address points to the satellite web server's external address.
// In the test environment the external address is not set by a config.
Expand Down
108 changes: 108 additions & 0 deletions satellite/console/emailreminders/chore_test.go
Expand Up @@ -5,6 +5,7 @@ package emailreminders_test

import (
"testing"
"time"

"github.com/stretchr/testify/require"
"go.uber.org/zap"
Expand Down Expand Up @@ -169,3 +170,110 @@ func TestEmailChoreLinkActivatesAccount(t *testing.T) {
require.Equal(t, console.UserStatus(1), u.Status)
})
}

func TestEmailChoreUpdatesTrialNotifications(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 0,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.EmailReminders.EnableTrialExpirationReminders = true
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
users := planet.Satellites[0].DB.Console().Users()
chore := planet.Satellites[0].Core.Mail.EmailReminders
chore.Loop.Pause()

// control group: paid tier user
id1 := testrand.UUID()
_, err := users.Insert(ctx, &console.User{
ID: id1,
FullName: "test",
Email: "userone@mail.test",
PasswordHash: []byte("password"),
})
require.NoError(t, err)
paidTier := true
require.NoError(t, users.Update(ctx, id1, console.UpdateUserRequest{PaidTier: &paidTier}))

now := time.Now()
tomorrow := now.Add(24 * time.Hour)
yesterday := now.Add(-24 * time.Hour)

// one expiring user
id2 := testrand.UUID()
_, err = users.Insert(ctx, &console.User{
ID: id2,
FullName: "test",
Email: "usertwo@mail.test",
PasswordHash: []byte("password"),
TrialExpiration: &tomorrow,
})
require.NoError(t, err)

// one expired user who was already reminded
id3 := testrand.UUID()
_, err = users.Insert(ctx, &console.User{
ID: id3,
FullName: "test",
Email: "usertwo@mail.test",
PasswordHash: []byte("password"),
TrialExpiration: &yesterday,
TrialNotifications: int(console.TrialExpirationReminder),
})
require.NoError(t, err)

reminded := console.TrialExpirationReminder

require.NoError(t, users.Update(ctx, id3, console.UpdateUserRequest{
TrialNotifications: &reminded,
}))

user1, err := users.Get(ctx, id1)
require.NoError(t, err)
require.True(t, user1.PaidTier)
require.Nil(t, user1.TrialExpiration)
require.Equal(t, int(console.NoTrialNotification), user1.TrialNotifications)

user2, err := users.Get(ctx, id2)
require.NoError(t, err)
require.False(t, user2.PaidTier)
require.Equal(t, tomorrow.Truncate(time.Millisecond), user2.TrialExpiration.Truncate(time.Millisecond))
require.Equal(t, int(console.NoTrialNotification), user2.TrialNotifications)

user3, err := users.Get(ctx, id3)
require.NoError(t, err)
require.False(t, user3.PaidTier)
require.Equal(t, yesterday.Truncate(time.Millisecond), user3.TrialExpiration.Truncate(time.Millisecond))
require.Equal(t, int(console.TrialExpirationReminder), user3.TrialNotifications)

chore.Loop.TriggerWait()

user1, err = users.Get(ctx, id1)
require.NoError(t, err)
require.Equal(t, int(console.NoTrialNotification), user1.TrialNotifications)

user2, err = users.Get(ctx, id2)
require.NoError(t, err)
require.Equal(t, int(console.TrialExpirationReminder), user2.TrialNotifications)

user3, err = users.Get(ctx, id3)
require.NoError(t, err)
require.Equal(t, int(console.TrialExpired), user3.TrialNotifications)

// run again to make sure values don't change.
chore.Loop.TriggerWait()

user1, err = users.Get(ctx, id1)
require.NoError(t, err)
require.Equal(t, int(console.NoTrialNotification), user1.TrialNotifications)

user2, err = users.Get(ctx, id2)
require.NoError(t, err)
require.Equal(t, int(console.TrialExpirationReminder), user2.TrialNotifications)

user3, err = users.Get(ctx, id3)
require.NoError(t, err)
require.Equal(t, int(console.TrialExpired), user3.TrialNotifications)
})
}

0 comments on commit d3bf12f

Please sign in to comment.