From bf62aa867aca3d67136d2fb5bc8356bb5d63417f Mon Sep 17 00:00:00 2001 From: kicher-erbse Date: Wed, 15 Apr 2026 14:40:42 +0200 Subject: [PATCH 1/3] user reactivation and confirmation flow --- db/repository.user.go | 73 +++++++++++++++++++++++++++ domain/model.go | 7 ++- domain/ports.go | 4 ++ go.mod | 12 ++--- go.sum | 19 ++++--- service/fakes_test.go | 96 +++++++++++++++++++++++++++++++----- service/subscription.go | 76 +++++++++++++++++++++++----- service/subscription_test.go | 94 +++++++++++++++++++++++++++++++++++ 8 files changed, 338 insertions(+), 43 deletions(-) diff --git a/db/repository.user.go b/db/repository.user.go index f2575bb..97d3696 100644 --- a/db/repository.user.go +++ b/db/repository.user.go @@ -65,6 +65,63 @@ func (r *MailingListRepository) ConfirmUser(ctx context.Context, userID uint) er return nil } +func (r *MailingListRepository) GetUserByEmail(ctx context.Context, mailingListName, email string) (*domain.User, error) { + var user User + + result := r.db.WithContext(ctx). + Where("mailing_list_name = ? AND email = ?", mailingListName, email). + First(&user) + if result.Error != nil { + return nil, fmt.Errorf("get user by email: %w", result.Error) + } + + return ToDomainUser(&user), nil +} + +func (r *MailingListRepository) GetUnsubscribedUserByEmail(ctx context.Context, mailingListName, email string) (*domain.User, error) { + var user User + + result := r.db.WithContext(ctx). + Unscoped(). + Where("mailing_list_name = ? AND email = ? AND deleted_at IS NOT NULL", mailingListName, email). + First(&user) + if result.Error != nil { + return nil, fmt.Errorf("get unsubscribed user by email: %w", result.Error) + } + + return ToDomainUser(&user), nil +} + +func (r *MailingListRepository) ReactivateUser(ctx context.Context, userID uint, name, unsubscribeToken string) (*domain.User, error) { + result := r.db.WithContext(ctx). + Unscoped(). + Model(&User{}). + Where("id = ?", userID). + Updates(map[string]any{ + "deleted_at": nil, + "confirmed_at": nil, + "name": name, + "unsubscribe_token": unsubscribeToken, + }) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to reactivate user", + slog.Uint64("user_id", uint64(userID)), + slog.Any("error", result.Error), + ) + return nil, fmt.Errorf("reactivate user: %w", result.Error) + } + + var user User + if err := r.db.WithContext(ctx).First(&user, userID).Error; err != nil { + return nil, fmt.Errorf("reactivate user fetch: %w", err) + } + + r.logger.InfoContext(ctx, "reactivated user", + slog.Uint64("user_id", uint64(userID)), + ) + return ToDomainUser(&user), nil +} + func (r *MailingListRepository) GetUserByUnsubscribeToken(ctx context.Context, token string) (*domain.User, error) { var user User @@ -173,6 +230,22 @@ func (r *MailingListRepository) GetConfirmationByToken(ctx context.Context, toke return ToDomainConfirmation(&c), nil } +func (r *MailingListRepository) DeleteConfirmationsByUserID(ctx context.Context, userID uint) error { + result := r.db.WithContext(ctx).Where("user_id = ?", userID).Delete(&Confirmation{}) + if result.Error != nil { + r.logger.ErrorContext(ctx, "failed to delete confirmations by user ID", + slog.Uint64("user_id", uint64(userID)), + slog.Any("error", result.Error), + ) + return fmt.Errorf("delete confirmations by user ID: %w", result.Error) + } + r.logger.InfoContext(ctx, "deleted confirmations for user", + slog.Uint64("user_id", uint64(userID)), + slog.Int64("count", result.RowsAffected), + ) + return nil +} + func (r *MailingListRepository) DeleteConfirmation(ctx context.Context, id uint) error { result := r.db.WithContext(ctx).Delete(&Confirmation{}, id) if result.Error != nil { diff --git a/domain/model.go b/domain/model.go index 0f11bc5..62f4841 100644 --- a/domain/model.go +++ b/domain/model.go @@ -1,6 +1,11 @@ package domain -import "time" +import ( + "errors" + "time" +) + +var ErrUserAlreadyConfirmed = errors.New("user already confirmed") type MailingList struct { Name string diff --git a/domain/ports.go b/domain/ports.go index b3dd833..d7de350 100644 --- a/domain/ports.go +++ b/domain/ports.go @@ -13,16 +13,20 @@ type MailingListRepository interface { type UserRepository interface { AddUser(ctx context.Context, mailingListName string, name, email, unsubscribeToken string) (*User, error) ConfirmUser(ctx context.Context, userID uint) error + GetUserByEmail(ctx context.Context, mailingListName, email string) (*User, error) + GetUnsubscribedUserByEmail(ctx context.Context, mailingListName, email string) (*User, error) GetUserByUnsubscribeToken(ctx context.Context, token string) (*User, error) GetUsers(ctx context.Context, mailingListName string) ([]User, error) GetConfirmedUsers(ctx context.Context, mailingListName string) ([]User, error) RemoveUser(ctx context.Context, userID uint) error + ReactivateUser(ctx context.Context, userID uint, name, unsubscribeToken string) (*User, error) } type ConfirmationRepository interface { CreateConfirmation(ctx context.Context, userID uint, token string) (*Confirmation, error) GetConfirmationByToken(ctx context.Context, token string) (*Confirmation, error) DeleteConfirmation(ctx context.Context, id uint) error + DeleteConfirmationsByUserID(ctx context.Context, userID uint) error } type TopicRepository interface { diff --git a/go.mod b/go.mod index a45e9ae..b2b2a91 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,9 @@ module github.com/5000K/5000mails go 1.26.0 require ( + github.com/ilyakaznacheev/cleanenv v1.5.0 + github.com/wneessen/go-mail v0.7.2 + github.com/yuin/goldmark v1.8.2 gopkg.in/yaml.v3 v3.0.1 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 @@ -13,10 +16,7 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/BurntSushi/toml v1.2.1 // indirect - github.com/go-co-op/gocron/v2 v2.20.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/ilyakaznacheev/cleanenv v1.5.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -24,12 +24,10 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/joho/godotenv v1.5.1 // indirect - github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/kr/text v0.2.0 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/stretchr/testify v1.11.1 // indirect - github.com/wneessen/go-mail v0.7.2 // indirect - github.com/yuin/goldmark v1.8.2 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/text v0.36.0 // indirect diff --git a/go.sum b/go.sum index 357bb61..cb20c91 100644 --- a/go.sum +++ b/go.sum @@ -2,15 +2,12 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak= github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc= -github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= @@ -27,19 +24,19 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= -github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= -github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/wneessen/go-mail v0.7.2 h1:xxPnhZ6IZLSgxShebmZ6DPKh1b6OJcoHfzy7UjOkzS8= @@ -53,6 +50,8 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/service/fakes_test.go b/service/fakes_test.go index 9df6494..78850e8 100644 --- a/service/fakes_test.go +++ b/service/fakes_test.go @@ -83,19 +83,27 @@ func (r *fakeListRepo) DeleteList(_ context.Context, name string) error { } type fakeUserRepo struct { - users map[uint]*domain.User - nextID uint + users map[uint]*domain.User + deletedUsers map[uint]*domain.User + nextID uint - addErr error - confirmErr error - getByUnsubscribeTokenErr error - getUsersErr error - getConfirmedErr error - removeErr error + addErr error + confirmErr error + getByEmailErr error + getUnsubscribedByEmailErr error + getByUnsubscribeTokenErr error + getUsersErr error + getConfirmedErr error + removeErr error + reactivateErr error } func newFakeUserRepo(seed ...*domain.User) *fakeUserRepo { - r := &fakeUserRepo{users: make(map[uint]*domain.User), nextID: 1} + r := &fakeUserRepo{ + users: make(map[uint]*domain.User), + deletedUsers: make(map[uint]*domain.User), + nextID: 1, + } for _, u := range seed { r.users[u.ID] = u if u.ID >= r.nextID { @@ -105,6 +113,13 @@ func newFakeUserRepo(seed ...*domain.User) *fakeUserRepo { return r } +func (r *fakeUserRepo) seedDeleted(u *domain.User) { + r.deletedUsers[u.ID] = u + if u.ID >= r.nextID { + r.nextID = u.ID + 1 + } +} + func (r *fakeUserRepo) AddUser(_ context.Context, mailingListName string, name, email, unsubscribeToken string) (*domain.User, error) { if r.addErr != nil { return nil, r.addErr @@ -166,14 +181,56 @@ func (r *fakeUserRepo) GetConfirmedUsers(_ context.Context, mailingListName stri return out, nil } +func (r *fakeUserRepo) GetUserByEmail(_ context.Context, mailingListName, email string) (*domain.User, error) { + if r.getByEmailErr != nil { + return nil, r.getByEmailErr + } + for _, u := range r.users { + if u.MailingListName == mailingListName && u.Email == email { + return u, nil + } + } + return nil, fmt.Errorf("user with email %q in list %q not found", email, mailingListName) +} + +func (r *fakeUserRepo) GetUnsubscribedUserByEmail(_ context.Context, mailingListName, email string) (*domain.User, error) { + if r.getUnsubscribedByEmailErr != nil { + return nil, r.getUnsubscribedByEmailErr + } + for _, u := range r.deletedUsers { + if u.MailingListName == mailingListName && u.Email == email { + return u, nil + } + } + return nil, fmt.Errorf("unsubscribed user with email %q in list %q not found", email, mailingListName) +} + +func (r *fakeUserRepo) ReactivateUser(_ context.Context, userID uint, name, unsubscribeToken string) (*domain.User, error) { + if r.reactivateErr != nil { + return nil, r.reactivateErr + } + u, ok := r.deletedUsers[userID] + if !ok { + return nil, fmt.Errorf("deleted user %d not found", userID) + } + u.Name = name + u.UnsubscribeToken = unsubscribeToken + u.ConfirmedAt = nil + delete(r.deletedUsers, userID) + r.users[userID] = u + return u, nil +} + func (r *fakeUserRepo) RemoveUser(_ context.Context, userID uint) error { if r.removeErr != nil { return r.removeErr } - if _, ok := r.users[userID]; !ok { + u, ok := r.users[userID] + if !ok { return fmt.Errorf("user %d not found", userID) } delete(r.users, userID) + r.deletedUsers[userID] = u return nil } @@ -181,9 +238,10 @@ type fakeConfirmationRepo struct { confirmations map[uint]*domain.Confirmation nextID uint - createErr error - getErr error - deleteErr error + createErr error + getErr error + deleteErr error + deleteByUserIDErr error } func newFakeConfirmationRepo(seed ...*domain.Confirmation) *fakeConfirmationRepo { @@ -230,6 +288,18 @@ func (r *fakeConfirmationRepo) DeleteConfirmation(_ context.Context, id uint) er return nil } +func (r *fakeConfirmationRepo) DeleteConfirmationsByUserID(_ context.Context, userID uint) error { + if r.deleteByUserIDErr != nil { + return r.deleteByUserIDErr + } + for id, c := range r.confirmations { + if c.UserID == userID { + delete(r.confirmations, id) + } + } + return nil +} + type fakeTopicRepo struct { topics map[uint]*domain.Topic userTopics map[uint]map[uint]bool diff --git a/service/subscription.go b/service/subscription.go index c45dd0b..8159820 100644 --- a/service/subscription.go +++ b/service/subscription.go @@ -49,27 +49,35 @@ func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, return nil, fmt.Errorf("mailing list %q not found: %w", listName, err) } - unsubToken, err := generateToken() - if err != nil { - return nil, fmt.Errorf("generating unsubscribe token: %w", err) + if activeUser, err := s.users.GetUserByEmail(ctx, list.Name, email); err == nil { + if activeUser.IsConfirmed() { + return nil, fmt.Errorf("subscribing %q to %q: %w", email, listName, domain.ErrUserAlreadyConfirmed) + } + if err := s.resendConfirmation(ctx, activeUser, listName); err != nil { + return nil, err + } + return activeUser, nil } - user, err := s.users.AddUser(ctx, list.Name, userName, email, unsubToken) - if err != nil { - return nil, fmt.Errorf("adding user to list %q: %w", listName, err) + if deletedUser, err := s.users.GetUnsubscribedUserByEmail(ctx, list.Name, email); err == nil { + return s.reactivateAndConfirm(ctx, deletedUser, list.Name, userName, topicNames) } - if err := s.subscribeToTopics(ctx, user.ID, list.Name, topicNames); err != nil { - return nil, fmt.Errorf("subscribing user to topics: %w", err) + return s.createAndConfirm(ctx, list.Name, userName, email, topicNames) +} + +func (s *SubscriptionService) resendConfirmation(ctx context.Context, user *domain.User, listName string) error { + if err := s.confirmations.DeleteConfirmationsByUserID(ctx, user.ID); err != nil { + return fmt.Errorf("clearing old confirmations for user %d: %w", user.ID, err) } token, err := generateToken() if err != nil { - return nil, fmt.Errorf("generating confirmation token: %w", err) + return fmt.Errorf("generating confirmation token: %w", err) } if _, err := s.confirmations.CreateConfirmation(ctx, user.ID, token); err != nil { - return nil, fmt.Errorf("creating confirmation for user %d: %w", user.ID, err) + return fmt.Errorf("creating confirmation for user %d: %w", user.ID, err) } metadata, body, err := s.renderer.Render(&s.confirmMail, map[string]any{ @@ -79,11 +87,55 @@ func (s *SubscriptionService) Subscribe(ctx context.Context, listName, userName, "Recipient": *user, }) if err != nil { - return nil, fmt.Errorf("rendering confirmation mail: %w", err) + return fmt.Errorf("rendering confirmation mail: %w", err) } if err := s.sender.SendMail(ctx, metadata, body, *user); err != nil { - return nil, fmt.Errorf("sending confirmation mail to %q: %w", email, err) + return fmt.Errorf("sending confirmation mail to %q: %w", user.Email, err) + } + + return nil +} + +func (s *SubscriptionService) reactivateAndConfirm(ctx context.Context, deletedUser *domain.User, listName, userName string, topicNames []string) (*domain.User, error) { + unsubToken, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generating unsubscribe token: %w", err) + } + + user, err := s.users.ReactivateUser(ctx, deletedUser.ID, userName, unsubToken) + if err != nil { + return nil, fmt.Errorf("reactivating user %d: %w", deletedUser.ID, err) + } + + if err := s.subscribeToTopics(ctx, user.ID, listName, topicNames); err != nil { + return nil, fmt.Errorf("subscribing user to topics: %w", err) + } + + if err := s.resendConfirmation(ctx, user, listName); err != nil { + return nil, err + } + + return user, nil +} + +func (s *SubscriptionService) createAndConfirm(ctx context.Context, listName, userName, email string, topicNames []string) (*domain.User, error) { + unsubToken, err := generateToken() + if err != nil { + return nil, fmt.Errorf("generating unsubscribe token: %w", err) + } + + user, err := s.users.AddUser(ctx, listName, userName, email, unsubToken) + if err != nil { + return nil, fmt.Errorf("adding user to list %q: %w", listName, err) + } + + if err := s.subscribeToTopics(ctx, user.ID, listName, topicNames); err != nil { + return nil, fmt.Errorf("subscribing user to topics: %w", err) + } + + if err := s.resendConfirmation(ctx, user, listName); err != nil { + return nil, err } return user, nil diff --git a/service/subscription_test.go b/service/subscription_test.go index b2b214b..b8bc79e 100644 --- a/service/subscription_test.go +++ b/service/subscription_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "testing" + "time" "github.com/5000K/5000mails/domain" ) @@ -78,6 +79,99 @@ func TestSubscriptionService_Subscribe(t *testing.T) { } }) + t.Run("resends confirmation when user exists but is unconfirmed", func(t *testing.T) { + existing := &domain.User{ID: 1, Name: "Alice", Email: "alice@example.com", MailingListName: "weekly", UnsubscribeToken: "unsub-tok"} + users := newFakeUserRepo(existing) + confs := newFakeConfirmationRepo(&domain.Confirmation{ID: 1, UserID: 1, Token: "old-tok"}) + sender := &fakeSender{} + metadata := domain.MailMetadata{Subject: "Confirm"} + svc := newSubscriptionSvc(newFakeListRepo(list), users, confs, newFakeTopicRepo(), &fakeRenderer{metadata: metadata, body: "click"}, sender) + + user, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.ID != existing.ID { + t.Errorf("expected existing user, got ID=%d", user.ID) + } + if len(confs.confirmations) != 1 { + t.Errorf("expected 1 confirmation (old replaced), got %d", len(confs.confirmations)) + } + if len(sender.calls) != 1 { + t.Errorf("expected 1 send call, got %d", len(sender.calls)) + } + }) + + t.Run("returns ErrUserAlreadyConfirmed when user is already confirmed", func(t *testing.T) { + now := time.Now() + existing := &domain.User{ID: 1, Name: "Alice", Email: "alice@example.com", MailingListName: "weekly", UnsubscribeToken: "unsub-tok", ConfirmedAt: &now} + users := newFakeUserRepo(existing) + svc := newSubscriptionSvc(newFakeListRepo(list), users, newFakeConfirmationRepo(), newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) + + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) + if !errors.Is(err, domain.ErrUserAlreadyConfirmed) { + t.Errorf("expected ErrUserAlreadyConfirmed, got: %v", err) + } + }) + + t.Run("reactivates unsubscribed user and sends new confirmation", func(t *testing.T) { + deleted := &domain.User{ID: 5, Name: "Alice", Email: "alice@example.com", MailingListName: "weekly", UnsubscribeToken: "old-unsub"} + users := newFakeUserRepo() + users.seedDeleted(deleted) + confs := newFakeConfirmationRepo() + sender := &fakeSender{} + metadata := domain.MailMetadata{Subject: "Confirm"} + svc := newSubscriptionSvc(newFakeListRepo(list), users, confs, newFakeTopicRepo(), &fakeRenderer{metadata: metadata, body: "click"}, sender) + + user, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if user.ID != deleted.ID { + t.Errorf("expected reactivated user ID=%d, got ID=%d", deleted.ID, user.ID) + } + if _, stillDeleted := users.deletedUsers[deleted.ID]; stillDeleted { + t.Error("expected user to be moved out of deleted users") + } + if _, active := users.users[deleted.ID]; !active { + t.Error("expected user to be in active users") + } + if user.UnsubscribeToken == "old-unsub" { + t.Error("expected unsubscribe token to be refreshed") + } + if len(sender.calls) != 1 { + t.Errorf("expected 1 send call, got %d", len(sender.calls)) + } + }) + + t.Run("returns error when resend confirmation DeleteConfirmationsByUserID fails", func(t *testing.T) { + existing := &domain.User{ID: 1, Name: "Alice", Email: "alice@example.com", MailingListName: "weekly", UnsubscribeToken: "unsub-tok"} + users := newFakeUserRepo(existing) + confs := newFakeConfirmationRepo() + deleteErr := errors.New("db delete failed") + confs.deleteByUserIDErr = deleteErr + svc := newSubscriptionSvc(newFakeListRepo(list), users, confs, newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) + + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) + if !errors.Is(err, deleteErr) { + t.Errorf("expected wrapped delete error, got: %v", err) + } + }) + + t.Run("returns error when ReactivateUser fails", func(t *testing.T) { + deleted := &domain.User{ID: 5, Name: "Alice", Email: "alice@example.com", MailingListName: "weekly", UnsubscribeToken: "old-unsub"} + users := newFakeUserRepo() + users.seedDeleted(deleted) + reactivateErr := errors.New("reactivate failed") + users.reactivateErr = reactivateErr + svc := newSubscriptionSvc(newFakeListRepo(list), users, newFakeConfirmationRepo(), newFakeTopicRepo(), &fakeRenderer{}, &fakeSender{}) + + _, err := svc.Subscribe(context.Background(), "weekly", "Alice", "alice@example.com", nil) + if !errors.Is(err, reactivateErr) { + t.Errorf("expected wrapped reactivate error, got: %v", err) + } + }) + t.Run("returns error when GetListByName fails", func(t *testing.T) { repo := newFakeListRepo() repo.getByNameErr = errors.New("list missing") From 843ea5ef4e84b9b1330c29d717973c8b73721f4c Mon Sep 17 00:00:00 2001 From: kicher-erbse Date: Wed, 15 Apr 2026 17:52:16 +0200 Subject: [PATCH 2/3] add preview URL and footer to email templates; refactor recipient data handling --- docs/TEMPLATE.md | 52 +++++++++++++++++++++------------------ service/mail.go | 58 +++++++++++++++++++++++--------------------- service/mail_test.go | 16 ++++++++++++ static/template.html | 25 +++++++++++++++++++ 4 files changed, 99 insertions(+), 52 deletions(-) diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index c88d2da..befce78 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -13,18 +13,20 @@ Both stages share the same data map, which is populated automatically with the v The table shows which variables are automatically injected in each sending context. Custom variables passed via `data` are always available on top of these. -| Variable | Type | Confirm mail | Mail to list | Test mail | Description | -| ---------------------------- | ------------- | :----------: | :----------: | :-------: | ---------------------------------------------------------------------------- | -| `Recipient` | `domain.User` | ✓ | ✓ | ✓ | The recipient of this mail (see fields below) | -| `Recipient.ID` | `uint` | ✓ | ✓ | ✓ | Database ID of the subscriber | -| `Recipient.Name` | `string` | ✓ | ✓ | ✓ | Display name | -| `Recipient.Email` | `string` | ✓ | ✓ | ✓ | Email address | -| `Recipient.MailingListName` | `string` | ✓ | ✓ | ✓ | Name of the mailing list the subscriber belongs to | -| `Recipient.UnsubscribeToken` | `string` | ✓ | ✓ | ✓ | Opaque token used to build unsubscribe links | -| `Recipient.ConfirmedAt` | `*time.Time` | ✗¹ | ✓ | ✗ | Timestamp of double opt-in confirmation (`nil` if unconfirmed) | -| `confirmURL` | `string` | ✓ | ✗ | ✗ | Full URL the subscriber must visit to confirm (`baseURL/confirm/`) | -| `token` | `string` | ✓ | ✗ | ✗ | Raw confirmation token (same value as the last path segment of `confirmURL`) | -| `unsubscribeURL` | `string` | ✗ | ✓ | ✗ | Full URL to unsubscribe (`baseURL/unsubscribe/`) | +| Variable | Type | Confirm mail | Mail to list | Test mail | Description | +| ---------------------------- | ------------- | :----------: | :----------: | :-------: | ------------------------------------------------------------------------------------------------------------------------------------- | +| `Recipient` | `domain.User` | ✓ | ✓ | ✓ | The recipient of this mail (see fields below) | +| `Recipient.ID` | `uint` | ✓ | ✓ | ✓ | Database ID of the subscriber | +| `Recipient.Name` | `string` | ✓ | ✓ | ✓ | Display name | +| `Recipient.Email` | `string` | ✓ | ✓ | ✓ | Email address | +| `Recipient.MailingListName` | `string` | ✓ | ✓ | ✓ | Name of the mailing list the subscriber belongs to | +| `Recipient.UnsubscribeToken` | `string` | ✓ | ✓ | ✓ | Opaque token used to build unsubscribe links | +| `Recipient.ConfirmedAt` | `*time.Time` | ✗¹ | ✓ | ✗ | Timestamp of double opt-in confirmation (`nil` if unconfirmed) | +| `confirmURL` | `string` | ✓ | ✗ | ✗ | Full URL the subscriber must visit to confirm (`baseURL/confirm/`) | +| `token` | `string` | ✓ | ✗ | ✗ | Raw confirmation token (same value as the last path segment of `confirmURL`) | +| `preferencesURL` | `string` | ✗ | ✓ | ✗ | Full URL to manage preferences (`baseURL/preferences//`) | +| `unsubscribeURL` | `string` | ✗ | ✓ | ✗ | Full URL to unsubscribe (`baseURL/unsubscribe/`) | +| `previewURL` | `string` | ✗ | ✓ | ✗ | Full URL to view this newsletter in a browser, personalised with the recipient's token (`baseURL/mail/?token=`) | > ¹ Always `nil` in the confirmation mail — the user has not confirmed yet. @@ -34,12 +36,12 @@ The table shows which variables are automatically injected in each sending conte In addition to all variables above (and any custom `data`), the following keys are injected exclusively when the HTML layout template (`template.html`) is executed: -| Variable | Type | Description | -| --------------------- | --------------------- | ----------------------------------------------------------------------------------------------- | -| `html` | `string` | Rendered HTML produced from the Markdown body | -| `metadata` | `domain.MailMetadata` | Typed, parsed frontmatter (see fields below) | -| `metadata.Subject` | `string` | Email subject from the `subject` frontmatter field | -| `metadata.SenderName` | `string` | Sender display name from the `sender` frontmatter field | +| Variable | Type | Description | +| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------ | +| `html` | `string` | Rendered HTML produced from the Markdown body | +| `metadata` | `domain.MailMetadata` | Typed, parsed frontmatter (see fields below) | +| `metadata.Subject` | `string` | Email subject from the `subject` frontmatter field | +| `metadata.SenderName` | `string` | Sender display name from the `sender` frontmatter field | | `frontmatter` | `map[string]any` | Raw key-value map of **all** frontmatter fields, including any custom ones (e.g. `{{.frontmatter.myField}}`) | --- @@ -59,11 +61,11 @@ sender: "Your Newsletter Name" Body starts here… ``` -| Field | Description | -|--------------|-------------------------------------------------------------------------------------------| -| `subject` | Email subject line | -| `sender` | Sender display name shown by mail clients | -| *(any key)* | Custom fields — accessible in the HTML layout template via `{{.frontmatter.yourField}}` | +| Field | Description | +| ----------- | --------------------------------------------------------------------------------------- | +| `subject` | Email subject line | +| `sender` | Sender display name shown by mail clients | +| _(any key)_ | Custom fields — accessible in the HTML layout template via `{{.frontmatter.yourField}}` | --- @@ -96,6 +98,8 @@ Hello {{.Recipient.Name}}, Welcome to this week's edition… +[View in browser]({{.previewURL}}) + [Unsubscribe]({{.unsubscribeURL}}) ``` @@ -106,7 +110,7 @@ Welcome to this week's edition… {{.metadata.Subject}} - + {{.html}} diff --git a/service/mail.go b/service/mail.go index b0d7c11..883c340 100644 --- a/service/mail.go +++ b/service/mail.go @@ -49,49 +49,51 @@ func (s *MailService) SendToList(ctx context.Context, listName string, raw strin return nil } - var firstMetadata domain.MailMetadata - recipientIDs := make([]uint, 0, len(recipients)) + metadata, _, err := s.renderer.Render(&raw, s.buildRecipientData(data, recipients[0], listName, "")) + if err != nil { + return fmt.Errorf("extracting mail metadata: %w", err) + } - for i, recipient := range recipients { - recipientData := make(map[string]any, len(data)+3) - for k, v := range data { - recipientData[k] = v - } - recipientData["Recipient"] = recipient - recipientData["unsubscribeURL"] = s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken - recipientData["preferencesURL"] = s.baseURL + "/preferences/" + listName + "/" + recipient.UnsubscribeToken + recipientIDs := make([]uint, len(recipients)) + for i, r := range recipients { + recipientIDs[i] = r.ID + } - metadata, body, err := s.renderer.Render(&raw, recipientData) + newsletter, err := s.newsletters.CreateSentNewsletter(ctx, metadata.Subject, metadata.SenderName, raw, recipientIDs, []string{listName}, topicNames) + if err != nil { + return fmt.Errorf("archiving sent newsletter: %w", err) + } + + for _, recipient := range recipients { + previewURL := fmt.Sprintf("%s/mail/%d?token=%s", s.baseURL, newsletter.ID, recipient.UnsubscribeToken) + metadata, body, err := s.renderer.Render(&raw, s.buildRecipientData(data, recipient, listName, previewURL)) if err != nil { return fmt.Errorf("rendering mail for %q: %w", recipient.Email, err) } - if i == 0 { - firstMetadata = metadata - } - if err := s.sender.SendMail(ctx, metadata, body, recipient); err != nil { return fmt.Errorf("sending mail to %q: %w", recipient.Email, err) } - recipientIDs = append(recipientIDs, recipient.ID) - } - - if _, err := s.newsletters.CreateSentNewsletter(ctx, firstMetadata.Subject, firstMetadata.SenderName, raw, recipientIDs, []string{listName}, topicNames); err != nil { - return fmt.Errorf("archiving sent newsletter: %w", err) } return nil } -func (s *MailService) SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error { - recipientData := make(map[string]any, len(data)+3) - for k, v := range data { - recipientData[k] = v +func (s *MailService) buildRecipientData(base map[string]any, recipient domain.User, listName, previewURL string) map[string]any { + d := make(map[string]any, len(base)+4) + for k, v := range base { + d[k] = v + } + d["Recipient"] = recipient + d["unsubscribeURL"] = s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken + d["preferencesURL"] = s.baseURL + "/preferences/" + listName + "/" + recipient.UnsubscribeToken + if previewURL != "" { + d["previewURL"] = previewURL } - recipientData["Recipient"] = recipient - recipientData["unsubscribeURL"] = s.baseURL + "/unsubscribe/" + recipient.UnsubscribeToken - recipientData["preferencesURL"] = s.baseURL + "/preferences/" + recipient.MailingListName + "/" + recipient.UnsubscribeToken + return d +} - metadata, body, err := s.renderer.Render(&raw, recipientData) +func (s *MailService) SendTestMail(ctx context.Context, recipient domain.User, raw string, data map[string]any) error { + metadata, body, err := s.renderer.Render(&raw, s.buildRecipientData(data, recipient, recipient.MailingListName, "")) if err != nil { return fmt.Errorf("rendering test mail for %q: %w", recipient.Email, err) } diff --git a/service/mail_test.go b/service/mail_test.go index b357734..c5660af 100644 --- a/service/mail_test.go +++ b/service/mail_test.go @@ -182,6 +182,22 @@ func TestMailService_SendToList(t *testing.T) { } }) + t.Run("injects previewURL with unsubscribe token into render data per recipient", func(t *testing.T) { + user := confirmedUser(1, "weekly", "alice@example.com") + user.UnsubscribeToken = "unsub-tok" + renderer := &fakeRenderer{metadata: metadata, body: "body"} + newsletterRepo := newFakeNewsletterRepo() + svc := NewMailService(newFakeListRepo(list), newFakeUserRepo(user), newFakeTopicRepo(), newsletterRepo, renderer, &fakeSender{}, "https://example.com") + + if err := svc.SendToList(context.Background(), "weekly", "raw", nil, nil); err != nil { + t.Fatalf("unexpected error: %v", err) + } + wantURL := "https://example.com/mail/1?token=unsub-tok" + if got, _ := renderer.lastData["previewURL"].(string); got != wantURL { + t.Errorf("previewURL = %q, want %q", got, wantURL) + } + }) + t.Run("wraps newsletter archive error", func(t *testing.T) { archiveErr := errors.New("archive failed") newsletterRepo := newFakeNewsletterRepo() diff --git a/static/template.html b/static/template.html index 9934544..0ea3728 100644 --- a/static/template.html +++ b/static/template.html @@ -106,11 +106,36 @@ img { max-width: 100%; height: auto; border-radius: 4px; } small { color: #666; font-size: 0.875em; } + + .footer { + max-width: 560px; + margin: 1.5em auto 0; + padding: 0 4px; + text-align: center; + color: #888; + font-size: 0.8em; + line-height: 1.6; + } + .footer a { color: #888; } + .footer a:hover { color: #555; } +
{{.html}}
+ {{if .unsubscribeURL}} + + {{end}} \ No newline at end of file From 8fb208692751653d21dc1decd8a05ab0363d8048 Mon Sep 17 00:00:00 2001 From: kicher-erbse Date: Thu, 16 Apr 2026 09:08:51 +0200 Subject: [PATCH 3/3] proper messages --- api/public.go | 85 ++++++++++++++---------- api/public_test.go | 159 ++++++++------------------------------------- config.example.yml | 105 ++++++++++++++++++++++++++++-- config/config.go | 21 +++--- docs/TEMPLATE.md | 32 +++++++++ main.go | 19 ++++-- 6 files changed, 232 insertions(+), 189 deletions(-) diff --git a/api/public.go b/api/public.go index 6a5f81b..45c5a67 100644 --- a/api/public.go +++ b/api/public.go @@ -3,6 +3,7 @@ package api import ( "context" "encoding/json" + "errors" "fmt" "log/slog" "net/http" @@ -28,13 +29,18 @@ type PreferencesManager interface { List(ctx context.Context, mailingListName string) ([]domain.Topic, error) } -type RedirectPages struct { - SubscribeSuccess string - SubscribeError string - ConfirmSuccess string - ConfirmError string - UnsubscribeSuccess string - UnsubscribeError string +type MessageStrings struct { + SubscribeSuccess string + SubscribeErrorInvalidInput string + SubscribeErrorAlreadySubscribed string + SubscribeError string + ConfirmSuccess string + ConfirmErrorInvalidToken string + UnsubscribeSuccess string + UnsubscribeErrorInvalidToken string + NewsletterNotFound string + PreferencesErrorInvalidToken string + PreferencesError string } type PublicHandler struct { @@ -43,12 +49,12 @@ type PublicHandler struct { preferences PreferencesManager users domain.UserRepository renderer domain.Renderer - redirects RedirectPages + messages MessageStrings logger *slog.Logger } -func NewPublicHandler(subscriptions Subscriber, newsletters NewsletterPreviewer, preferences PreferencesManager, users domain.UserRepository, renderer domain.Renderer, redirects RedirectPages, logger *slog.Logger) *PublicHandler { - return &PublicHandler{subscriptions: subscriptions, newsletters: newsletters, preferences: preferences, users: users, renderer: renderer, redirects: redirects, logger: logger} +func NewPublicHandler(subscriptions Subscriber, newsletters NewsletterPreviewer, preferences PreferencesManager, users domain.UserRepository, renderer domain.Renderer, messages MessageStrings, logger *slog.Logger) *PublicHandler { + return &PublicHandler{subscriptions: subscriptions, newsletters: newsletters, preferences: preferences, users: users, renderer: renderer, messages: messages, logger: logger} } func (h *PublicHandler) Routes() *http.ServeMux { @@ -67,21 +73,25 @@ func (h *PublicHandler) handleSubscribe(w http.ResponseWriter, r *http.Request) name, email, err := parseSubscribeBody(r) if err != nil { - redirectOrError(w, r, h.redirects.SubscribeError, http.StatusBadRequest, err.Error()) + h.writeMessagePage(w, r, http.StatusBadRequest, h.messages.SubscribeErrorInvalidInput, map[string]any{"listName": listName}) return } if _, err := h.subscriptions.Subscribe(r.Context(), listName, name, email, nil); err != nil { + if errors.Is(err, domain.ErrUserAlreadyConfirmed) { + h.writeMessagePage(w, r, http.StatusConflict, h.messages.SubscribeErrorAlreadySubscribed, map[string]any{"listName": listName, "email": email}) + return + } h.logger.ErrorContext(r.Context(), "subscribe failed", slog.String("list", listName), slog.String("email", email), slog.Any("error", err), ) - redirectOrError(w, r, h.redirects.SubscribeError, http.StatusInternalServerError, "subscription failed") + h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.SubscribeError, map[string]any{"listName": listName}) return } - redirectOrJSON(w, r, h.redirects.SubscribeSuccess, http.StatusAccepted, map[string]string{"message": "check your email for a confirmation link"}) + h.writeMessagePage(w, r, http.StatusAccepted, h.messages.SubscribeSuccess, map[string]any{"listName": listName}) } func (h *PublicHandler) handleConfirm(w http.ResponseWriter, r *http.Request) { @@ -92,11 +102,11 @@ func (h *PublicHandler) handleConfirm(w http.ResponseWriter, r *http.Request) { slog.String("token", token), slog.Any("error", err), ) - redirectOrError(w, r, h.redirects.ConfirmError, http.StatusBadRequest, "invalid or expired confirmation token") + h.writeMessagePage(w, r, http.StatusBadRequest, h.messages.ConfirmErrorInvalidToken, nil) return } - redirectOrJSON(w, r, h.redirects.ConfirmSuccess, http.StatusOK, map[string]string{"message": "your subscription has been confirmed"}) + h.writeMessagePage(w, r, http.StatusOK, h.messages.ConfirmSuccess, nil) } func (h *PublicHandler) handleUnsubscribe(w http.ResponseWriter, r *http.Request) { @@ -107,11 +117,11 @@ func (h *PublicHandler) handleUnsubscribe(w http.ResponseWriter, r *http.Request slog.String("token", token), slog.Any("error", err), ) - redirectOrError(w, r, h.redirects.UnsubscribeError, http.StatusBadRequest, "invalid or expired unsubscribe token") + h.writeMessagePage(w, r, http.StatusBadRequest, h.messages.UnsubscribeErrorInvalidToken, nil) return } - redirectOrJSON(w, r, h.redirects.UnsubscribeSuccess, http.StatusOK, map[string]string{"message": "you have been unsubscribed"}) + h.writeMessagePage(w, r, http.StatusOK, h.messages.UnsubscribeSuccess, nil) } func (h *PublicHandler) handleNewsletterPreview(w http.ResponseWriter, r *http.Request) { @@ -129,7 +139,7 @@ func (h *PublicHandler) handleNewsletterPreview(w http.ResponseWriter, r *http.R slog.Uint64("id", id), slog.Any("error", err), ) - writeError(w, http.StatusNotFound, "newsletter not found") + h.writeMessagePage(w, r, http.StatusNotFound, h.messages.NewsletterNotFound, nil) return } @@ -161,21 +171,21 @@ func (h *PublicHandler) handlePreferencesPage(w http.ResponseWriter, r *http.Req user, err := h.users.GetUserByUnsubscribeToken(r.Context(), token) if err != nil { h.logger.ErrorContext(r.Context(), "preferences: user lookup failed", slog.Any("error", err)) - writeError(w, http.StatusNotFound, "invalid token") + h.writeMessagePage(w, r, http.StatusNotFound, h.messages.PreferencesErrorInvalidToken, nil) return } allTopics, err := h.preferences.List(r.Context(), user.MailingListName) if err != nil { h.logger.ErrorContext(r.Context(), "preferences: list topics failed", slog.Any("error", err)) - writeError(w, http.StatusInternalServerError, "failed to load topics") + h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.PreferencesError, nil) return } userTopics, err := h.preferences.GetUserTopics(r.Context(), user.MailingListName, user.ID) if err != nil { h.logger.ErrorContext(r.Context(), "preferences: get user topics failed", slog.Any("error", err)) - writeError(w, http.StatusInternalServerError, "failed to load preferences") + h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.PreferencesError, nil) return } @@ -198,7 +208,7 @@ func (h *PublicHandler) handlePreferencesPage(w http.ResponseWriter, r *http.Req rendered, err := h.renderer.RenderHTML(preferencesTemplate, data) if err != nil { h.logger.ErrorContext(r.Context(), "preferences: render failed", slog.Any("error", err)) - writeError(w, http.StatusInternalServerError, "failed to render preferences") + h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.PreferencesError, nil) return } @@ -213,12 +223,12 @@ func (h *PublicHandler) handleSavePreferences(w http.ResponseWriter, r *http.Req user, err := h.users.GetUserByUnsubscribeToken(r.Context(), token) if err != nil { h.logger.ErrorContext(r.Context(), "save preferences: user lookup failed", slog.Any("error", err)) - writeError(w, http.StatusNotFound, "invalid token") + h.writeMessagePage(w, r, http.StatusNotFound, h.messages.PreferencesErrorInvalidToken, nil) return } if err := r.ParseForm(); err != nil { - writeError(w, http.StatusBadRequest, "invalid form data") + h.writeMessagePage(w, r, http.StatusBadRequest, h.messages.PreferencesError, nil) return } @@ -234,7 +244,7 @@ func (h *PublicHandler) handleSavePreferences(w http.ResponseWriter, r *http.Req if err := h.preferences.SetUserTopics(r.Context(), user.MailingListName, user.ID, topicIDs); err != nil { h.logger.ErrorContext(r.Context(), "save preferences failed", slog.Any("error", err)) - writeError(w, http.StatusInternalServerError, "failed to save preferences") + h.writeMessagePage(w, r, http.StatusInternalServerError, h.messages.PreferencesError, nil) return } @@ -243,20 +253,27 @@ func (h *PublicHandler) handleSavePreferences(w http.ResponseWriter, r *http.Req http.Redirect(w, r, fmt.Sprintf("/preferences/%s/%s?saved=1", listName, token), http.StatusSeeOther) } -func redirectOrJSON(w http.ResponseWriter, r *http.Request, redirectURL string, status int, v any) { - if redirectURL != "" { - http.Redirect(w, r, redirectURL, http.StatusSeeOther) +func (h *PublicHandler) writeMessagePage(w http.ResponseWriter, r *http.Request, status int, markdown string, extra map[string]any) { + if h.renderer == nil || markdown == "" { + writeError(w, status, http.StatusText(status)) return } - writeJSON(w, status, v) -} -func redirectOrError(w http.ResponseWriter, r *http.Request, redirectURL string, status int, msg string) { - if redirectURL != "" { - http.Redirect(w, r, redirectURL, http.StatusSeeOther) + data := map[string]any{"isMessage": true} + for k, v := range extra { + data[k] = v + } + + _, body, err := h.renderer.Render(&markdown, data) + if err != nil { + h.logger.ErrorContext(r.Context(), "rendering message page failed", slog.Any("error", err)) + writeError(w, status, http.StatusText(status)) return } - writeError(w, status, msg) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + fmt.Fprint(w, body) } func parseSubscribeBody(r *http.Request) (name, email string, err error) { diff --git a/api/public_test.go b/api/public_test.go index de12519..ab10fec 100644 --- a/api/public_test.go +++ b/api/public_test.go @@ -60,11 +60,7 @@ func (f *fakeNewsletterPreviewer) RenderNewsletter(_ context.Context, id uint, t } func newTestHandler(sub *fakeSubscriber) *PublicHandler { - return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "", err: nil}, nil, nil, nil, RedirectPages{}, slog.Default()) -} - -func newTestHandlerWithRedirects(sub *fakeSubscriber, redirects RedirectPages) *PublicHandler { - return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "", err: nil}, nil, nil, nil, redirects, slog.Default()) + return NewPublicHandler(sub, &fakeNewsletterPreviewer{body: "", err: nil}, nil, nil, nil, MessageStrings{}, slog.Default()) } func TestHandleSubscribe(t *testing.T) { @@ -139,7 +135,23 @@ func TestHandleSubscribe(t *testing.T) { } }) - t.Run("returns 500 on service error", func(t *testing.T) { + t.Run("returns 409 when user is already confirmed", func(t *testing.T) { + sub := &fakeSubscriber{subscribeErr: domain.ErrUserAlreadyConfirmed} + h := newTestHandler(sub) + + req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(`{"name":"Alice","email":"alice@example.com"}`)) + req.Header.Set("Content-Type", "application/json") + req.SetPathValue("listName", "weekly") + w := httptest.NewRecorder() + + h.handleSubscribe(w, req) + + if w.Code != http.StatusConflict { + t.Errorf("expected 409, got %d", w.Code) + } + }) + + t.Run("returns 500 on generic service error", func(t *testing.T) { sub := &fakeSubscriber{subscribeErr: errors.New("db down")} h := newTestHandler(sub) @@ -260,143 +272,24 @@ func TestRoutes(t *testing.T) { } }) - t.Run("response has application/json content type", func(t *testing.T) { + t.Run("falls back to json when no renderer is configured", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/unsubscribe/sometoken", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) if ct := w.Header().Get("Content-Type"); ct != "application/json" { - t.Errorf("expected application/json, got %q", ct) + t.Errorf("expected application/json fallback, got %q", ct) } - }) - - t.Run("response body is valid JSON", func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, "/unsubscribe/sometoken", nil) - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) var got map[string]string if err := json.NewDecoder(w.Body).Decode(&got); err != nil { - t.Errorf("expected valid JSON response: %v", err) + t.Errorf("expected valid JSON fallback response: %v", err) } }) } -func TestRedirectPages(t *testing.T) { - redirects := RedirectPages{ - SubscribeSuccess: "https://example.com/subscribe/success", - SubscribeError: "https://example.com/subscribe/error", - ConfirmSuccess: "https://example.com/confirm/success", - ConfirmError: "https://example.com/confirm/error", - UnsubscribeSuccess: "https://example.com/unsubscribe/success", - UnsubscribeError: "https://example.com/unsubscribe/error", - } - - t.Run("subscribe success redirects", func(t *testing.T) { - h := newTestHandlerWithRedirects(&fakeSubscriber{}, redirects) - body := `{"name":"Alice","email":"alice@example.com"}` - req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - req.SetPathValue("listName", "weekly") - w := httptest.NewRecorder() - h.handleSubscribe(w, req) - if w.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", w.Code) - } - if loc := w.Header().Get("Location"); loc != redirects.SubscribeSuccess { - t.Errorf("expected Location %q, got %q", redirects.SubscribeSuccess, loc) - } - }) - - t.Run("subscribe error redirects on bad request", func(t *testing.T) { - h := newTestHandlerWithRedirects(&fakeSubscriber{}, redirects) - req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(`{"name":"Alice"}`)) - req.Header.Set("Content-Type", "application/json") - req.SetPathValue("listName", "weekly") - w := httptest.NewRecorder() - h.handleSubscribe(w, req) - if w.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", w.Code) - } - if loc := w.Header().Get("Location"); loc != redirects.SubscribeError { - t.Errorf("expected Location %q, got %q", redirects.SubscribeError, loc) - } - }) - - t.Run("subscribe error redirects on service error", func(t *testing.T) { - h := newTestHandlerWithRedirects(&fakeSubscriber{subscribeErr: errors.New("db down")}, redirects) - body := `{"name":"Alice","email":"alice@example.com"}` - req := httptest.NewRequest(http.MethodPost, "/weekly/subscribe", bytes.NewBufferString(body)) - req.Header.Set("Content-Type", "application/json") - req.SetPathValue("listName", "weekly") - w := httptest.NewRecorder() - h.handleSubscribe(w, req) - if w.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", w.Code) - } - if loc := w.Header().Get("Location"); loc != redirects.SubscribeError { - t.Errorf("expected Location %q, got %q", redirects.SubscribeError, loc) - } - }) - - t.Run("confirm success redirects", func(t *testing.T) { - h := newTestHandlerWithRedirects(&fakeSubscriber{}, redirects) - req := httptest.NewRequest(http.MethodGet, "/confirm/abc123", nil) - req.SetPathValue("token", "abc123") - w := httptest.NewRecorder() - h.handleConfirm(w, req) - if w.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", w.Code) - } - if loc := w.Header().Get("Location"); loc != redirects.ConfirmSuccess { - t.Errorf("expected Location %q, got %q", redirects.ConfirmSuccess, loc) - } - }) - - t.Run("confirm error redirects", func(t *testing.T) { - h := newTestHandlerWithRedirects(&fakeSubscriber{confirmeErr: errors.New("bad token")}, redirects) - req := httptest.NewRequest(http.MethodGet, "/confirm/bad", nil) - req.SetPathValue("token", "bad") - w := httptest.NewRecorder() - h.handleConfirm(w, req) - if w.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", w.Code) - } - if loc := w.Header().Get("Location"); loc != redirects.ConfirmError { - t.Errorf("expected Location %q, got %q", redirects.ConfirmError, loc) - } - }) - - t.Run("unsubscribe success redirects", func(t *testing.T) { - h := newTestHandlerWithRedirects(&fakeSubscriber{}, redirects) - req := httptest.NewRequest(http.MethodGet, "/unsubscribe/tok123", nil) - req.SetPathValue("token", "tok123") - w := httptest.NewRecorder() - h.handleUnsubscribe(w, req) - if w.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", w.Code) - } - if loc := w.Header().Get("Location"); loc != redirects.UnsubscribeSuccess { - t.Errorf("expected Location %q, got %q", redirects.UnsubscribeSuccess, loc) - } - }) - - t.Run("unsubscribe error redirects", func(t *testing.T) { - h := newTestHandlerWithRedirects(&fakeSubscriber{unsubscribeErr: errors.New("bad token")}, redirects) - req := httptest.NewRequest(http.MethodGet, "/unsubscribe/bad", nil) - req.SetPathValue("token", "bad") - w := httptest.NewRecorder() - h.handleUnsubscribe(w, req) - if w.Code != http.StatusSeeOther { - t.Errorf("expected 303, got %d", w.Code) - } - if loc := w.Header().Get("Location"); loc != redirects.UnsubscribeError { - t.Errorf("expected Location %q, got %q", redirects.UnsubscribeError, loc) - } - }) -} func TestHandleNewsletterPreview(t *testing.T) { t.Run("returns rendered HTML for valid id without token", func(t *testing.T) { previewer := &fakeNewsletterPreviewer{body: "Hello"} - h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, MessageStrings{}, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/mail/42", nil) req.SetPathValue("id", "42") w := httptest.NewRecorder() @@ -421,7 +314,7 @@ func TestHandleNewsletterPreview(t *testing.T) { t.Run("passes token to previewer when provided", func(t *testing.T) { previewer := &fakeNewsletterPreviewer{body: ""} - h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, MessageStrings{}, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/mail/7?token=abc123", nil) req.SetPathValue("id", "7") w := httptest.NewRecorder() @@ -436,7 +329,7 @@ func TestHandleNewsletterPreview(t *testing.T) { }) t.Run("returns 400 on non-numeric id", func(t *testing.T) { - h := NewPublicHandler(&fakeSubscriber{}, &fakeNewsletterPreviewer{}, nil, nil, nil, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, &fakeNewsletterPreviewer{}, nil, nil, nil, MessageStrings{}, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/mail/abc", nil) req.SetPathValue("id", "abc") w := httptest.NewRecorder() @@ -449,7 +342,7 @@ func TestHandleNewsletterPreview(t *testing.T) { t.Run("returns 404 when newsletter not found regardless of token", func(t *testing.T) { previewer := &fakeNewsletterPreviewer{err: errors.New("not found")} - h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, MessageStrings{}, slog.Default()) for _, token := range []string{"", "some-token"} { req := httptest.NewRequest(http.MethodGet, "/mail/99?token="+token, nil) @@ -465,7 +358,7 @@ func TestHandleNewsletterPreview(t *testing.T) { t.Run("preview is routed via Routes()", func(t *testing.T) { previewer := &fakeNewsletterPreviewer{body: "

hi

"} - h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, RedirectPages{}, slog.Default()) + h := NewPublicHandler(&fakeSubscriber{}, previewer, nil, nil, nil, MessageStrings{}, slog.Default()) req := httptest.NewRequest(http.MethodGet, "/mail/1", nil) w := httptest.NewRecorder() h.Routes().ServeHTTP(w, req) diff --git a/config.example.yml b/config.example.yml index 1779d8c..cc8e940 100644 --- a/config.example.yml +++ b/config.example.yml @@ -22,10 +22,101 @@ paths: template: "./static/template.html" # HTML wrapper rendered around markdown content confirm-mail: "./static/confirm.md" # markdown template for the double opt-in email -redirects: - subscribe-success: "" # https://yoursite.com/subscribed - subscribe-error: "" # https://yoursite.com/subscribe-failed - confirm-success: "" # https://yoursite.com/confirmed - confirm-error: "" # https://yoursite.com/confirm-failed - unsubscribe-success: "" # https://yoursite.com/unsubscribed - unsubscribe-error: "" # https://yoursite.com/unsubscribe-failed +strings: + subscribe-success: | + --- + subject: "Subscription received" + --- + # Check your inbox + + Thanks for signing up to **{{.listName}}**! + We've sent you a confirmation email — click the link inside to complete your subscription. + + subscribe-error-invalid-input: | + --- + subject: "Invalid subscription request" + --- + # Something's missing + + Please make sure you fill in both your **name** and **email address** before submitting. + + subscribe-error-already-subscribed: | + --- + subject: "Already subscribed" + --- + # You're already subscribed + + **{{.email}}** is already confirmed on **{{.listName}}**. + No action needed — you're all set! + + subscribe-error: | + --- + subject: "Subscription failed" + --- + # Something went wrong + + We couldn't process your subscription to **{{.listName}}** right now. + Please try again in a few minutes. + + confirm-success: | + --- + subject: "Subscription confirmed" + --- + # You're confirmed! + + Your subscription has been confirmed. Welcome aboard! + You'll start receiving emails as soon as the next issue is sent. + + confirm-error-invalid-token: | + --- + subject: "Confirmation link invalid" + --- + # Link expired or invalid + + This confirmation link is no longer valid. It may have already been used or has expired. + Try subscribing again to receive a fresh confirmation email. + + unsubscribe-success: | + --- + subject: "Unsubscribed" + --- + # You've been unsubscribed + + You have been successfully removed from the mailing list. + We're sorry to see you go! + + unsubscribe-error-invalid-token: | + --- + subject: "Unsubscribe link invalid" + --- + # Link expired or invalid + + This unsubscribe link is no longer valid. + If you'd like to unsubscribe, please use the link in one of your recent emails. + + newsletter-not-found: | + --- + subject: "Issue not found" + --- + # Issue not found + + This newsletter issue could not be found. + It may have been removed or the link may be incorrect. + + preferences-error-invalid-token: | + --- + subject: "Preferences link invalid" + --- + # Link expired or invalid + + This preferences link is no longer valid. + Please use the link in one of your recent emails to manage your subscription. + + preferences-error: | + --- + subject: "Preferences unavailable" + --- + # Something went wrong + + We couldn't load your preferences right now. + Please try again in a few minutes. diff --git a/config/config.go b/config/config.go index 58eac22..04d2225 100644 --- a/config/config.go +++ b/config/config.go @@ -28,13 +28,18 @@ type SmtpConfig struct { TLSPolicy TLSPolicy `env:"SMTP_TLS_POLICY" env-default:"TLSOpportunistic" yaml:"tls-policy"` } -type RedirectPages struct { - SubscribeSuccess string `env:"REDIRECT_SUBSCRIBE_SUCCESS" yaml:"subscribe-success"` - SubscribeError string `env:"REDIRECT_SUBSCRIBE_ERROR" yaml:"subscribe-error"` - ConfirmSuccess string `env:"REDIRECT_CONFIRM_SUCCESS" yaml:"confirm-success"` - ConfirmError string `env:"REDIRECT_CONFIRM_ERROR" yaml:"confirm-error"` - UnsubscribeSuccess string `env:"REDIRECT_UNSUBSCRIBE_SUCCESS" yaml:"unsubscribe-success"` - UnsubscribeError string `env:"REDIRECT_UNSUBSCRIBE_ERROR" yaml:"unsubscribe-error"` +type MessageStrings struct { + SubscribeSuccess string `yaml:"subscribe-success"` + SubscribeErrorInvalidInput string `yaml:"subscribe-error-invalid-input"` + SubscribeErrorAlreadySubscribed string `yaml:"subscribe-error-already-subscribed"` + SubscribeError string `yaml:"subscribe-error"` + ConfirmSuccess string `yaml:"confirm-success"` + ConfirmErrorInvalidToken string `yaml:"confirm-error-invalid-token"` + UnsubscribeSuccess string `yaml:"unsubscribe-success"` + UnsubscribeErrorInvalidToken string `yaml:"unsubscribe-error-invalid-token"` + NewsletterNotFound string `yaml:"newsletter-not-found"` + PreferencesErrorInvalidToken string `yaml:"preferences-error-invalid-token"` + PreferencesError string `yaml:"preferences-error"` } type Config struct { @@ -53,7 +58,7 @@ type Config struct { PublicKeyPath string `env:"AUTH_PUBLIC_KEY_PATH" yaml:"public-key-path"` } `yaml:"auth"` - Redirects RedirectPages `yaml:"redirects"` + Strings MessageStrings `yaml:"strings"` Paths struct { Config string `env:"CONFIG_PATH" env-default:"config.yml"` diff --git a/docs/TEMPLATE.md b/docs/TEMPLATE.md index befce78..fd94be3 100644 --- a/docs/TEMPLATE.md +++ b/docs/TEMPLATE.md @@ -32,6 +32,38 @@ The table shows which variables are automatically injected in each sending conte --- +## Message page variables + +Message pages are the HTML responses shown to users after subscription actions (subscribe, confirm, unsubscribe) or on error conditions. Each page is a markdown string configured under `strings:` in `config.yml` and rendered through the same pipeline as newsletter content (markdown → Goldmark → `template.html`). + +The `isMessage` flag is always `true` in this context, letting the HTML layout template visually distinguish message pages from newsletter issues (e.g. to hide a newsletter header or apply a simpler layout). + +| Variable | Type | Available on | Description | +| ------------ | -------- | -------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `isMessage` | `bool` | all message pages | Always `true`; use in `template.html` to detect message pages vs newsletter renders | +| `listName` | `string` | `subscribe-success`, `subscribe-error-invalid-input`, `subscribe-error-already-subscribed`, `subscribe-error` | The mailing list name from the URL (`/:listName/subscribe`) | +| `email` | `string` | `subscribe-error-already-subscribed` | The email address that is already confirmed | + +All message strings support frontmatter (`subject`, `sender`, and custom fields) exactly like newsletter bodies and confirm-mail templates. + +### Configured string keys + +| Config key | Trigger | +| ----------------------------------- | ------------------------------------------------------------------------------ | +| `subscribe-success` | Subscription request accepted (confirmation email sent) | +| `subscribe-error-invalid-input` | Name or email missing from the subscribe form | +| `subscribe-error-already-subscribed`| The submitted email is already confirmed on the list | +| `subscribe-error` | Internal server error during subscription | +| `confirm-success` | Double opt-in token validated successfully | +| `confirm-error-invalid-token` | Confirmation token not found or already used | +| `unsubscribe-success` | User removed from the list | +| `unsubscribe-error-invalid-token` | Unsubscribe token not found | +| `newsletter-not-found` | Requested newsletter issue does not exist | +| `preferences-error-invalid-token` | Preferences link token not found | +| `preferences-error` | Internal error loading or saving topic preferences | + +--- + ## HTML layout template variables In addition to all variables above (and any custom `data`), the following keys are injected exclusively when the HTML layout template (`template.html`) is executed: diff --git a/main.go b/main.go index 718c728..50df058 100644 --- a/main.go +++ b/main.go @@ -72,13 +72,18 @@ func main() { schedulingSvc := service.NewSchedulingService(repo, mailSvc, 30*time.Second, logger) schedulingSvc.Start() - publicHandler := api.NewPublicHandler(subscriptionSvc, mailSvc, topicSvc, repo, rndr, api.RedirectPages{ - SubscribeSuccess: cfg.Redirects.SubscribeSuccess, - SubscribeError: cfg.Redirects.SubscribeError, - ConfirmSuccess: cfg.Redirects.ConfirmSuccess, - ConfirmError: cfg.Redirects.ConfirmError, - UnsubscribeSuccess: cfg.Redirects.UnsubscribeSuccess, - UnsubscribeError: cfg.Redirects.UnsubscribeError, + publicHandler := api.NewPublicHandler(subscriptionSvc, mailSvc, topicSvc, repo, rndr, api.MessageStrings{ + SubscribeSuccess: cfg.Strings.SubscribeSuccess, + SubscribeErrorInvalidInput: cfg.Strings.SubscribeErrorInvalidInput, + SubscribeErrorAlreadySubscribed: cfg.Strings.SubscribeErrorAlreadySubscribed, + SubscribeError: cfg.Strings.SubscribeError, + ConfirmSuccess: cfg.Strings.ConfirmSuccess, + ConfirmErrorInvalidToken: cfg.Strings.ConfirmErrorInvalidToken, + UnsubscribeSuccess: cfg.Strings.UnsubscribeSuccess, + UnsubscribeErrorInvalidToken: cfg.Strings.UnsubscribeErrorInvalidToken, + NewsletterNotFound: cfg.Strings.NewsletterNotFound, + PreferencesErrorInvalidToken: cfg.Strings.PreferencesErrorInvalidToken, + PreferencesError: cfg.Strings.PreferencesError, }, logger) var publicKey ed25519.PublicKey