From 3c30888d72bdd52785bba2ae35061748ca330cb6 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 19 May 2026 18:41:21 +0200 Subject: [PATCH 01/13] feat(service): update subscription.go --- api/internal/model/subscription.go | 26 +++++++++++++------------ api/internal/repository/subscription.go | 10 ++++++++++ api/internal/service/subscription.go | 16 ++++++++++++++- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/api/internal/model/subscription.go b/api/internal/model/subscription.go index bb2fad61..052e5aad 100644 --- a/api/internal/model/subscription.go +++ b/api/internal/model/subscription.go @@ -21,18 +21,20 @@ const ( ) type Subscription struct { - ID string `gorm:"unique" json:"id"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - UserID string `json:"-"` - Type string `json:"type"` - ActiveUntil time.Time `json:"active_until"` - IsActive bool `json:"-"` - Tier string `json:"tier"` - TokenHash string `gorm:"unique" json:"-"` - Notified bool `json:"-"` - Status SubscriptionStatus `gorm:"-" json:"status"` - Outage bool `gorm:"-" json:"outage"` + ID string `gorm:"unique" json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + UserID string `json:"-"` + Type string `json:"type"` + ActiveUntil time.Time `json:"active_until"` + IsActive bool `json:"-"` + Tier string `json:"tier"` + TokenHash string `gorm:"unique" json:"-"` + Notified bool `json:"-"` + Status SubscriptionStatus `gorm:"-" json:"status"` + Outage bool `gorm:"-" json:"outage"` + Terminated bool `json:"terminated"` + TerminatedAt time.Time `json:"terminated_at"` } func (s *Subscription) Active() bool { diff --git a/api/internal/repository/subscription.go b/api/internal/repository/subscription.go index bea472c5..9eb3bf27 100644 --- a/api/internal/repository/subscription.go +++ b/api/internal/repository/subscription.go @@ -17,6 +17,16 @@ func (d *Database) GetSubscription(ctx context.Context, userID string) (model.Su return subscription, q.Error } +func (d *Database) GetSubscriptionByTokenHash(ctx context.Context, tokenHash string) (model.Subscription, error) { + var subscription model.Subscription + q := d.Client.Where("token_hash = ?", tokenHash).Find(&subscription) + if q.RowsAffected == 0 { + return model.Subscription{}, fmt.Errorf("could not get subscription by token hash") + } + + return subscription, q.Error +} + func (d *Database) PostSubscription(ctx context.Context, sub model.Subscription) error { return d.Client.Create(&sub).Error } diff --git a/api/internal/service/subscription.go b/api/internal/service/subscription.go index 7461581c..bb378e59 100644 --- a/api/internal/service/subscription.go +++ b/api/internal/service/subscription.go @@ -26,6 +26,7 @@ var ( type SubscriptionStore interface { GetSubscription(context.Context, string) (model.Subscription, error) + GetSubscriptionByTokenHash(context.Context, string) (model.Subscription, error) PostSubscription(context.Context, model.Subscription) error UpdateSubscription(context.Context, model.Subscription) error DeleteSubscription(context.Context, string) error @@ -44,6 +45,19 @@ func (s *Service) GetSubscription(ctx context.Context, userID string) (model.Sub } func (s *Service) PostSubscription(ctx context.Context, userID string, preauth model.Preauth) error { + // Support for signup reset + existingSub, err := s.Store.GetSubscriptionByTokenHash(ctx, preauth.TokenHash) + if err == nil { + existingSub.TokenHash = "" + existingSub.Terminated = true + existingSub.TerminatedAt = time.Now() + err = s.Store.UpdateSubscription(ctx, existingSub) + if err != nil { + log.Printf("error clearing token hash for existing subscription: %s", err.Error()) + return ErrPostSubscription + } + } + sub := model.Subscription{ UserID: userID, ActiveUntil: preauth.ActiveUntil, @@ -53,7 +67,7 @@ func (s *Service) PostSubscription(ctx context.Context, userID string, preauth m } sub.ID = uuid.New().String() - err := s.Store.PostSubscription(ctx, sub) + err = s.Store.PostSubscription(ctx, sub) if err != nil { log.Printf("error posting subscription: %s", err.Error()) var mysqlErr *mysql.MySQLError From 563677e40eb58aa497be72d18ca7fd8ae2b98ea0 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 19 May 2026 18:49:54 +0200 Subject: [PATCH 02/13] feat(cron): update user.go --- api/internal/cron/cron.go | 6 ++++++ api/internal/cron/jobs/user.go | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/api/internal/cron/cron.go b/api/internal/cron/cron.go index 19372db8..9dba11d1 100644 --- a/api/internal/cron/cron.go +++ b/api/internal/cron/cron.go @@ -34,6 +34,12 @@ func New(db *gorm.DB) { // return // } + err = gocron.Every(1).Hour().Do(jobs.DeleteTerminatedUsers, db) + if err != nil { + log.Println("Error scheduling job:", err) + return + } + err = gocron.Every(1).Hour().Do(jobs.CleanupDeletedAliases, db) if err != nil { log.Println("Error scheduling job:", err) diff --git a/api/internal/cron/jobs/user.go b/api/internal/cron/jobs/user.go index 91610e84..9cafdd00 100644 --- a/api/internal/cron/jobs/user.go +++ b/api/internal/cron/jobs/user.go @@ -19,6 +19,38 @@ func DeleteUnverifiedUsers(db *gorm.DB) { deleteUsers(db, users) } +// Delete users with terminated subscriptions older than 2 days +func DeleteTerminatedUsers(db *gorm.DB) { + subscriptions := []model.Subscription{} + err := db.Where("terminated = ? AND terminated_at < NOW() - INTERVAL ? DAY", true, 2).Find(&subscriptions).Error + if err != nil { + log.Println("Error finding terminated subscriptions:", err) + return + } + + userIDs := make([]string, 0, len(subscriptions)) + seen := make(map[string]struct{}, len(subscriptions)) + for _, s := range subscriptions { + if _, ok := seen[s.UserID]; !ok { + seen[s.UserID] = struct{}{} + userIDs = append(userIDs, s.UserID) + } + } + + if len(userIDs) == 0 { + return + } + + users := []model.User{} + err = db.Where("id IN ?", userIDs).Find(&users).Error + if err != nil { + log.Println("Error finding terminated users:", err) + return + } + + deleteUsers(db, users) +} + func deleteUsers(db *gorm.DB, users []model.User) { for _, user := range users { ID := user.ID From c41452f274099284551e0b73b59ae3f7a60fed09 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 19 May 2026 18:55:43 +0200 Subject: [PATCH 03/13] refactor(service): update message.go --- api/internal/service/message.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/api/internal/service/message.go b/api/internal/service/message.go index eee19983..b70518b3 100644 --- a/api/internal/service/message.go +++ b/api/internal/service/message.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "slices" "ivpn.net/email/api/internal/model" ) @@ -92,9 +93,9 @@ func (s *Service) RemoveLastMessage(ctx context.Context, aliasId string, userId } var lastMessageID uint - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Type == typ { - lastMessageID = messages[i].ID + for _, m := range slices.Backward(messages) { + if m.Type == typ { + lastMessageID = m.ID break } } From cca8c4e79466722545b6dfbb88705fe955fb3990 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 26 May 2026 14:13:38 +0200 Subject: [PATCH 04/13] feat(cron): update user.go --- api/internal/cron/jobs/user.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/api/internal/cron/jobs/user.go b/api/internal/cron/jobs/user.go index bd5784de..6bec6976 100644 --- a/api/internal/cron/jobs/user.go +++ b/api/internal/cron/jobs/user.go @@ -2,6 +2,7 @@ package jobs import ( "log" + "time" "gorm.io/gorm" "ivpn.net/email/api/internal/model" @@ -10,7 +11,7 @@ import ( // Delete unverified users older than 7 days func DeleteUnverifiedUsers(db *gorm.DB) { users := []model.User{} - err := db.Where("is_active = ? AND created_at < NOW() - INTERVAL ? DAY", false, 7).Find(&users).Error + err := db.Where("is_active = ? AND created_at < ?", false, time.Now().AddDate(0, 0, -7)).Find(&users).Error if err != nil { log.Println("Error deleting unverified users:", err) return @@ -22,7 +23,7 @@ func DeleteUnverifiedUsers(db *gorm.DB) { // Delete users with terminated subscriptions older than 2 days func DeleteTerminatedUsers(db *gorm.DB) { subscriptions := []model.Subscription{} - err := db.Where("terminated = ? AND terminated_at < NOW() - INTERVAL ? DAY", true, 2).Find(&subscriptions).Error + err := db.Where("terminated = ? AND terminated_at < ?", true, time.Now().AddDate(0, 0, -2)).Find(&subscriptions).Error if err != nil { log.Println("Error finding terminated subscriptions:", err) return From d6b32110c3c2234d161f7b6e01cd05dee7b78b4c Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Tue, 26 May 2026 17:47:17 +0200 Subject: [PATCH 05/13] feat(cron): update user.go --- api/internal/cron/jobs/user.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/internal/cron/jobs/user.go b/api/internal/cron/jobs/user.go index 6bec6976..896080dc 100644 --- a/api/internal/cron/jobs/user.go +++ b/api/internal/cron/jobs/user.go @@ -23,7 +23,7 @@ func DeleteUnverifiedUsers(db *gorm.DB) { // Delete users with terminated subscriptions older than 2 days func DeleteTerminatedUsers(db *gorm.DB) { subscriptions := []model.Subscription{} - err := db.Where("terminated = ? AND terminated_at < ?", true, time.Now().AddDate(0, 0, -2)).Find(&subscriptions).Error + err := db.Where("`terminated` = ? AND `terminated_at` < ?", true, time.Now().AddDate(0, 0, -2)).Find(&subscriptions).Error if err != nil { log.Println("Error finding terminated subscriptions:", err) return From 5ff43c49d8d7ffbf2a68cae40b5f5449d867f3f0 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 28 May 2026 08:14:22 +0200 Subject: [PATCH 06/13] feat(model): update subscription.go --- api/internal/model/subscription.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/api/internal/model/subscription.go b/api/internal/model/subscription.go index 052e5aad..58e63f93 100644 --- a/api/internal/model/subscription.go +++ b/api/internal/model/subscription.go @@ -57,6 +57,10 @@ func (s *Subscription) LimitedAccess() bool { return false } +func (s *Subscription) PendingDelete() bool { + return s.Terminated +} + func (s *Subscription) ActiveStatus() bool { return s.Active() || s.GracePeriod() } @@ -78,6 +82,9 @@ func (s *Subscription) OutageGracePeriodDays(days int) bool { } func (s *Subscription) GetStatus() SubscriptionStatus { + if s.PendingDelete() { + return PendingDelete + } if s.Active() { return Active } From ba526c10d8324be11c8f42b002e63e15e554bab3 Mon Sep 17 00:00:00 2001 From: Juraj Hilje Date: Thu, 28 May 2026 08:19:05 +0200 Subject: [PATCH 07/13] feat(app): update AccountSubscription.vue --- app/src/components/AccountSubscription.vue | 17 +++++++++++++++++ .../components/AccountSubscriptionStatus.vue | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/app/src/components/AccountSubscription.vue b/app/src/components/AccountSubscription.vue index 62329109..c1d59db5 100644 --- a/app/src/components/AccountSubscription.vue +++ b/app/src/components/AccountSubscription.vue @@ -45,6 +45,19 @@ +
+
+
+ +
+
+

Pending Deletion

+

+ Your account is pending deletion. +

+
+
+
@@ -149,6 +162,10 @@ const isLimited = () => { return sub.value.status === 'limited_access' } +const isPendingDelete = () => { + return sub.value.status === 'pending_delete' +} + const isManaged = () => { return sub.value.type === 'Managed' } diff --git a/app/src/components/AccountSubscriptionStatus.vue b/app/src/components/AccountSubscriptionStatus.vue index a1590ba7..9882cff8 100644 --- a/app/src/components/AccountSubscriptionStatus.vue +++ b/app/src/components/AccountSubscriptionStatus.vue @@ -24,6 +24,19 @@
+
+
+
+ +
+
+

Pending Deletion

+

+ Your account is pending deletion. +

+
+
+