diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go
index 6edbfbab..724e1f7e 100644
--- a/backend/internal/bootstrap/router_bootstrap.go
+++ b/backend/internal/bootstrap/router_bootstrap.go
@@ -49,7 +49,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
- apiKeyService := service.NewApiKeyService(db)
+ apiKeyService := service.NewApiKeyService(db, emailService)
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
@@ -61,6 +61,7 @@ func initRouter(ctx context.Context, db *gorm.DB, appConfigService *service.AppC
job.RegisterLdapJobs(ctx, ldapService, appConfigService)
job.RegisterDbCleanupJobs(ctx, db)
job.RegisterFileCleanupJobs(ctx, db)
+ job.RegisterApiKeyExpiryJob(ctx, apiKeyService, appConfigService)
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, userService, jwtService)
diff --git a/backend/internal/dto/api_key_dto.go b/backend/internal/dto/api_key_dto.go
index 989176e9..e0945c58 100644
--- a/backend/internal/dto/api_key_dto.go
+++ b/backend/internal/dto/api_key_dto.go
@@ -11,12 +11,13 @@ type ApiKeyCreateDto struct {
}
type ApiKeyDto struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Description string `json:"description"`
- ExpiresAt datatype.DateTime `json:"expiresAt"`
- LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
- CreatedAt datatype.DateTime `json:"createdAt"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Description string `json:"description"`
+ ExpiresAt datatype.DateTime `json:"expiresAt"`
+ LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
+ CreatedAt datatype.DateTime `json:"createdAt"`
+ ExpirationEmailSent bool `json:"expirationEmailSent"`
}
type ApiKeyResponseDto struct {
diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go
new file mode 100644
index 00000000..5a44f369
--- /dev/null
+++ b/backend/internal/job/api_key_expiry_job.go
@@ -0,0 +1,49 @@
+package job
+
+import (
+ "context"
+ "log"
+
+ "github.com/go-co-op/gocron/v2"
+ "github.com/pocket-id/pocket-id/backend/internal/service"
+)
+
+type ApiKeyEmailJobs struct {
+ apiKeyService *service.ApiKeyService
+ appConfigService *service.AppConfigService
+}
+
+func RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *service.ApiKeyService, appConfigService *service.AppConfigService) {
+ jobs := &ApiKeyEmailJobs{
+ apiKeyService: apiKeyService,
+ appConfigService: appConfigService,
+ }
+
+ scheduler, err := gocron.NewScheduler()
+ if err != nil {
+ log.Fatalf("Failed to create a new scheduler: %v", err)
+ }
+
+ registerJob(ctx, scheduler, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys)
+
+ scheduler.Start()
+}
+
+func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
+
+ apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
+ if err != nil {
+ log.Printf("Failed to list expiring API keys: %v", err)
+ return err
+ }
+
+ for _, key := range apiKeys {
+ if key.User.Email == "" {
+ continue
+ }
+ if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil {
+ log.Printf("Failed to send email for key %s: %v", key.ID, err)
+ }
+ }
+ return nil
+}
diff --git a/backend/internal/model/api_key.go b/backend/internal/model/api_key.go
index 2fcf3703..c2ff9308 100644
--- a/backend/internal/model/api_key.go
+++ b/backend/internal/model/api_key.go
@@ -5,11 +5,12 @@ import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type ApiKey struct {
Base
- Name string `sortable:"true"`
- Key string
- Description *string
- ExpiresAt datatype.DateTime `sortable:"true"`
- LastUsedAt *datatype.DateTime `sortable:"true"`
+ Name string `sortable:"true"`
+ Key string
+ Description *string
+ ExpiresAt datatype.DateTime `sortable:"true"`
+ LastUsedAt *datatype.DateTime `sortable:"true"`
+ ExpirationEmailSent bool
UserID string
User User
diff --git a/backend/internal/service/api_key_service.go b/backend/internal/service/api_key_service.go
index 50b04e5c..4ae4c810 100644
--- a/backend/internal/service/api_key_service.go
+++ b/backend/internal/service/api_key_service.go
@@ -6,6 +6,7 @@ import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/utils/email"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
@@ -16,11 +17,12 @@ import (
)
type ApiKeyService struct {
- db *gorm.DB
+ db *gorm.DB
+ emailService *EmailService
}
-func NewApiKeyService(db *gorm.DB) *ApiKeyService {
- return &ApiKeyService{db: db}
+func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
+ return &ApiKeyService{db: db, emailService: emailService}
}
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
@@ -117,3 +119,47 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
return key.User, nil
}
+
+func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]model.ApiKey, error) {
+ var keys []model.ApiKey
+ now := time.Now()
+ cutoff := now.AddDate(0, 0, daysAhead)
+
+ err := s.db.
+ WithContext(ctx).
+ Preload("User").
+ Where("expires_at > ? AND expires_at <= ? AND expiration_email_sent = ?", datatype.DateTime(now), datatype.DateTime(cutoff), false).
+ Find(&keys).
+ Error
+
+ return keys, err
+}
+
+func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
+ user := apiKey.User
+
+ if user.ID == "" {
+ if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil {
+ return err
+ }
+ }
+
+ err := SendEmail(ctx, s.emailService, email.Address{
+ Name: user.FullName(),
+ Email: user.Email,
+ }, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
+ ApiKeyName: apiKey.Name,
+ ExpiresAt: apiKey.ExpiresAt.ToTime(),
+ Name: user.FirstName,
+ })
+ if err != nil {
+ return err
+ }
+
+ // Mark the API key as having had an expiration email sent
+ return s.db.WithContext(ctx).
+ Model(&model.ApiKey{}).
+ Where("id = ?", apiKey.ID).
+ Update("expiration_email_sent", true).
+ Error
+}
diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go
index bc123889..6b59fc26 100644
--- a/backend/internal/service/audit_log_service.go
+++ b/backend/internal/service/audit_log_service.go
@@ -90,7 +90,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
}
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
- Name: user.Username,
+ Name: user.FullName(),
Email: user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress,
diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go
index 8b7366c5..c140ab63 100644
--- a/backend/internal/service/email_service_templates.go
+++ b/backend/internal/service/email_service_templates.go
@@ -42,6 +42,13 @@ var TestTemplate = email.Template[struct{}]{
},
}
+var ApiKeyExpiringSoonTemplate = email.Template[ApiKeyExpiringSoonTemplateData]{
+ Path: "api-key-expiring-soon",
+ Title: func(data *email.TemplateData[ApiKeyExpiringSoonTemplateData]) string {
+ return fmt.Sprintf("API Key \"%s\" Expiring Soon", data.Data.ApiKeyName)
+ },
+}
+
type NewLoginTemplateData struct {
IPAddress string
Country string
@@ -56,5 +63,11 @@ type OneTimeAccessTemplateData = struct {
LoginLinkWithCode string
}
+type ApiKeyExpiringSoonTemplateData struct {
+ Name string
+ ApiKeyName string
+ ExpiresAt time.Time
+}
+
// this is list of all template paths used for preloading templates
-var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path}
+var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path}
diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go
index 63211de4..afd65231 100644
--- a/backend/internal/service/user_service.go
+++ b/backend/internal/service/user_service.go
@@ -399,7 +399,7 @@ func (s *UserService) RequestOneTimeAccessEmail(ctx context.Context, emailAddres
}
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
- Name: user.Username,
+ Name: user.FullName(),
Email: user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
diff --git a/backend/resources/email-templates/api-key-expiring-soon_html.tmpl b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl
new file mode 100644
index 00000000..f0632cbb
--- /dev/null
+++ b/backend/resources/email-templates/api-key-expiring-soon_html.tmpl
@@ -0,0 +1,17 @@
+{{ define "base" }}
+
+
+
API Key Expiring Soon
+
+ Hello {{ .Data.Name }},
+ This is a reminder that your API key {{ .Data.ApiKeyName }} will expire on {{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}.
+ Please generate a new API key if you need continued access.
+
+
+{{ end }}
\ No newline at end of file
diff --git a/backend/resources/email-templates/api-key-expiring-soon_text.tmpl b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl
new file mode 100644
index 00000000..7cc78715
--- /dev/null
+++ b/backend/resources/email-templates/api-key-expiring-soon_text.tmpl
@@ -0,0 +1,10 @@
+{{ define "base" -}}
+API Key Expiring Soon
+====================
+
+Hello {{ .Data.Name }},
+
+This is a reminder that your API key "{{ .Data.ApiKeyName }}" will expire on {{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}.
+
+Please generate a new API key if you need continued access.
+{{ end -}}
\ No newline at end of file
diff --git a/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.down.sql b/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.down.sql
new file mode 100644
index 00000000..f9e9ab44
--- /dev/null
+++ b/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE api_keys
+DROP COLUMN IF EXISTS expiration_email_sent;
\ No newline at end of file
diff --git a/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.up.sql b/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.up.sql
new file mode 100644
index 00000000..5530eddc
--- /dev/null
+++ b/backend/resources/migrations/postgres/20250417120000_add_expiration_email_sent_to_api_keys.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE api_keys
+ADD COLUMN expiration_email_sent BOOLEAN NOT NULL DEFAULT FALSE;
\ No newline at end of file
diff --git a/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.down.sql b/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.down.sql
new file mode 100644
index 00000000..16532c3a
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.down.sql
@@ -0,0 +1,2 @@
+ALTER TABLE api_keys
+DROP COLUMN expiration_email_sent;
\ No newline at end of file
diff --git a/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.up.sql b/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.up.sql
new file mode 100644
index 00000000..8f63aba6
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20250417120000_add_expiration_email_sent_to_api_keys.up.sql
@@ -0,0 +1,2 @@
+ALTER TABLE api_keys
+ADD COLUMN expiration_email_sent BOOLEAN NOT NULL DEFAULT 0;
\ No newline at end of file