Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add jobs to send email reminders and expire tokens #85

Merged
merged 1 commit into from
Mar 9, 2025
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ The configuration files are yaml mappings with the following values:
| `scheduler_jobs.due_frequency` | `5m` | The interval for sending regular notifications. |
| `scheduler_jobs.overdue_frequency` | `24h` | The interval for sending overdue notifications. |
| `scheduler_jobs.password_reset_validity` | `24h` | How long password reset tokens are valid for. |
| `token_expiration_reminder` | `72h` | How long before an app token expiration to send a reminder for it. |
| `email.host` | (empty) | The email server host. |
| `email.port` | (empty) | The email server port. |
| `email.email` | (empty) | The email address used for sending emails. |
Expand Down
7 changes: 4 additions & 3 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,10 @@ type ServerConfig struct {
}

type SchedulerConfig struct {
DueFrequency time.Duration `mapstructure:"due_frequency" yaml:"due_frequency" default:"5m"`
OverdueFrequency time.Duration `mapstructure:"overdue_frequency" yaml:"overdue_frequency" default:"1d"`
PasswordResetValidity time.Duration `mapstructure:"password_reset_validity" yaml:"password_reset_validity" default:"24h"`
DueFrequency time.Duration `mapstructure:"due_frequency" yaml:"due_frequency" default:"5m"`
OverdueFrequency time.Duration `mapstructure:"overdue_frequency" yaml:"overdue_frequency" default:"1d"`
PasswordResetValidity time.Duration `mapstructure:"password_reset_validity" yaml:"password_reset_validity" default:"24h"`
TokenExpirationReminder time.Duration `mapstructure:"token_expiration_reminder" yaml:"token_expiration_reminder" default:"72h"`
}

type EmailConfig struct {
Expand Down
5 changes: 3 additions & 2 deletions config/debug.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ server:
registration: true
debug: true
scheduler_jobs:
due_frequency: 5s
overdue_frequency: 10s
due_frequency: 1m
overdue_frequency: 1m
password_reset_validity: 1m
token_expiration_reminder: 1m
email:
host:
port:
Expand Down
1 change: 1 addition & 0 deletions config/prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ scheduler_jobs:
due_frequency: 5m
overdue_frequency: 24h
password_reset_validity: 24h
token_expiration_reminder: 72h
email:
host:
port:
Expand Down
3 changes: 2 additions & 1 deletion internal/models/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ const (
)

type AppToken struct {
ID int `json:"id" gorm:"primary_key;not null"`
ID int `json:"id" gorm:"primary_key"`
UserID int `json:"user_id" gorm:"column:user_id;not null"`
Name string `json:"name" gorm:"column:name;not null"`
Token string `json:"token" gorm:"column:token;index;not null"`
Scopes pq.StringArray `json:"scopes" gorm:"column:scopes;type:text[]"`
CreatedAt time.Time `json:"-" gorm:"column:created_at;default:CURRENT_TIMESTAMP"`
ExpiresAt time.Time `json:"expires_at" gorm:"column:expires_at;default:CURRENT_TIMESTAMP"`
User User `json:"-" gorm:"foreignKey:UserID"`
}
21 changes: 20 additions & 1 deletion internal/repos/user/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,25 @@ func (r *UserRepository) UpdatePasswordByUserId(c context.Context, userID int, p
}

func (r *UserRepository) DeleteStalePasswordResets(c context.Context) error {
now := time.Now()
now := time.Now().UTC()
return r.db.WithContext(c).Where("expiration_date <= ?", now).Delete(&models.UserPasswordReset{}).Error
}

func (r *UserRepository) GetAppTokensNearingExpiration(c context.Context, before time.Duration) ([]*models.AppToken, error) {
lowerBound := time.Now().UTC().Add(-before)
var tokens []*models.AppToken
if err := r.db.WithContext(c).
Where("expires_at > ?", lowerBound).
Where("expires_at <= ?", lowerBound.Add(before)).
Preload("User").
Find(&tokens).Error; err != nil {
return nil, err
}

return tokens, nil
}

func (r *UserRepository) DeleteStaleAppTokens(c context.Context) error {
now := time.Now().UTC()
return r.db.WithContext(c).Where("expires_at <= ?", now).Delete(&models.AppToken{}).Error
}
55 changes: 55 additions & 0 deletions internal/services/housekeeper/app_tokens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package housekeeper

import (
"context"

"dkhalife.com/tasks/core/config"
uRepo "dkhalife.com/tasks/core/internal/repos/user"
"dkhalife.com/tasks/core/internal/services/logging"
"dkhalife.com/tasks/core/internal/utils/email"
)

type AppTokenCleaner struct {
cfg *config.Config
uRepo *uRepo.UserRepository
es *email.EmailSender
}

func NewAppTokenCleaner(cfg *config.Config, ur *uRepo.UserRepository, es *email.EmailSender) *AppTokenCleaner {
return &AppTokenCleaner{
cfg: cfg,
uRepo: ur,
es: es,
}
}

func (prc *AppTokenCleaner) SendTokenExpirationReminder(c context.Context) error {
log := logging.FromContext(c)

tokens, err := prc.uRepo.GetAppTokensNearingExpiration(c, prc.cfg.SchedulerJobs.TokenExpirationReminder)
log.Debug("Tokens nearing expiration", " count ", len(tokens))

if err != nil {
return err
}

for _, token := range tokens {
log.Debug("Sending token expiration reminder", "email", token.User.Email, "token", token.Name)

err = prc.es.SendTokenExpirationReminder(c, token.Name, token.User.Email)
if err != nil {
log.Error("Failed to send token expiration reminder email", "email", token.User.Email, "error", err)
}
}

return nil
}

func (prc *AppTokenCleaner) CleanupExpiredTokens(c context.Context) error {
err := prc.uRepo.DeleteStaleAppTokens(c)
if err != nil {
return err
}

return nil
}
6 changes: 5 additions & 1 deletion internal/services/scheduler/scheduler.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,16 @@ type Scheduler struct {
stopChan chan bool
notifier *notifications.Notifier
passwordResetCleaner *housekeeper.PasswordResetCleaner
appTokenCleaner *housekeeper.AppTokenCleaner
config config.SchedulerConfig
}

func NewScheduler(cfg *config.Config, n *notifications.Notifier, prc *housekeeper.PasswordResetCleaner) *Scheduler {
func NewScheduler(cfg *config.Config, n *notifications.Notifier, prc *housekeeper.PasswordResetCleaner, atk *housekeeper.AppTokenCleaner) *Scheduler {
return &Scheduler{
stopChan: make(chan bool),
notifier: n,
passwordResetCleaner: prc,
appTokenCleaner: atk,
config: cfg.SchedulerJobs,
}
}
Expand All @@ -40,6 +42,8 @@ func (s *Scheduler) Start(c context.Context) {
go s.runScheduler(c, "NOTIFICATION_SENDER", s.notifier.LoadAndSendNotificationJob, s.config.DueFrequency)
go s.runScheduler(c, "NOTIFICATION_CLEANUP", s.notifier.CleanupSentNotifications, 2*s.config.DueFrequency)
go s.runScheduler(c, "PASSWORD_RESET_CLEANUP", s.passwordResetCleaner.CleanupStalePasswordResets, s.config.PasswordResetValidity)
go s.runScheduler(c, "TOKEN_EXPIRATION_REMINDER", s.appTokenCleaner.SendTokenExpirationReminder, s.config.TokenExpirationReminder)
go s.runScheduler(c, "TOKEN_EXPIRATION_CLEANUP", s.appTokenCleaner.CleanupExpiredTokens, time.Duration(24)*time.Hour)
}

func (s *Scheduler) runScheduler(c context.Context, jobName string, job func(c context.Context) error, interval time.Duration) {
Expand Down
26 changes: 26 additions & 0 deletions internal/utils/email/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,3 +137,29 @@ func (es *EmailSender) SendWelcomeEmail(c context.Context, name string, to strin

return nil
}

func (es *EmailSender) SendTokenExpirationReminder(c context.Context, tokenName string, to string) error {
err := es.validateConfig()
if err != nil {
return err
}

htmlBody := `
<html>
<body>
<p>Dear user,</p>
<p>Your Task Wizard access token '` + tokenName + `' is about to expire. Please log in to the application to generate a new token.</p>
<p>If you did not request a new token, please ignore this email.</p>
<p>Thank you,</p>
<p><strong>Task Wizard</strong></p>
</body>
</html>
`

err = es.sendEmail(c, to, "Task Wizard - Token Expiration Reminder", htmlBody)
if err != nil {
return err
}

return nil
}
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ func main() {
// add services
fx.Provide(notifier.NewNotifier),
fx.Provide(housekeeper.NewPasswordResetCleaner),
fx.Provide(housekeeper.NewAppTokenCleaner),

// Rate limiter
fx.Provide(utils.NewRateLimiter),
Expand Down