web/satellite: create chore that will resend verification emails to unverified users

We want to remind unverified users to verify their emails:
once after 24 hours has passed and again after 5 days has passed.

Add mailservice.Service to satellite core because it is needed by the
chore for sending emails. To add the mailservice.Service to the core,
we create a helper function in satellite/peer.go to avoid duplicating
the code in both api.go and core.go. In addition to the chore, this
change adds methods to users.DB to get unverified users in need of
reminder.

Change-Id: I4e515bdf43f922788b4f965b2efb34fa32288bd1
This commit is contained in:
Malcolm Bouzi 2022-01-06 14:46:53 -05:00 committed by Jeff Wendling
parent 836fa188fa
commit 087e57d037
13 changed files with 559 additions and 93 deletions

View File

@ -548,6 +548,8 @@ func (planet *Planet) newSatellite(ctx context.Context, prefix string, index int
return nil, err
}
peer.Mail.EmailReminders.TestSetLinkAddress("http://" + api.Console.Listener.Addr().String() + "/")
return createNewSystem(prefix, log, config, peer, api, repairerPeer, adminPeer, gcPeer), nil
}

View File

@ -8,8 +8,6 @@ import (
"errors"
"fmt"
"net"
"net/mail"
"net/smtp"
"runtime/pprof"
"github.com/spacemonkeygo/monkit/v3"
@ -27,8 +25,6 @@ import (
"storj.io/private/debug"
"storj.io/private/version"
"storj.io/storj/private/lifecycle"
"storj.io/storj/private/post"
"storj.io/storj/private/post/oauth2"
"storj.io/storj/private/server"
"storj.io/storj/private/version/checker"
"storj.io/storj/satellite/accounting"
@ -43,7 +39,6 @@ import (
"storj.io/storj/satellite/inspector"
"storj.io/storj/satellite/internalpb"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/mailservice/simulate"
"storj.io/storj/satellite/metabase"
"storj.io/storj/satellite/metainfo"
"storj.io/storj/satellite/metainfo/piecedeletion"
@ -464,66 +459,7 @@ func NewAPI(log *zap.Logger, full *identity.FullIdentity, db DB,
}
{ // setup mailservice
// TODO(yar): test multiple satellites using same OAUTH credentials
mailConfig := config.Mail
// validate from mail address
from, err := mail.ParseAddress(mailConfig.From)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}
// validate smtp server address
host, _, err := net.SplitHostPort(mailConfig.SMTPServerAddress)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}
var sender mailservice.Sender
switch mailConfig.AuthType {
case "oauth2":
creds := oauth2.Credentials{
ClientID: mailConfig.ClientID,
ClientSecret: mailConfig.ClientSecret,
TokenURI: mailConfig.TokenURI,
}
token, err := oauth2.RefreshToken(context.TODO(), creds, mailConfig.RefreshToken)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}
sender = &post.SMTPSender{
From: *from,
Auth: &oauth2.Auth{
UserEmail: from.Address,
Storage: oauth2.NewTokenStore(creds, *token),
},
ServerAddress: mailConfig.SMTPServerAddress,
}
case "plain":
sender = &post.SMTPSender{
From: *from,
Auth: smtp.PlainAuth("", mailConfig.Login, mailConfig.Password, host),
ServerAddress: mailConfig.SMTPServerAddress,
}
case "login":
sender = &post.SMTPSender{
From: *from,
Auth: post.LoginAuth{
Username: mailConfig.Login,
Password: mailConfig.Password,
},
ServerAddress: mailConfig.SMTPServerAddress,
}
default:
sender = simulate.NewDefaultLinkClicker(peer.Log.Named("mail:linkclicker"))
}
peer.Mail.Service, err = mailservice.New(
peer.Log.Named("mail:service"),
sender,
mailConfig.TemplatePath,
)
peer.Mail.Service, err = setupMailService(peer.Log, *config)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}

View File

@ -334,10 +334,6 @@ func (a *Auth) Register(w http.ResponseWriter, r *http.Request) {
UserName: userName,
},
)
if err = a.service.UpdateEmailVerificationReminder(ctx, time.Now().UTC()); err != nil {
a.serveJSONError(w, err)
}
}
// ValidateEmail validates email to have correct form and syntax.
@ -616,10 +612,6 @@ func (a *Auth) ResendEmail(w http.ResponseWriter, r *http.Request) {
UserName: userName,
},
)
if err = a.service.UpdateEmailVerificationReminder(ctx, time.Now().UTC()); err != nil {
a.serveJSONError(w, err)
}
}
// EnableUserMFA enables multi-factor authentication for the user.

View File

@ -0,0 +1,150 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
package emailreminders
import (
"context"
"strings"
"time"
"github.com/spacemonkeygo/monkit/v3"
"go.uber.org/zap"
"storj.io/common/sync2"
"storj.io/storj/private/post"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleauth"
"storj.io/storj/satellite/console/consoleweb/consoleapi"
"storj.io/storj/satellite/console/consoleweb/consoleql"
"storj.io/storj/satellite/mailservice"
)
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"`
}
// Chore checks whether any emails need to be re-sent.
//
// architecture: Chore
type Chore struct {
log *zap.Logger
Loop *sync2.Cycle
tokens *consoleauth.Service
usersDB console.Users
mailService *mailservice.Service
config Config
address 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 {
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,
}
}
// Run starts the chore.
func (chore *Chore) Run(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
return chore.Loop.Run(ctx, func(ctx context.Context) (err error) {
defer mon.Task()(&ctx)(&err)
now := time.Now()
users, err := chore.usersDB.GetUnverifiedNeedingReminder(ctx, now.Add(-chore.config.FirstVerificationReminder), now.Add(-chore.config.SecondVerificationReminder))
if err != nil {
chore.log.Error("error getting users in need of reminder", zap.Error(err))
return nil
}
mon.IntVal("unverified_needing_reminder").Observe(int64(len(users)))
for _, u := range users {
token, err := chore.tokens.CreateToken(ctx, u.ID, u.Email)
if err != nil {
chore.log.Error("error generating activation token", zap.Error(err))
return nil
}
authController := consoleapi.NewAuth(zap.L(), nil, nil, nil, nil, nil, chore.address, "", "", "")
link := authController.ActivateAccountURL + "?token=" + token
userName := u.ShortName
if u.ShortName == "" {
userName = u.FullName
}
// 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, Name: userName}},
&consoleql.AccountActivationEmail{
ActivationLink: link,
Origin: authController.ExternalAddress,
UserName: userName,
},
)
if err != nil {
chore.log.Error("error sending email reminder", zap.Error(err))
continue
}
} else {
chore.mailService.SendRenderedAsync(
ctx,
[]post.Address{{Address: u.Email, Name: userName}},
&consoleql.AccountActivationEmail{
ActivationLink: link,
Origin: authController.ExternalAddress,
UserName: userName,
},
)
}
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
})
}
// Close closes chore.
func (chore *Chore) Close() error {
chore.Loop.Close()
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.
// It is an internal address, and we don't know what the port is until after it
// has been assigned. With this method, we get the address from the api in testplanet
// and assign it here.
func (chore *Chore) TestSetLinkAddress(address string) {
chore.address = address
}
// TestUseBlockingSend allows us to set the chore to use a blocking send method.
// Using a blocking send method allows us to test that links are clicked without
// potential race conditions.
func (chore *Chore) TestUseBlockingSend() {
chore.useBlockingSend = true
}

View File

@ -0,0 +1,167 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package emailreminders_test
import (
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"storj.io/common/testcontext"
"storj.io/common/testrand"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
)
func TestEmailChoreUpdatesVerificationReminders(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.FirstVerificationReminder = 0
config.EmailReminders.SecondVerificationReminder = 0
},
},
}, 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()
// Overwrite link address in chore so the links don't work
// and we can test that the correct number of reminders are sent.
chore.TestSetLinkAddress("")
id1 := testrand.UUID()
_, err := users.Insert(ctx, &console.User{
ID: id1,
FullName: "test",
Email: "userone@mail.test",
PasswordHash: []byte("123a123"),
})
require.NoError(t, err)
id2 := testrand.UUID()
_, err = users.Insert(ctx, &console.User{
ID: id2,
FullName: "test",
Email: "usertwo@mail.test",
PasswordHash: []byte("123a123"),
})
require.NoError(t, err)
id3 := testrand.UUID()
_, err = users.Insert(ctx, &console.User{
ID: id3,
FullName: "test",
Email: "userthree@mail.test",
PasswordHash: []byte("123a123"),
})
require.NoError(t, err)
// This user will verify immediately and should not get reminders.
user1, err := users.Get(ctx, id1)
require.NoError(t, err)
require.Zero(t, user1.VerificationReminders)
// This user will get one reminder and then verify and should not get a second.
user2, err := users.Get(ctx, id2)
require.NoError(t, err)
require.Zero(t, user2.VerificationReminders)
// This user will not verify at all and should get 2 reminders and no more.
user3, err := users.Get(ctx, id3)
require.NoError(t, err)
require.Zero(t, user3.VerificationReminders)
user1.Status = 1
err = users.Update(ctx, user1)
require.NoError(t, err)
chore.Loop.TriggerWait()
user1, err = users.Get(ctx, id1)
require.NoError(t, err)
require.Zero(t, user1.VerificationReminders)
user2, err = users.Get(ctx, id2)
require.NoError(t, err)
require.Equal(t, 1, user2.VerificationReminders)
user3, err = users.Get(ctx, id3)
require.NoError(t, err)
require.Equal(t, 1, user3.VerificationReminders)
user2.Status = 1
err = users.Update(ctx, user2)
require.NoError(t, err)
chore.Loop.TriggerWait()
user1, err = users.Get(ctx, id1)
require.NoError(t, err)
require.Zero(t, user1.VerificationReminders)
user2, err = users.Get(ctx, id2)
require.NoError(t, err)
require.Equal(t, 1, user2.VerificationReminders)
user3, err = users.Get(ctx, id3)
require.NoError(t, err)
require.Equal(t, 2, user3.VerificationReminders)
// Check user is not reminded again after 2
chore.Loop.TriggerWait()
user1, err = users.Get(ctx, id1)
require.NoError(t, err)
require.Zero(t, user1.VerificationReminders)
user2, err = users.Get(ctx, id2)
require.NoError(t, err)
require.Equal(t, 1, user2.VerificationReminders)
user3, err = users.Get(ctx, id3)
require.NoError(t, err)
require.Equal(t, 2, user3.VerificationReminders)
})
}
func TestEmailChoreLinkActivatesAccount(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.FirstVerificationReminder = 0
config.EmailReminders.SecondVerificationReminder = 0
},
},
}, 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()
chore.TestUseBlockingSend()
id := testrand.UUID()
_, err := users.Insert(ctx, &console.User{
ID: id,
FullName: "test",
Email: "userone@mail.test",
PasswordHash: []byte("123a123"),
})
require.NoError(t, err)
u, err := users.Get(ctx, id)
require.NoError(t, err)
require.Equal(t, console.UserStatus(0), u.Status)
chore.Loop.TriggerWait()
u, err = users.Get(ctx, id)
require.NoError(t, err)
require.Equal(t, console.UserStatus(1), u.Status)
})
}

View File

@ -1064,21 +1064,6 @@ func (s *Service) UpdateAccount(ctx context.Context, fullName string, shortName
return nil
}
// UpdateEmailVerificationReminder updates the last time a user was sent a verification email.
func (s *Service) UpdateEmailVerificationReminder(ctx context.Context, t time.Time) (err error) {
defer mon.Task()(&ctx)(&err)
err = s.store.Users().Update(ctx, &User{
LastVerificationReminder: t,
})
if err != nil {
return Error.Wrap(err)
}
return nil
}
// ChangeEmail updates email for a given user.
func (s *Service) ChangeEmail(ctx context.Context, newEmail string) (err error) {
defer mon.Task()(&ctx)(&err)

View File

@ -20,6 +20,10 @@ import (
type Users interface {
// Get is a method for querying user from the database by id.
Get(ctx context.Context, id uuid.UUID) (*User, error)
// GetUnverifiedNeedingReminder gets unverified users needing a reminder to verify their email.
GetUnverifiedNeedingReminder(ctx context.Context, firstReminder, secondReminder time.Time) ([]*User, error)
// UpdateVerificationReminders increments verification_reminders.
UpdateVerificationReminders(ctx context.Context, id uuid.UUID) error
// GetByEmailWithUnverified is a method for querying users by email from the database.
GetByEmailWithUnverified(ctx context.Context, email string) (*User, []User, error)
// GetByEmail is a method for querying user by verified email from the database.

View File

@ -11,10 +11,12 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"storj.io/common/memory"
"storj.io/common/testcontext"
"storj.io/common/testrand"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/satellitedb/satellitedbtest"
@ -339,3 +341,64 @@ func TestGetUserByEmail(t *testing.T) {
require.Equal(t, activeUser.ID, dbUser.ID)
})
}
func TestGetUnverifiedNeedingReminder(t *testing.T) {
testplanet.Run(t, testplanet.Config{
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.EmailReminders.FirstVerificationReminder = 24 * time.Hour
config.EmailReminders.SecondVerificationReminder = 120 * time.Hour
},
},
SatelliteCount: 1,
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
var sentFirstReminder bool
var sentSecondReminder bool
config := planet.Satellites[0].Config.EmailReminders
db := planet.Satellites[0].DB.Console().Users()
id := testrand.UUID()
_, err := db.Insert(ctx, &console.User{
ID: id,
FullName: "unverified user one",
Email: "userone@mail.test",
PasswordHash: []byte("123a123"),
})
require.NoError(t, err)
now := time.Now()
// We expect two reminders in total - one after a day of account creation,
// and one after five. This test will check to ensure that both reminders occur.
// Each iteration advances time by i*24 hours from `now`.
for i := 0; i <= 6; i++ {
u, err := db.Get(ctx, id)
require.NoError(t, err)
// Intuitively it would be better to test this by setting `created_at` to some point in the past.
// Since we have no control over `created_at` (it's autoinserted) we will instead pass in a future time
// as the `now` argument to `GetUnverifiedNeedingReminder`
futureTime := now.Add(time.Duration(i*24) * time.Hour)
needReminder, err := db.GetUnverifiedNeedingReminder(ctx, futureTime.Add(-config.FirstVerificationReminder), futureTime.Add(-config.SecondVerificationReminder))
require.NoError(t, err)
// These are the conditions in the SQL query which selects users needing reminder
if u.VerificationReminders == 0 && u.CreatedAt.Before(futureTime.Add(-config.FirstVerificationReminder)) {
require.NotEmpty(t, needReminder)
require.Equal(t, u.ID, needReminder[0].ID)
require.NoError(t, db.UpdateVerificationReminders(ctx, u.ID))
sentFirstReminder = true
} else if u.VerificationReminders == 1 && u.CreatedAt.Before(futureTime.Add(-config.SecondVerificationReminder)) {
require.NotEmpty(t, needReminder)
require.Equal(t, u.ID, needReminder[0].ID)
require.NoError(t, db.UpdateVerificationReminders(ctx, u.ID))
sentSecondReminder = true
} else {
require.Empty(t, needReminder)
}
}
require.True(t, sentFirstReminder)
require.True(t, sentSecondReminder)
})
}

View File

@ -32,7 +32,10 @@ import (
"storj.io/storj/satellite/accounting/tally"
"storj.io/storj/satellite/audit"
"storj.io/storj/satellite/buckets"
"storj.io/storj/satellite/console/consoleauth"
"storj.io/storj/satellite/console/emailreminders"
"storj.io/storj/satellite/gracefulexit"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/metabase"
"storj.io/storj/satellite/metabase/segmentloop"
"storj.io/storj/satellite/metabase/zombiedeletion"
@ -66,6 +69,11 @@ type Core struct {
Service *version_checker.Service
}
Mail struct {
Service *mailservice.Service
EmailReminders *emailreminders.Chore
}
Debug struct {
Listener net.Listener
Server *debug.Server
@ -209,6 +217,40 @@ func New(log *zap.Logger, full *identity.FullIdentity, db DB,
peer.Dialer = rpc.NewDefaultDialer(tlsOptions)
}
{ // setup mailservice
peer.Mail.Service, err = setupMailService(peer.Log, *config)
if err != nil {
return nil, errs.Combine(err, peer.Close())
}
peer.Services.Add(lifecycle.Item{
Name: "mail:service",
Close: peer.Mail.Service.Close,
})
}
{ // setup email reminders
authTokens := consoleauth.NewService(config.ConsoleAuth, &consoleauth.Hmac{Secret: []byte(config.Console.AuthTokenSecret)})
if err != nil {
return nil, errs.Combine(err, peer.Close())
}
peer.Mail.EmailReminders = emailreminders.NewChore(
peer.Log.Named("console:chore"),
authTokens,
peer.DB.Console().Users(),
peer.Mail.Service,
config.EmailReminders,
config.Console.ExternalAddress,
)
peer.Services.Add(lifecycle.Item{
Name: "mail:email-reminders",
Run: peer.Mail.EmailReminders.Run,
Close: peer.Mail.EmailReminders.Close,
})
}
{ // setup overlay
peer.Overlay.DB = peer.DB.OverlayCache()
peer.Overlay.Service, err = overlay.NewService(peer.Log.Named("overlay"), peer.Overlay.DB, config.Overlay)

View File

@ -5,12 +5,18 @@ package satellite
import (
"context"
"net"
"net/mail"
"net/smtp"
hw "github.com/jtolds/monkit-hw/v2"
"github.com/spacemonkeygo/monkit/v3"
"go.uber.org/zap"
"storj.io/common/identity"
"storj.io/private/debug"
"storj.io/storj/private/post"
"storj.io/storj/private/post/oauth2"
"storj.io/storj/private/server"
version_checker "storj.io/storj/private/version/checker"
"storj.io/storj/satellite/accounting"
@ -28,11 +34,13 @@ import (
"storj.io/storj/satellite/console"
"storj.io/storj/satellite/console/consoleauth"
"storj.io/storj/satellite/console/consoleweb"
"storj.io/storj/satellite/console/emailreminders"
"storj.io/storj/satellite/console/restkeys"
"storj.io/storj/satellite/contact"
"storj.io/storj/satellite/gc"
"storj.io/storj/satellite/gracefulexit"
"storj.io/storj/satellite/mailservice"
"storj.io/storj/satellite/mailservice/simulate"
"storj.io/storj/satellite/metabase/zombiedeletion"
"storj.io/storj/satellite/metainfo"
"storj.io/storj/satellite/metainfo/expireddeletion"
@ -146,9 +154,10 @@ type Config struct {
Payments paymentsconfig.Config
RESTKeys restkeys.Config
Console consoleweb.Config
ConsoleAuth consoleauth.Config
RESTKeys restkeys.Config
Console consoleweb.Config
ConsoleAuth consoleauth.Config
EmailReminders emailreminders.Config
Version version_checker.Config
@ -162,3 +171,66 @@ type Config struct {
Analytics analytics.Config
}
func setupMailService(log *zap.Logger, config Config) (*mailservice.Service, error) {
// TODO(yar): test multiple satellites using same OAUTH credentials
mailConfig := config.Mail
// validate from mail address
from, err := mail.ParseAddress(mailConfig.From)
if err != nil {
return nil, err
}
// validate smtp server address
host, _, err := net.SplitHostPort(mailConfig.SMTPServerAddress)
if err != nil {
return nil, err
}
var sender mailservice.Sender
switch mailConfig.AuthType {
case "oauth2":
creds := oauth2.Credentials{
ClientID: mailConfig.ClientID,
ClientSecret: mailConfig.ClientSecret,
TokenURI: mailConfig.TokenURI,
}
token, err := oauth2.RefreshToken(context.TODO(), creds, mailConfig.RefreshToken)
if err != nil {
return nil, err
}
sender = &post.SMTPSender{
From: *from,
Auth: &oauth2.Auth{
UserEmail: from.Address,
Storage: oauth2.NewTokenStore(creds, *token),
},
ServerAddress: mailConfig.SMTPServerAddress,
}
case "plain":
sender = &post.SMTPSender{
From: *from,
Auth: smtp.PlainAuth("", mailConfig.Login, mailConfig.Password, host),
ServerAddress: mailConfig.SMTPServerAddress,
}
case "login":
sender = &post.SMTPSender{
From: *from,
Auth: post.LoginAuth{
Username: mailConfig.Login,
Password: mailConfig.Password,
},
ServerAddress: mailConfig.SMTPServerAddress,
}
default:
sender = simulate.NewDefaultLinkClicker(log.Named("mail:linkclicker"))
}
return mailservice.New(
log.Named("mail:service"),
sender,
mailConfig.TemplatePath,
)
}

View File

@ -33,7 +33,7 @@ type ConsoleDB struct {
// Users is getter a for Users repository.
func (db *ConsoleDB) Users() console.Users {
return &users{db.methods}
return &users{db.db}
}
// Projects is a getter for Projects repository.

View File

@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"strings"
"time"
"github.com/zeebo/errs"
@ -21,7 +22,7 @@ var _ console.Users = (*users)(nil)
// implementation of Users interface repository using spacemonkeygo/dbx orm.
type users struct {
db dbx.Methods
db *satelliteDB
}
// Get is a method for querying user from the database by id.
@ -75,6 +76,46 @@ func (users *users) GetByEmail(ctx context.Context, email string) (_ *console.Us
return userFromDBX(ctx, user)
}
// GetUnverifiedNeedingReminder returns users in need of a reminder to verify their email.
func (users *users) GetUnverifiedNeedingReminder(ctx context.Context, firstReminder, secondReminder time.Time) (usersNeedingReminder []*console.User, err error) {
defer mon.Task()(&ctx)(&err)
rows, err := users.db.Query(ctx, `
SELECT id, email, full_name, short_name
FROM users
WHERE status = 0
AND (
(verification_reminders = 0 AND created_at < $1)
OR (verification_reminders = 1 AND created_at < $2)
)
`, firstReminder, secondReminder)
if err != nil {
return nil, err
}
defer func() { err = errs.Combine(err, rows.Close()) }()
for rows.Next() {
var user console.User
err = rows.Scan(&user.ID, &user.Email, &user.FullName, &user.ShortName)
if err != nil {
return nil, err
}
usersNeedingReminder = append(usersNeedingReminder, &user)
}
return usersNeedingReminder, rows.Err()
}
// UpdateVerificationReminders increments verification_reminders.
func (users *users) UpdateVerificationReminders(ctx context.Context, id uuid.UUID) error {
_, err := users.db.ExecContext(ctx, `
UPDATE users
SET verification_reminders = verification_reminders + 1
WHERE id = $1
`, id.Bytes())
return err
}
// Insert is a method for inserting user into the database.
func (users *users) Insert(ctx context.Context, user *console.User) (_ *console.User, err error) {
defer mon.Task()(&ctx)(&err)

View File

@ -385,6 +385,18 @@ contact.external-address: ""
# If set, a path to write a process trace SVG to
# debug.trace-out: ""
# how often to send reminders to users who need to verify their email
# email-reminders.chore-interval: 24h0m0s
# enable sending emails reminding users to verify their email
# email-reminders.enable: true
# amount of time before sending first reminder to users who need to verify their email
# email-reminders.first-verification-reminder: 24h0m0s
# amount of time before sending second reminder to users who need to verify their email
# email-reminders.second-verification-reminder: 120h0m0s
# set if expired segment cleanup is enabled or not
# expired-deletion.enabled: true