From 43aaccd5bfacb76283b11ce039fb3f0716196fcb Mon Sep 17 00:00:00 2001 From: Elias Schneider Date: Mon, 27 Jul 2026 20:00:59 +0200 Subject: [PATCH] refactor: modularize email module and use actor for email verification (#1625) --- .../internal/bootstrap/router_bootstrap.go | 10 +- .../internal/bootstrap/scheduler_bootstrap.go | 2 +- .../internal/bootstrap/services_bootstrap.go | 41 ++- .../controller/app_config_controller.go | 13 +- .../internal/controller/user_controller.go | 52 +-- backend/internal/email/module.go | 265 ++++++++++++++ backend/internal/email/module_test.go | 345 ++++++++++++++++++ backend/internal/email/templates.go | 77 ++++ backend/internal/emailverification/actor.go | 196 ++++++++++ backend/internal/emailverification/handler.go | 65 ++++ backend/internal/emailverification/module.go | 50 +++ backend/internal/emailverification/service.go | 150 ++++++++ .../emailverification/service_test.go | 260 +++++++++++++ backend/internal/job/api_key_expiry_job.go | 29 +- backend/internal/job/db_cleanup_job.go | 14 - .../middleware/auth_middleware_test.go | 2 +- .../model/email_verification_token.go | 13 - backend/internal/onetimeaccess/module.go | 11 +- backend/internal/onetimeaccess/service.go | 20 +- .../internal/onetimeaccess/service_test.go | 3 +- backend/internal/service/audit_log_service.go | 33 +- backend/internal/service/e2etest_service.go | 48 ++- backend/internal/service/email_service.go | 175 --------- .../service/email_service_templates.go | 86 ----- backend/internal/service/ldap_service_test.go | 1 - .../service/one_time_access_email_sender.go | 29 -- backend/internal/service/user_service.go | 75 +--- backend/internal/service/user_service_test.go | 1 - backend/internal/utils/email/composer.go | 215 ----------- backend/internal/utils/email/composer_test.go | 92 ----- .../utils/email/email_service_templates.go | 61 ---- ...7120000_email_verification_actors.down.sql | 10 + ...727120000_email_verification_actors.up.sql | 1 + ...7120000_email_verification_actors.down.sql | 17 + ...727120000_email_verification_actors.up.sql | 7 + tests/resources/export/database.json | 16 - 36 files changed, 1562 insertions(+), 923 deletions(-) create mode 100644 backend/internal/email/module.go create mode 100644 backend/internal/email/module_test.go create mode 100644 backend/internal/email/templates.go create mode 100644 backend/internal/emailverification/actor.go create mode 100644 backend/internal/emailverification/handler.go create mode 100644 backend/internal/emailverification/module.go create mode 100644 backend/internal/emailverification/service.go create mode 100644 backend/internal/emailverification/service_test.go delete mode 100644 backend/internal/model/email_verification_token.go delete mode 100644 backend/internal/service/email_service.go delete mode 100644 backend/internal/service/email_service_templates.go delete mode 100644 backend/internal/service/one_time_access_email_sender.go delete mode 100644 backend/internal/utils/email/composer.go delete mode 100644 backend/internal/utils/email/composer_test.go delete mode 100644 backend/internal/utils/email/email_service_templates.go create mode 100644 backend/resources/migrations/postgres/20260727120000_email_verification_actors.down.sql create mode 100644 backend/resources/migrations/postgres/20260727120000_email_verification_actors.up.sql create mode 100644 backend/resources/migrations/sqlite/20260727120000_email_verification_actors.down.sql create mode 100644 backend/resources/migrations/sqlite/20260727120000_email_verification_actors.up.sql diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index fe68f019..62c75dec 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -158,8 +158,8 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate), ) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) - controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule) - controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) + controller.NewUserController(apiGroup, authMiddleware, svc.appConfigService, svc.userService, svc.webauthnModule) + controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailModule, svc.ldapService) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) controller.NewUserGroupController(apiGroup, authMiddleware, svc.appConfigService, svc.userGroupService) @@ -177,6 +177,12 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessToken), rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessEmail), ) + svc.emailVerificationModule.RegisterRoutes( + apiGroup, + authMiddleware.WithAdminNotRequired().Add(), + rateLimitMiddleware.Add(middleware.RateLimitSendEmailVerification), + rateLimitMiddleware.Add(middleware.RateLimitVerifyEmail), + ) optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add() browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add() diff --git a/backend/internal/bootstrap/scheduler_bootstrap.go b/backend/internal/bootstrap/scheduler_bootstrap.go index c31f60e2..29695018 100644 --- a/backend/internal/bootstrap/scheduler_bootstrap.go +++ b/backend/internal/bootstrap/scheduler_bootstrap.go @@ -22,7 +22,7 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche if err != nil { return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err) } - err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyModule, svc.appConfigService, svc.emailService) + err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyModule, svc.appConfigService, svc.emailModule) if err != nil { return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err) } diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 8e2b363b..966a3911 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -12,6 +12,8 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/email" + "github.com/pocket-id/pocket-id/backend/internal/emailverification" "github.com/pocket-id/pocket-id/backend/internal/job" "github.com/pocket-id/pocket-id/backend/internal/oidc" "github.com/pocket-id/pocket-id/backend/internal/onetimeaccess" @@ -24,7 +26,7 @@ import ( type services struct { appConfigService *appconfig.AppConfigService appImagesService *service.AppImagesService - emailService *service.EmailService + emailModule *email.Module geoLiteService *service.GeoLiteService auditLogService *service.AuditLogService jwtService *service.JwtService @@ -38,13 +40,14 @@ type services struct { fileStorage storage.FileStorage appLockService *service.AppLockService - apiKeyModule *apikey.Module - oidcModule *oidc.Module - webauthnModule *webauthn.Module - userSignUpModule *usersignup.Module - oneTimeAccessModule *onetimeaccess.Module - apiModule *api.Module - actors *local.Host + apiKeyModule *apikey.Module + oidcModule *oidc.Module + webauthnModule *webauthn.Module + userSignUpModule *usersignup.Module + oneTimeAccessModule *onetimeaccess.Module + emailVerificationModule *emailverification.Module + apiModule *api.Module + actors *local.Host } // Initializes all services @@ -72,13 +75,13 @@ func initServices( svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage) svc.appLockService = service.NewAppLockService(db) - svc.emailService, err = service.NewEmailService(db) + svc.emailModule, err = email.New(db) if err != nil { - return nil, fmt.Errorf("failed to create email service: %w", err) + return nil, fmt.Errorf("failed to create email module: %w", err) } svc.geoLiteService = service.NewGeoLiteService(httpClient) - svc.auditLogService = service.NewAuditLogService(db, svc.emailService, svc.geoLiteService, svc.appConfigService) + svc.auditLogService = service.NewAuditLogService(db, svc.emailModule, svc.geoLiteService, svc.appConfigService) svc.jwtService, err = service.NewJwtService(ctx, db, instanceID) if err != nil { return nil, fmt.Errorf("failed to create JWT service: %w", err) @@ -125,7 +128,7 @@ func initServices( } svc.userGroupService = service.NewUserGroupService(db, svc.scimService) - svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) + svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) svc.ldapService = service.NewLdapService(db, httpClient, svc.userService, svc.userGroupService, fileStorage) svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{ @@ -154,13 +157,25 @@ func initServices( Signer: svc.jwtService, AuditLog: svc.auditLogService, UserProvider: svc.userService, - EmailSender: service.NewOneTimeAccessEmailSender(svc.emailService), + EmailSender: svc.emailModule, AppConfig: svc.appConfigService, }) if err != nil { return nil, fmt.Errorf("failed to create one-time access module: %w", err) } + svc.emailVerificationModule, err = emailverification.New(emailverification.Dependencies{ + DB: db, + Actors: actors, + Users: svc.userService, + EmailSender: svc.emailModule, + AppConfig: svc.appConfigService, + AppURL: common.EnvConfig.AppURL, + }) + if err != nil { + return nil, fmt.Errorf("failed to create email verification module: %w", err) + } + svc.versionService = service.NewVersionService(httpClient) return svc, nil diff --git a/backend/internal/controller/app_config_controller.go b/backend/internal/controller/app_config_controller.go index 289cd765..e0b64f7d 100644 --- a/backend/internal/controller/app_config_controller.go +++ b/backend/internal/controller/app_config_controller.go @@ -1,6 +1,7 @@ package controller import ( + "context" "net/http" "strconv" @@ -13,6 +14,10 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/tracing" ) +type TestEmailSender interface { + SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, recipientUserID string) error +} + // NewAppConfigController creates a new controller for application configuration endpoints // @Summary Create a new application configuration controller // @Description Initialize routes for application configuration @@ -21,13 +26,13 @@ func NewAppConfigController( group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, appConfigService *appconfig.AppConfigService, - emailService *service.EmailService, + emailSender TestEmailSender, ldapService *service.LdapService, ) { acc := &AppConfigController{ appConfigService: appConfigService, - emailService: emailService, + emailSender: emailSender, ldapService: ldapService, } group.GET("/application-configuration", acc.listAppConfigHandler) @@ -40,7 +45,7 @@ func NewAppConfigController( type AppConfigController struct { appConfigService *appconfig.AppConfigService - emailService *service.EmailService + emailSender TestEmailSender ldapService *service.LdapService } @@ -176,7 +181,7 @@ func (acc *AppConfigController) testEmailHandler(c *gin.Context) { userID := c.GetString("userID") - err = acc.emailService.SendTestEmail(c.Request.Context(), dbConfig, userID) + err = acc.emailSender.SendTestEmail(c.Request.Context(), dbConfig, userID) if err != nil { _ = c.Error(err) return diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 5aab14d7..ecb5ccc3 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -19,7 +19,7 @@ import ( // @Summary User management controller // @Description Initializes all user-related API endpoints // @Tags Users -func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, appConfigService *appconfig.AppConfigService, userService *service.UserService, webAuthnService *webauthn.Module) { +func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, appConfigService *appconfig.AppConfigService, userService *service.UserService, webAuthnService *webauthn.Module) { uc := UserController{ appConfigService: appConfigService, userService: userService, @@ -46,9 +46,6 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler) group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) - - group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(middleware.RateLimitSendEmailVerification), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler) - group.POST("/users/me/verify-email", rateLimitMiddleware.Add(middleware.RateLimitVerifyEmail), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler) } type UserController struct { @@ -484,50 +481,3 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) c.Status(http.StatusNoContent) } - -// sendEmailVerificationHandler godoc -// @Summary Send email verification -// @Description Send an email verification to the currently authenticated user -// @Tags Users -// @Produce json -// @Success 204 "No Content" -// @Router /api/users/me/send-email-verification [post] -func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) { - dbConfig, err := uc.appConfigService.GetConfig(c.Request.Context()) - if err != nil { - _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) - return - } - - userID := c.GetString("userID") - - if err := uc.userService.SendEmailVerification(c.Request.Context(), dbConfig, userID); err != nil { - _ = c.Error(err) - return - } - - c.Status(http.StatusNoContent) -} - -// verifyEmailHandler godoc -// @Summary Verify email -// @Description Verify the currently authenticated user's email using a verification token -// @Tags Users -// @Param body body dto.EmailVerificationDto true "Email verification token" -// @Success 204 "No Content" -// @Router /api/users/me/verify-email [post] -func (uc *UserController) verifyEmailHandler(c *gin.Context) { - var input dto.EmailVerificationDto - if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { - _ = c.Error(err) - return - } - - userID := c.GetString("userID") - if err := uc.userService.VerifyEmail(c.Request.Context(), userID, input.Token); err != nil { - _ = c.Error(err) - return - } - - c.Status(http.StatusNoContent) -} diff --git a/backend/internal/email/module.go b/backend/internal/email/module.go new file mode 100644 index 00000000..bb8a6a5f --- /dev/null +++ b/backend/internal/email/module.go @@ -0,0 +1,265 @@ +package email + +import ( + "context" + "errors" + "fmt" + htemplate "html/template" + "net" + "net/url" + "path" + "strings" + ttemplate "text/template" + "time" + + "github.com/italypaleale/go-kit/emailer" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/resources" +) + +type Module struct { + db *gorm.DB + htmlTemplates map[string]*htemplate.Template + textTemplates map[string]*ttemplate.Template +} + +type template[V any] struct { + path string + title func(data *templateData[V]) string +} + +type templateData[V any] struct { + AppName string + LogoURL string + Data *V +} + +type address struct { + name string + email string +} + +func New(db *gorm.DB) (*Module, error) { + // Preload both template variants so missing or invalid embedded templates fail during startup + htmlTemplates, err := prepareHTMLTemplates(templatePaths) + if err != nil { + return nil, fmt.Errorf("prepare HTML templates: %w", err) + } + + textTemplates, err := prepareTextTemplates(templatePaths) + if err != nil { + return nil, fmt.Errorf("prepare text templates: %w", err) + } + + return &Module{ + db: db, + htmlTemplates: htmlTemplates, + textTemplates: textTemplates, + }, nil +} + +func (m *Module) SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, recipientUserID string) error { + // Resolve the recipient from the database so test emails use the same user identity as notification emails + var user model.User + err := m.db. + WithContext(ctx). + First(&user, "id = ?", recipientUserID). + Error + if err != nil { + return err + } + + if user.Email == nil { + return &common.UserEmailNotSetError{} + } + + return send(ctx, m, dbConfig, address{ + name: user.FullName(), + email: *user.Email, + }, testTemplate, nil) +} + +func (m *Module) SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, verificationLink string) error { + return send(ctx, m, dbConfig, address{ + name: userFullName, + email: userEmail, + }, emailVerificationTemplate, &emailVerificationTemplateData{ + UserFullName: userFullName, + VerificationLink: verificationLink, + }) +} + +func (m *Module) SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, code, loginLink, loginLinkWithCode, expirationString string) error { + return send(ctx, m, dbConfig, address{ + name: userFullName, + email: userEmail, + }, oneTimeAccessTemplate, &oneTimeAccessTemplateData{ + Code: code, + LoginLink: loginLink, + LoginLinkWithCode: loginLinkWithCode, + ExpirationString: expirationString, + }) +} + +func (m *Module) SendNewLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, ipAddress, country, city, device string, dateTime time.Time) error { + return send(ctx, m, dbConfig, address{ + name: userFullName, + email: userEmail, + }, newLoginTemplate, &newLoginTemplateData{ + IPAddress: ipAddress, + Country: country, + City: city, + Device: device, + DateTime: dateTime, + }) +} + +func (m *Module) SendAPIKeyExpiringSoon(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, firstName, apiKeyName string, expiresAt time.Time) error { + return send(ctx, m, dbConfig, address{ + name: userFullName, + email: userEmail, + }, apiKeyExpiringSoonTemplate, &apiKeyExpiringSoonTemplateData{ + Name: firstName, + ApiKeyName: apiKeyName, + ExpiresAt: expiresAt, + }) +} + +func send[V any](ctx context.Context, module *Module, dbConfig *appconfig.AppConfigModel, recipient address, tmpl template[V], data *V) error { + // Combine application metadata with message-specific data before rendering both MIME alternatives + templateData := &templateData[V]{ + AppName: dbConfig.AppName.String(), + LogoURL: common.EnvConfig.AppURL + "/api/application-images/email", + Data: data, + } + + // Render the complete message before opening an SMTP connection so template failures never produce partial deliveries + text, html, err := renderBody(module, tmpl, templateData) + if err != nil { + return fmt.Errorf("prepare email body for '%s': %w", tmpl.path, err) + } + + // Resolve SMTP settings for each delivery so application configuration changes take effect without restarting + emailerService, err := module.getEmailer(ctx, dbConfig) + if err != nil { + return fmt.Errorf("failed to configure emailer: %w", err) + } + + // Send text and HTML together so clients can select the format they support + err = emailerService.SendEmail(ctx, emailer.EmailAddress{ + Name: recipient.name, + Address: recipient.email, + }, tmpl.title(templateData), emailer.SendEmailMessage{ + Text: text, + HTML: html, + }) + if err != nil { + return fmt.Errorf("failed to send email: %w", err) + } + + return nil +} + +func (m *Module) getEmailer(ctx context.Context, dbConfig *appconfig.AppConfigModel) (emailer.Emailer, error) { + connString, err := smtpConnString(dbConfig) + if err != nil { + return nil, err + } + + return emailer.NewEmailer(ctx, emailer.NewEmailerOpts{ + ConnString: connString, + }) +} + +func smtpConnString(dbConfig *appconfig.AppConfigModel) (string, error) { + // Build the SMTP authority from the configured endpoint and optional credentials + host := dbConfig.SmtpHost.String() + if host == "" { + return "", errors.New("SMTP host is not configured") + } + + smtpURL := &url.URL{ + Scheme: "smtp", + Host: host, + } + port := dbConfig.SmtpPort.String() + if port != "" { + smtpURL.Host = net.JoinHostPort(host, port) + } + + smtpUser := dbConfig.SmtpUser.String() + smtpPassword := dbConfig.SmtpPassword.String() + if smtpUser != "" || smtpPassword != "" { + smtpURL.User = url.UserPassword(smtpUser, smtpPassword) + } + + // Preserve sender identity and transport security settings in the connection string consumed by the emailer + tlsMode := dbConfig.SmtpTls.String() + if tlsMode == "" { + tlsMode = "none" + } + + query := url.Values{} + query.Set("fromAddress", dbConfig.SmtpFrom.String()) + query.Set("fromName", dbConfig.AppName.String()) + query.Set("tls", tlsMode) + if dbConfig.SmtpSkipCertVerify.IsTrue() { + query.Set("insecureSkipVerify", "true") + } + smtpURL.RawQuery = query.Encode() + + return smtpURL.String(), nil +} + +func renderBody[V any](module *Module, tmpl template[V], data *templateData[V]) (text string, html string, err error) { + // Render both variants from the same data so the plain-text and HTML messages cannot diverge + textBuilder := &strings.Builder{} + err = module.textTemplates[tmpl.path].ExecuteTemplate(textBuilder, "root", data) + if err != nil { + return "", "", fmt.Errorf("execute text template: %w", err) + } + + htmlBuilder := &strings.Builder{} + err = module.htmlTemplates[tmpl.path].ExecuteTemplate(htmlBuilder, "root", data) + if err != nil { + return "", "", fmt.Errorf("execute HTML template: %w", err) + } + + return textBuilder.String(), htmlBuilder.String(), nil +} + +func prepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) { + textTemplates := make(map[string]*ttemplate.Template, len(templates)) + for _, tmpl := range templates { + templatePath := path.Join("email-templates", tmpl+"_text.tmpl") + + parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath) + if err != nil { + return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err) + } + + textTemplates[tmpl] = parsedTemplate + } + + return textTemplates, nil +} + +func prepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) { + htmlTemplates := make(map[string]*htemplate.Template, len(templates)) + for _, tmpl := range templates { + templatePath := path.Join("email-templates", tmpl+"_html.tmpl") + + parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath) + if err != nil { + return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err) + } + + htmlTemplates[tmpl] = parsedTemplate + } + + return htmlTemplates, nil +} diff --git a/backend/internal/email/module_test.go b/backend/internal/email/module_test.go new file mode 100644 index 00000000..bc5f099d --- /dev/null +++ b/backend/internal/email/module_test.go @@ -0,0 +1,345 @@ +//go:build unit + +package email + +import ( + "bufio" + "context" + "io" + "net" + "net/url" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +func TestNewLoadsEveryEmailTemplate(t *testing.T) { + module, err := New(nil) + require.NoError(t, err) + require.Len(t, module.textTemplates, len(templatePaths)) + require.Len(t, module.htmlTemplates, len(templatePaths)) + + for _, templatePath := range templatePaths { + assert.NotNil(t, module.textTemplates[templatePath]) + assert.NotNil(t, module.htmlTemplates[templatePath]) + } +} + +func TestModuleSendsEveryEmailType(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + userEmail := "recipient@example.test" + user := model.User{ + Base: model.Base{ID: "email-recipient"}, + Username: "email-recipient", + Email: &userEmail, + FirstName: "Test", + LastName: "User", + } + require.NoError(t, db.Create(&user).Error) + + module, err := New(db) + require.NoError(t, err) + + eventTime := time.Date(2030, time.January, 2, 15, 4, 5, 0, time.UTC) + tests := []struct { + name string + subject string + bodyContains []string + send func(ctx context.Context, config *appconfig.AppConfigModel) error + }{ + { + name: "test email", + subject: "Test email", + bodyContains: []string{"TEST EMAIL", "Your email setup is working correctly!"}, + send: func(ctx context.Context, config *appconfig.AppConfigModel) error { + return module.SendTestEmail(ctx, config, user.ID) + }, + }, + { + name: "email verification", + subject: "Verify your Pocket ID Test email address", + bodyContains: []string{"EMAIL VERIFICATION", "Hello Test User", "https://id.example.test/verify-token"}, + send: func(ctx context.Context, config *appconfig.AppConfigModel) error { + return module.SendEmailVerification(ctx, config, user.FullName(), userEmail, "https://id.example.test/verify-token") + }, + }, + { + name: "one-time access", + subject: "Login Code", + bodyContains: []string{"YOUR LOGIN CODE", "123456", "https://id.example.test/lc/123456", "15 minutes"}, + send: func(ctx context.Context, config *appconfig.AppConfigModel) error { + return module.SendOneTimeAccessEmail(ctx, config, user.FullName(), userEmail, "123456", "https://id.example.test/lc", "https://id.example.test/lc/123456", "15 minutes") + }, + }, + { + name: "new login", + subject: "New device login with Pocket ID Test", + bodyContains: []string{"NEW SIGN-IN DETECTED", "Zurich, Switzerland", "192.0.2.10", "Firefox on Linux", "January 2, 2030 at 3:04 PM UTC"}, + send: func(ctx context.Context, config *appconfig.AppConfigModel) error { + return module.SendNewLogin(ctx, config, user.FullName(), userEmail, "192.0.2.10", "Switzerland", "Zurich", "Firefox on Linux", eventTime) + }, + }, + { + name: "API key expiration", + subject: `API Key "Automation" Expiring Soon`, + bodyContains: []string{"API KEY EXPIRING SOON", "Hello Test", "Automation", "2030-01-02 15:04:05 UTC"}, + send: func(ctx context.Context, config *appconfig.AppConfigModel) error { + return module.SendAPIKeyExpiringSoon(ctx, config, user.FullName(), userEmail, user.FirstName, "Automation", eventTime) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + // Use the real SMTP transport so the test covers module mapping, rendering, MIME generation, and delivery together + server := newSMTPTestServer(t) + config := newSMTPTestConfig(t, server.address()) + + require.NoError(t, test.send(t.Context(), config)) + + session, sessionErr := server.wait() + require.NoError(t, sessionErr) + assert.Equal(t, "", session.mailFrom) + assert.Equal(t, "", session.rcptTo) + assert.Contains(t, session.message, "From: Pocket ID Test \r\n") + assert.Contains(t, session.message, "To: Test User \r\n") + assert.Contains(t, session.message, "Subject: "+test.subject+"\r\n") + assert.Contains(t, session.message, "Content-Type: multipart/alternative; boundary=") + assert.Contains(t, session.message, "Content-Type: text/plain; charset=UTF-8") + assert.Contains(t, session.message, "Content-Type: text/html; charset=UTF-8") + for _, expected := range test.bodyContains { + assert.Contains(t, session.message, expected) + } + }) + } +} + +func TestSendTestEmailRequiresUserEmail(t *testing.T) { + db := testutils.NewDatabaseForTest(t) + user := model.User{ + Base: model.Base{ID: "user-without-email"}, + Username: "user-without-email", + } + require.NoError(t, db.Create(&user).Error) + + module, err := New(db) + require.NoError(t, err) + + err = module.SendTestEmail(t.Context(), &appconfig.AppConfigModel{}, user.ID) + var emailNotSetError *common.UserEmailNotSetError + require.ErrorAs(t, err, &emailNotSetError) +} + +func TestSMTPConnStringPreservesConfiguration(t *testing.T) { + config := &appconfig.AppConfigModel{ + AppName: "Pocket ID Test", + SmtpHost: "smtp.example.test", + SmtpPort: "2525", + SmtpFrom: "sender@example.test", + SmtpUser: "mailer", + SmtpPassword: "secret", + SmtpTls: "starttls", + SmtpSkipCertVerify: "true", + } + + connectionString, err := smtpConnString(config) + require.NoError(t, err) + + smtpURL, err := url.Parse(connectionString) + require.NoError(t, err) + assert.Equal(t, "smtp", smtpURL.Scheme) + assert.Equal(t, "smtp.example.test:2525", smtpURL.Host) + assert.Equal(t, "mailer", smtpURL.User.Username()) + password, hasPassword := smtpURL.User.Password() + assert.True(t, hasPassword) + assert.Equal(t, "secret", password) + assert.Equal(t, "sender@example.test", smtpURL.Query().Get("fromAddress")) + assert.Equal(t, "Pocket ID Test", smtpURL.Query().Get("fromName")) + assert.Equal(t, "starttls", smtpURL.Query().Get("tls")) + assert.Equal(t, "true", smtpURL.Query().Get("insecureSkipVerify")) +} + +func TestSMTPConnStringRequiresHostAndDefaultsTLS(t *testing.T) { + _, err := smtpConnString(&appconfig.AppConfigModel{}) + require.ErrorContains(t, err, "SMTP host is not configured") + + connectionString, err := smtpConnString(&appconfig.AppConfigModel{SmtpHost: "smtp.example.test"}) + require.NoError(t, err) + + smtpURL, err := url.Parse(connectionString) + require.NoError(t, err) + assert.Equal(t, "none", smtpURL.Query().Get("tls")) + assert.Empty(t, smtpURL.Query().Get("insecureSkipVerify")) +} + +type smtpTestSession struct { + mailFrom string + rcptTo string + message string +} + +type smtpTestServer struct { + listener net.Listener + sessionCh chan smtpTestSession + errorCh chan error +} + +func newSMTPTestServer(t *testing.T) *smtpTestServer { + t.Helper() + + // Bind an ephemeral loopback port so each delivery test gets an isolated SMTP endpoint + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + server := &smtpTestServer{ + listener: listener, + sessionCh: make(chan smtpTestSession, 1), + errorCh: make(chan error, 1), + } + go server.serve() + + t.Cleanup(func() { + _ = listener.Close() + }) + + return server +} + +func newSMTPTestConfig(t *testing.T, address string) *appconfig.AppConfigModel { + t.Helper() + + host, port, err := net.SplitHostPort(address) + require.NoError(t, err) + + return &appconfig.AppConfigModel{ + AppName: "Pocket ID Test", + SmtpHost: appconfig.AppConfigValue(host), + SmtpPort: appconfig.AppConfigValue(port), + SmtpFrom: "sender@example.test", + SmtpTls: "none", + } +} + +func (s *smtpTestServer) address() string { + return s.listener.Addr().String() +} + +func (s *smtpTestServer) wait() (smtpTestSession, error) { + select { + case session := <-s.sessionCh: + return session, nil + case err := <-s.errorCh: + return smtpTestSession{}, err + case <-time.After(5 * time.Second): + return smtpTestSession{}, context.DeadlineExceeded + } +} + +func (s *smtpTestServer) serve() { + conn, err := s.listener.Accept() + if err != nil { + s.errorCh <- err + return + } + + session, err := handleSMTPConnection(conn) + if err != nil { + s.errorCh <- err + return + } + + s.sessionCh <- session +} + +func handleSMTPConnection(conn net.Conn) (smtpTestSession, error) { + defer func() { + _ = conn.Close() + }() + + reader := bufio.NewReader(conn) + writer := bufio.NewWriter(conn) + session := smtpTestSession{} + + err := writeSMTPResponse(writer, "220 localhost ESMTP test") + if err != nil { + return smtpTestSession{}, err + } + + for { + line, readErr := reader.ReadString('\n') + if readErr != nil { + return smtpTestSession{}, readErr + } + line = strings.TrimRight(line, "\r\n") + + switch { + case strings.HasPrefix(line, "EHLO "): + err = writeSMTPResponse(writer, "250-localhost ESMTP test", "250 OK") + case strings.HasPrefix(line, "HELO "): + err = writeSMTPResponse(writer, "250 localhost") + case strings.HasPrefix(line, "MAIL FROM:"): + session.mailFrom = strings.TrimPrefix(line, "MAIL FROM:") + err = writeSMTPResponse(writer, "250 2.1.0 Ok") + case strings.HasPrefix(line, "RCPT TO:"): + session.rcptTo = strings.TrimPrefix(line, "RCPT TO:") + err = writeSMTPResponse(writer, "250 2.1.5 Ok") + case line == "DATA": + err = writeSMTPResponse(writer, "354 End data with .") + if err != nil { + return smtpTestSession{}, err + } + session.message, err = readSMTPData(reader) + if err == nil { + err = writeSMTPResponse(writer, "250 2.0.0 Ok: queued") + } + case line == "QUIT": + err = writeSMTPResponse(writer, "221 2.0.0 Bye") + return session, err + default: + err = writeSMTPResponse(writer, "250 2.0.0 Ok") + } + + if err != nil { + return smtpTestSession{}, err + } + } +} + +func writeSMTPResponse(writer *bufio.Writer, lines ...string) error { + for _, line := range lines { + _, err := writer.WriteString(line + "\r\n") + if err != nil { + return err + } + } + + return writer.Flush() +} + +func readSMTPData(reader *bufio.Reader) (string, error) { + var message strings.Builder + for { + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + if line == ".\r\n" { + return message.String(), nil + } + if strings.HasPrefix(line, "..") { + line = line[1:] + } + _, err = io.WriteString(&message, line) + if err != nil { + return "", err + } + } +} diff --git a/backend/internal/email/templates.go b/backend/internal/email/templates.go new file mode 100644 index 00000000..051130ba --- /dev/null +++ b/backend/internal/email/templates.go @@ -0,0 +1,77 @@ +package email + +import ( + "fmt" + "time" +) + +// Every template path must have matching text and HTML resources and be listed in templatePaths so startup validates both variants + +var newLoginTemplate = template[newLoginTemplateData]{ + path: "login-with-new-device", + title: func(data *templateData[newLoginTemplateData]) string { + return fmt.Sprintf("New device login with %s", data.AppName) + }, +} + +var oneTimeAccessTemplate = template[oneTimeAccessTemplateData]{ + path: "one-time-access", + title: func(_ *templateData[oneTimeAccessTemplateData]) string { + return "Login Code" + }, +} + +var testTemplate = template[struct{}]{ + path: "test", + title: func(_ *templateData[struct{}]) string { + return "Test email" + }, +} + +var apiKeyExpiringSoonTemplate = template[apiKeyExpiringSoonTemplateData]{ + path: "api-key-expiring-soon", + title: func(data *templateData[apiKeyExpiringSoonTemplateData]) string { + return fmt.Sprintf("API Key \"%s\" Expiring Soon", data.Data.ApiKeyName) + }, +} + +var emailVerificationTemplate = template[emailVerificationTemplateData]{ + path: "email-verification", + title: func(data *templateData[emailVerificationTemplateData]) string { + return "Verify your " + data.AppName + " email address" + }, +} + +type newLoginTemplateData struct { + IPAddress string + Country string + City string + Device string + DateTime time.Time +} + +type oneTimeAccessTemplateData struct { + Code string + LoginLink string + LoginLinkWithCode string + ExpirationString string +} + +type apiKeyExpiringSoonTemplateData struct { + Name string + ApiKeyName string + ExpiresAt time.Time +} + +type emailVerificationTemplateData struct { + UserFullName string + VerificationLink string +} + +var templatePaths = []string{ + newLoginTemplate.path, + oneTimeAccessTemplate.path, + testTemplate.path, + apiKeyExpiringSoonTemplate.path, + emailVerificationTemplate.path, +} diff --git a/backend/internal/emailverification/actor.go b/backend/internal/emailverification/actor.go new file mode 100644 index 00000000..e3dfe17b --- /dev/null +++ b/backend/internal/emailverification/actor.go @@ -0,0 +1,196 @@ +package emailverification + +import ( + "context" + "crypto/subtle" + "errors" + "fmt" + "time" + + "github.com/italypaleale/francis/actor" + + "github.com/pocket-id/pocket-id/backend/internal/common" +) + +// ActorType is the actor type for email verification state +const ActorType = "EmailVerification" + +const ( + // MethodIssue replaces the outstanding verification state for a user + MethodIssue = "issue" + methodConsume = "consume" + methodDiscard = "discard" + methodRestore = "restore" +) + +type consumeStatus string + +const ( + consumeOK consumeStatus = "ok" + consumeNotFound consumeStatus = "not_found" +) + +// State is the persisted verification state for one user +type State struct { + TokenHash string + Email string + ExpiresAt time.Time +} + +type tokenRequest struct { + TokenHash string +} + +type consumeResponse struct { + Status consumeStatus + State State +} + +type emailVerificationActor struct { + client actor.Client[State] +} + +// NewActor allocates the email verification actor for a user +func NewActor(actorID string, service *actor.Service) actor.Actor { + return &emailVerificationActor{ + client: actor.NewActorClient[State](ActorType, actorID, service), + } +} + +// Invoke implements actor.ActorInvoke +func (a *emailVerificationActor) Invoke(ctx context.Context, method string, data actor.Envelope) (any, error) { + switch method { + case MethodIssue: + return nil, a.issue(ctx, data) + case methodConsume: + return a.consume(ctx, data) + case methodDiscard: + return nil, a.discard(ctx, data) + case methodRestore: + return nil, a.restore(ctx, data) + default: + return nil, common.ErrUnsupportedActorMethod{Method: method} + } +} + +func (a *emailVerificationActor) issue(ctx context.Context, data actor.Envelope) error { + state, err := decodeState(data, MethodIssue) + if err != nil { + return err + } + + return a.setState(ctx, state) +} + +func (a *emailVerificationActor) consume(ctx context.Context, data actor.Envelope) (consumeResponse, error) { + request, err := decodeTokenRequest(data, methodConsume) + if err != nil { + return consumeResponse{}, err + } + + state, err := a.client.GetState(ctx) + if err != nil { + return consumeResponse{}, fmt.Errorf("error retrieving actor state: %w", err) + } + + // Compare if the hash matches + if state.TokenHash == "" || state.ExpiresAt.Before(time.Now()) || + subtle.ConstantTimeCompare([]byte(state.TokenHash), []byte(request.TokenHash)) != 1 { + return consumeResponse{Status: consumeNotFound}, nil + } + + err = a.client.DeleteState(ctx) + if err != nil { + return consumeResponse{}, fmt.Errorf("error deleting actor state: %w", err) + } + + return consumeResponse{ + Status: consumeOK, + State: state, + }, nil +} + +func (a *emailVerificationActor) discard(ctx context.Context, data actor.Envelope) error { + request, err := decodeTokenRequest(data, methodDiscard) + if err != nil { + return err + } + + state, err := a.client.GetState(ctx) + if err != nil { + return fmt.Errorf("error retrieving actor state: %w", err) + } + + // Only discard if the token hash matches, to avoid discarding a newer token that may have been issued after the one being discarded + if state.TokenHash == "" || subtle.ConstantTimeCompare([]byte(state.TokenHash), []byte(request.TokenHash)) != 1 { + return nil + } + + err = a.client.DeleteState(ctx) + if err != nil && !errors.Is(err, actor.ErrStateNotFound) { + return fmt.Errorf("error deleting actor state: %w", err) + } + + return nil +} + +func (a *emailVerificationActor) restore(ctx context.Context, data actor.Envelope) error { + state, err := decodeState(data, methodRestore) + if err != nil { + return err + } + + current, err := a.client.GetState(ctx) + if err != nil { + return fmt.Errorf("error retrieving actor state: %w", err) + } + + // Preserve a newer verification request that may have been issued after consumption + if current.TokenHash != "" { + return nil + } + + return a.setState(ctx, state) +} + +func (a *emailVerificationActor) setState(ctx context.Context, state State) error { + ttl := time.Until(state.ExpiresAt) + if ttl <= 0 { + return nil + } + + err := a.client.SetState(ctx, state, &actor.SetStateOpts{TTL: ttl}) + if err != nil { + return fmt.Errorf("error saving actor state: %w", err) + } + + return nil +} + +func decodeState(data actor.Envelope, method string) (State, error) { + if data == nil { + return State{}, fmt.Errorf("request body is empty for method '%s'", method) + } + + var state State + err := data.Decode(&state) + if err != nil { + return State{}, fmt.Errorf("request body is not valid for method '%s': %w", method, err) + } + + return state, nil +} + +func decodeTokenRequest(data actor.Envelope, method string) (tokenRequest, error) { + if data == nil { + return tokenRequest{}, fmt.Errorf("request body is empty for method '%s'", method) + } + + var request tokenRequest + err := data.Decode(&request) + if err != nil { + return tokenRequest{}, fmt.Errorf("request body is not valid for method '%s': %w", method, err) + } + + return request, nil +} diff --git a/backend/internal/emailverification/handler.go b/backend/internal/emailverification/handler.go new file mode 100644 index 00000000..9f363ac9 --- /dev/null +++ b/backend/internal/emailverification/handler.go @@ -0,0 +1,65 @@ +package emailverification + +import ( + "fmt" + "net/http" + + "github.com/gin-gonic/gin" + + "github.com/pocket-id/pocket-id/backend/internal/dto" +) + +type handler struct { + service *Service + appConfig AppConfigResolver +} + +func newHandler(service *Service, appConfig AppConfigResolver) *handler { + return &handler{service: service, appConfig: appConfig} +} + +// send godoc +// @Summary Send email verification +// @Description Send an email verification to the currently authenticated user +// @Tags Users +// @Produce json +// @Success 204 "No Content" +// @Router /api/users/me/send-email-verification [post] +func (h *handler) send(c *gin.Context) { + dbConfig, err := h.appConfig.GetConfig(c.Request.Context()) + if err != nil { + _ = c.Error(fmt.Errorf("error loading app configuration: %w", err)) + return + } + + err = h.service.Send(c.Request.Context(), dbConfig, c.GetString("userID")) + if err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} + +// verify godoc +// @Summary Verify email +// @Description Verify the currently authenticated user's email using a verification token +// @Tags Users +// @Param body body dto.EmailVerificationDto true "Email verification token" +// @Success 204 "No Content" +// @Router /api/users/me/verify-email [post] +func (h *handler) verify(c *gin.Context) { + var input dto.EmailVerificationDto + if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil { + _ = c.Error(err) + return + } + + err := h.service.Verify(c.Request.Context(), c.GetString("userID"), input.Token) + if err != nil { + _ = c.Error(err) + return + } + + c.Status(http.StatusNoContent) +} diff --git a/backend/internal/emailverification/module.go b/backend/internal/emailverification/module.go new file mode 100644 index 00000000..b67b92e6 --- /dev/null +++ b/backend/internal/emailverification/module.go @@ -0,0 +1,50 @@ +package emailverification + +import ( + "context" + "fmt" + + "github.com/gin-gonic/gin" + "github.com/italypaleale/francis/host/local" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/appconfig" +) + +type AppConfigResolver interface { + GetConfig(ctx context.Context) (*appconfig.AppConfigModel, error) +} + +type Dependencies struct { + DB *gorm.DB + Actors *local.Host + + Users UserProvider + EmailSender EmailSender + AppConfig AppConfigResolver + AppURL string +} + +type Module struct { + service *Service + handler *handler +} + +func New(deps Dependencies) (*Module, error) { + err := deps.Actors.RegisterActor(ActorType, NewActor) + if err != nil { + return nil, fmt.Errorf("error registering the %s actor: %w", ActorType, err) + } + + service := newService(deps.DB, deps.Actors.Service(), deps.Users, deps.EmailSender, deps.AppURL) + return &Module{ + service: service, + handler: newHandler(service, deps.AppConfig), + }, nil +} + +// RegisterRoutes mounts the email verification endpoints +func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, userAuth, sendRateLimit, verifyRateLimit gin.HandlerFunc) { + apiGroup.POST("/users/me/send-email-verification", sendRateLimit, userAuth, m.handler.send) + apiGroup.POST("/users/me/verify-email", verifyRateLimit, userAuth, m.handler.verify) +} diff --git a/backend/internal/emailverification/service.go b/backend/internal/emailverification/service.go new file mode 100644 index 00000000..47ce2701 --- /dev/null +++ b/backend/internal/emailverification/service.go @@ -0,0 +1,150 @@ +package emailverification + +import ( + "context" + "fmt" + "log/slog" + "time" + + "github.com/italypaleale/francis/actor" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +const tokenLifetime = 24 * time.Hour + +type UserProvider interface { + GetUser(ctx context.Context, userID string) (model.User, error) +} + +type EmailSender interface { + SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, verificationLink string) error +} + +type Service struct { + db *gorm.DB + actors *actor.Service + users UserProvider + emailSender EmailSender + appURL string +} + +func newService(db *gorm.DB, actors *actor.Service, users UserProvider, emailSender EmailSender, appURL string) *Service { + return &Service{ + db: db, + actors: actors, + users: users, + emailSender: emailSender, + appURL: appURL, + } +} + +func (s *Service) Send(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error { + user, err := s.users.GetUser(ctx, userID) + if err != nil { + return err + } + if user.Email == nil { + return &common.UserEmailNotSetError{} + } + + token, err := utils.GenerateRandomAlphanumericString(32) + if err != nil { + return err + } + + // Persist the token hash in the email verification actor for this user + state := State{ + TokenHash: utils.CreateSha256Hash(token), + Email: *user.Email, + ExpiresAt: time.Now().Add(tokenLifetime), + } + _, err = s.actors.Invoke(ctx, ActorType, user.ID, MethodIssue, state) + if err != nil { + return fmt.Errorf("error issuing email verification token: %w", err) + } + + // Send the email verification message to the user + err = s.emailSender.SendEmailVerification( + ctx, + dbConfig, + user.FullName(), + *user.Email, + s.appURL+"/verify-email?token="+token, + ) + if err != nil { + // If the email delivery fails, discard the token in the actor to avoid leaving a valid token in the system + s.discardAfterSendFailure(ctx, user.ID, state.TokenHash) + return err + } + + return nil +} + +func (s *Service) Verify(ctx context.Context, userID, token string) error { + // Consume the token in the email verification actor for this user + response, err := s.actors.Invoke(ctx, ActorType, userID, methodConsume, tokenRequest{ + TokenHash: utils.CreateSha256Hash(token), + }) + if err != nil { + return fmt.Errorf("error consuming email verification token: %w", err) + } + + var result consumeResponse + if response == nil { + return fmt.Errorf("email verification actor returned an empty response") + } + err = response.Decode(&result) + if err != nil { + return fmt.Errorf("error decoding email verification actor response: %w", err) + } + if result.Status != consumeOK { + return &common.InvalidEmailVerificationTokenError{} + } + + // Update the user's email_verified field in the database + // We are querying by both user ID and email to ensure that the email has not changed since the token was issued + update := s.db. + WithContext(ctx). + Model(&model.User{}). + Where("id = ? AND email = ?", userID, result.State.Email). + Updates(map[string]any{ + "email_verified": true, + "updated_at": new(datatype.DateTime(time.Now())), + }) + if update.Error != nil { + // If the database update fails, restore the token in the actor to allow the user to retry verification + s.restoreAfterDatabaseFailure(ctx, userID, result.State) + return update.Error + } + if update.RowsAffected != 1 { + return &common.InvalidEmailVerificationTokenError{} + } + + return nil +} + +func (s *Service) discardAfterSendFailure(parentCtx context.Context, userID, tokenHash string) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second) + defer cancel() + + _, err := s.actors.Invoke(ctx, ActorType, userID, methodDiscard, tokenRequest{TokenHash: tokenHash}) + if err != nil { + slog.ErrorContext(ctx, "Failed to discard email verification token after email delivery failed", slog.Any("error", err)) + } +} + +func (s *Service) restoreAfterDatabaseFailure(parentCtx context.Context, userID string, state State) { + ctx, cancel := context.WithTimeout(context.WithoutCancel(parentCtx), 10*time.Second) + defer cancel() + + _, err := s.actors.Invoke(ctx, ActorType, userID, methodRestore, state) + if err != nil { + slog.ErrorContext(ctx, "Failed to restore email verification token after the database update failed", slog.Any("error", err)) + } +} diff --git a/backend/internal/emailverification/service_test.go b/backend/internal/emailverification/service_test.go new file mode 100644 index 00000000..b1a48d67 --- /dev/null +++ b/backend/internal/emailverification/service_test.go @@ -0,0 +1,260 @@ +package emailverification + +import ( + "context" + "errors" + "net/url" + "testing" + "time" + + "github.com/italypaleale/francis/actor" + "github.com/italypaleale/francis/host/local" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/appconfig" + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/model" + "github.com/pocket-id/pocket-id/backend/internal/utils" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +type testUserProvider struct { + db *gorm.DB +} + +func (p testUserProvider) GetUser(ctx context.Context, userID string) (model.User, error) { + var user model.User + err := p.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error + return user, err +} + +type testEmailSender struct { + err error + sent []sentVerificationEmail +} + +type sentVerificationEmail struct { + userFullName string + userEmail string + verificationLink string +} + +func (s *testEmailSender) SendEmailVerification(_ context.Context, _ *appconfig.AppConfigModel, userFullName, userEmail, verificationLink string) error { + if s.err != nil { + return s.err + } + + s.sent = append(s.sent, sentVerificationEmail{ + userFullName: userFullName, + userEmail: userEmail, + verificationLink: verificationLink, + }) + return nil +} + +func newServiceForTest(t *testing.T, emailSender *testEmailSender) (*Service, *local.Host, *gorm.DB) { + t.Helper() + + db := testutils.NewDatabaseForTest(t) + + var service *Service + host := testutils.NewActorHostForTest(t, func(t *testing.T, host *local.Host) { + require.NoError(t, host.RegisterActor(ActorType, NewActor)) + service = newService(db, host.Service(), testUserProvider{db: db}, emailSender, "https://id.example.test") + }) + require.NotNil(t, service) + + return service, host, db +} + +func createTestUser(t *testing.T, db *gorm.DB, userID, address string) model.User { + t.Helper() + + user := model.User{ + Base: model.Base{ID: userID}, + Username: userID, + Email: &address, + FirstName: "Test", + LastName: "User", + } + require.NoError(t, db.Create(&user).Error) + + return user +} + +func verificationTokenFromEmail(t *testing.T, sentEmail sentVerificationEmail) string { + t.Helper() + + verificationURL, err := url.Parse(sentEmail.verificationLink) + require.NoError(t, err) + + token := verificationURL.Query().Get("token") + require.NotEmpty(t, token) + return token +} + +func TestSendBindsAddressAndReplacesOutstandingToken(t *testing.T) { + emailSender := &testEmailSender{} + service, host, db := newServiceForTest(t, emailSender) + user := createTestUser(t, db, "user-1", "user@example.test") + + require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID)) + firstToken := verificationTokenFromEmail(t, emailSender.sent[0]) + require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID)) + secondToken := verificationTokenFromEmail(t, emailSender.sent[1]) + + var state State + require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state)) + require.Equal(t, "user@example.test", state.Email) + require.Equal(t, utils.CreateSha256Hash(secondToken), state.TokenHash) + require.NotEqual(t, firstToken, secondToken) + require.Len(t, emailSender.sent, 2) +} + +func TestVerifyConsumesTokenAndMarksBoundAddressVerified(t *testing.T) { + emailSender := &testEmailSender{} + service, host, db := newServiceForTest(t, emailSender) + user := createTestUser(t, db, "user-2", "user@example.test") + + require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID)) + token := verificationTokenFromEmail(t, emailSender.sent[0]) + require.NoError(t, service.Verify(t.Context(), user.ID, token)) + + var updated model.User + require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error) + require.True(t, updated.EmailVerified) + + var state State + require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound) +} + +func TestVerifyRejectsTokenAfterAddressChanges(t *testing.T) { + emailSender := &testEmailSender{} + service, host, db := newServiceForTest(t, emailSender) + user := createTestUser(t, db, "user-3", "attacker-controlled@example.test") + + require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID)) + token := verificationTokenFromEmail(t, emailSender.sent[0]) + require.NoError(t, db.Model(&model.User{}).Where("id = ?", user.ID).Updates(map[string]any{ + "email": "victim@example.test", + "email_verified": false, + }).Error) + + err := service.Verify(t.Context(), user.ID, token) + var invalidTokenError *common.InvalidEmailVerificationTokenError + require.ErrorAs(t, err, &invalidTokenError) + + var updated model.User + require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error) + require.Equal(t, "victim@example.test", *updated.Email) + require.False(t, updated.EmailVerified) + + var state State + require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound) +} + +func TestVerifyDoesNotConsumeStateForWrongToken(t *testing.T) { + emailSender := &testEmailSender{} + service, host, db := newServiceForTest(t, emailSender) + user := createTestUser(t, db, "user-4", "user@example.test") + + require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID)) + + err := service.Verify(t.Context(), user.ID, "wrong-verification-code") + var invalidTokenError *common.InvalidEmailVerificationTokenError + require.ErrorAs(t, err, &invalidTokenError) + + var state State + require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state)) + require.NotEmpty(t, state.TokenHash) +} + +func TestVerifyRejectsExpiredToken(t *testing.T) { + emailSender := &testEmailSender{} + service, host, db := newServiceForTest(t, emailSender) + user := createTestUser(t, db, "user-expired", "user@example.test") + token := "expired-verification-token" + + require.NoError(t, host.SetState(t.Context(), ActorType, user.ID, State{ + TokenHash: utils.CreateSha256Hash(token), + Email: *user.Email, + ExpiresAt: time.Now().Add(time.Hour), + }, &actor.SetStateOpts{TTL: time.Millisecond})) + require.Eventually(t, func() bool { + var state State + return errors.Is(host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound) + }, time.Second, time.Millisecond) + + err := service.Verify(t.Context(), user.ID, token) + var invalidTokenError *common.InvalidEmailVerificationTokenError + require.ErrorAs(t, err, &invalidTokenError) + + var updated model.User + require.NoError(t, db.Where("id = ?", user.ID).First(&updated).Error) + require.False(t, updated.EmailVerified) +} + +func TestVerifyRestoresActorStateAfterDatabaseWriteFailure(t *testing.T) { + emailSender := &testEmailSender{} + service, host, db := newServiceForTest(t, emailSender) + user := createTestUser(t, db, "user-restore", "user@example.test") + + require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID)) + token := verificationTokenFromEmail(t, emailSender.sent[0]) + + forcedError := errors.New("forced database write failure") + require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:fail-email-verification-update", func(tx *gorm.DB) { + _ = tx.AddError(forcedError) + })) + + require.ErrorIs(t, service.Verify(t.Context(), user.ID, token), forcedError) + + var state State + require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state)) + require.Equal(t, utils.CreateSha256Hash(token), state.TokenHash) + require.Equal(t, *user.Email, state.Email) +} + +func TestVerifyPreservesNewActorStateAfterDatabaseWriteFailure(t *testing.T) { + emailSender := &testEmailSender{} + service, host, db := newServiceForTest(t, emailSender) + user := createTestUser(t, db, "user-concurrent-issue", "user@example.test") + + require.NoError(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID)) + token := verificationTokenFromEmail(t, emailSender.sent[0]) + replacement := State{ + TokenHash: "new-token-hash", + Email: *user.Email, + ExpiresAt: time.Now().Add(time.Hour), + } + + forcedError := errors.New("forced database write failure") + require.NoError(t, db.Callback().Update().Before("gorm:update").Register("test:issue-token-before-email-verification-update-fails", func(tx *gorm.DB) { + _, err := host.Service().Invoke(tx.Statement.Context, ActorType, user.ID, MethodIssue, replacement) + if err != nil { + _ = tx.AddError(err) + return + } + _ = tx.AddError(forcedError) + })) + + require.ErrorIs(t, service.Verify(t.Context(), user.ID, token), forcedError) + + var state State + require.NoError(t, host.GetState(t.Context(), ActorType, user.ID, &state)) + require.Equal(t, replacement.TokenHash, state.TokenHash) + require.Equal(t, replacement.Email, state.Email) + require.True(t, replacement.ExpiresAt.Equal(state.ExpiresAt)) +} + +func TestSendDiscardsTokenWhenEmailDeliveryFails(t *testing.T) { + emailSender := &testEmailSender{err: errors.New("delivery failed")} + service, host, db := newServiceForTest(t, emailSender) + user := createTestUser(t, db, "user-5", "user@example.test") + + require.ErrorContains(t, service.Send(t.Context(), &appconfig.AppConfigModel{}, user.ID), "delivery failed") + + var state State + require.ErrorIs(t, host.GetState(t.Context(), ActorType, user.ID, &state), actor.ErrStateNotFound) +} diff --git a/backend/internal/job/api_key_expiry_job.go b/backend/internal/job/api_key_expiry_job.go index 4717f0a2..42efd833 100644 --- a/backend/internal/job/api_key_expiry_job.go +++ b/backend/internal/job/api_key_expiry_job.go @@ -4,26 +4,30 @@ import ( "context" "fmt" "log/slog" + "time" "github.com/go-co-op/gocron/v2" "github.com/pocket-id/pocket-id/backend/internal/apikey" "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/service" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" ) +type APIKeyExpiryEmailSender interface { + SendAPIKeyExpiringSoon(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, firstName, apiKeyName string, expiresAt time.Time) error +} + type ApiKeyEmailJobs struct { apiKeyModule *apikey.Module appConfigService *appconfig.AppConfigService - emailService *service.EmailService + emailSender APIKeyExpiryEmailSender } -func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *appconfig.AppConfigService, emailService *service.EmailService) error { +func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *appconfig.AppConfigService, emailSender APIKeyExpiryEmailSender) error { jobs := &ApiKeyEmailJobs{ apiKeyModule: apiKeyModule, appConfigService: appConfigService, - emailService: emailService, + emailSender: emailSender, } // Send every day at midnight @@ -51,14 +55,15 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err continue } - err = service.SendEmail(ctx, j.emailService, dbConfig, email.Address{ - Name: key.User.FullName(), - Email: *key.User.Email, - }, service.ApiKeyExpiringSoonTemplate, &service.ApiKeyExpiringSoonTemplateData{ - Name: key.User.FirstName, - ApiKeyName: key.Name, - ExpiresAt: key.ExpiresAt.ToTime(), - }) + err = j.emailSender.SendAPIKeyExpiringSoon( + ctx, + dbConfig, + key.User.FullName(), + *key.User.Email, + key.User.FirstName, + key.Name, + key.ExpiresAt.ToTime(), + ) if err != nil { slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index 7c682891..a7e2f1e0 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -33,7 +33,6 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro // Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure return errors.Join( s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), - s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearOAuth2JTIs", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2JTIs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearInteractionSessions", jobDefWithJitter(24*time.Hour), jobs.clearInteractionSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), @@ -122,16 +121,3 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { return nil } - -// ClearEmailVerificationTokens deletes email verification tokens that have expired -func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error { - st := j.db. - WithContext(ctx). - Delete(&model.EmailVerificationToken{}, "expires_at < ?", datatype.DateTime(time.Now())) - if st.Error != nil { - return fmt.Errorf("failed to clean expired email verification tokens: %w", st.Error) - } - - slog.InfoContext(ctx, "Cleaned expired email verification tokens", slog.Int64("count", st.RowsAffected)) - return nil -} diff --git a/backend/internal/middleware/auth_middleware_test.go b/backend/internal/middleware/auth_middleware_test.go index 22c98b43..e4369b87 100644 --- a/backend/internal/middleware/auth_middleware_test.go +++ b/backend/internal/middleware/auth_middleware_test.go @@ -39,7 +39,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) { jwtService, err := service.NewJwtService(t.Context(), db, instanceID) require.NoError(t, err) - userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil, nil) + userService := service.NewUserService(db, jwtService, nil, nil, nil, nil, nil) apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db}) require.NoError(t, err) diff --git a/backend/internal/model/email_verification_token.go b/backend/internal/model/email_verification_token.go deleted file mode 100644 index d93d6c6d..00000000 --- a/backend/internal/model/email_verification_token.go +++ /dev/null @@ -1,13 +0,0 @@ -package model - -import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" - -type EmailVerificationToken struct { - Base - - Token string - ExpiresAt datatype.DateTime - - UserID string - User User -} diff --git a/backend/internal/onetimeaccess/module.go b/backend/internal/onetimeaccess/module.go index 9654921c..9f9344b7 100644 --- a/backend/internal/onetimeaccess/module.go +++ b/backend/internal/onetimeaccess/module.go @@ -11,20 +11,11 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/model" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" ) -// EmailData is the data rendered in the one-time access email -type EmailData struct { - Code string - LoginLink string - LoginLinkWithCode string - ExpirationString string -} - // EmailSender sends the one-time access email type EmailSender interface { - SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, to email.Address, data EmailData) error + SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, code, loginLink, loginLinkWithCode, expirationString string) error } type TokenService interface { diff --git a/backend/internal/onetimeaccess/service.go b/backend/internal/onetimeaccess/service.go index 4c4a9cfd..86273cf9 100644 --- a/backend/internal/onetimeaccess/service.go +++ b/backend/internal/onetimeaccess/service.go @@ -16,7 +16,6 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" ) // authenticationMethodOneTimePassword identifies one-time password/code authentication @@ -112,15 +111,16 @@ func (s *Service) requestOneTimeAccessEmailInternal(ctx context.Context, userID, linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath } - innerErr := s.emailSender.SendOneTimeAccessEmail(innerCtx, dbConfig, email.Address{ - Name: user.FullName(), - Email: *user.Email, - }, EmailData{ - Code: oneTimeAccessToken, - LoginLink: link, - LoginLinkWithCode: linkWithCode, - ExpirationString: utils.DurationToString(ttl), - }) + innerErr := s.emailSender.SendOneTimeAccessEmail( + innerCtx, + dbConfig, + user.FullName(), + *user.Email, + oneTimeAccessToken, + link, + linkWithCode, + utils.DurationToString(ttl), + ) if innerErr != nil { slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", innerErr), slog.String("address", *user.Email)) return diff --git a/backend/internal/onetimeaccess/service_test.go b/backend/internal/onetimeaccess/service_test.go index ccd1b42f..6332c426 100644 --- a/backend/internal/onetimeaccess/service_test.go +++ b/backend/internal/onetimeaccess/service_test.go @@ -13,7 +13,6 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/model" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" ) @@ -44,7 +43,7 @@ func (f fakeUserProvider) GetUser(ctx context.Context, userID string) (model.Use type fakeEmailSender struct{} -func (fakeEmailSender) SendOneTimeAccessEmail(_ context.Context, _ *appconfig.AppConfigModel, _ email.Address, _ EmailData) error { +func (fakeEmailSender) SendOneTimeAccessEmail(_ context.Context, _ *appconfig.AppConfigModel, _, _, _, _, _, _ string) error { return nil } diff --git a/backend/internal/service/audit_log_service.go b/backend/internal/service/audit_log_service.go index f45cac89..3a46ea17 100644 --- a/backend/internal/service/audit_log_service.go +++ b/backend/internal/service/audit_log_service.go @@ -4,26 +4,30 @@ import ( "context" "fmt" "log/slog" + "time" userAgentParser "github.com/mileusna/useragent" "github.com/pocket-id/pocket-id/backend/internal/appconfig" "github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/utils" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" "gorm.io/gorm" ) +type NewLoginEmailSender interface { + SendNewLogin(ctx context.Context, dbConfig *appconfig.AppConfigModel, userFullName, userEmail, ipAddress, country, city, device string, dateTime time.Time) error +} + type AuditLogService struct { db *gorm.DB - emailService *EmailService + emailSender NewLoginEmailSender geoliteService *GeoLiteService appConfigService *appconfig.AppConfigService } -func NewAuditLogService(db *gorm.DB, emailService *EmailService, geoliteService *GeoLiteService, appConfigService *appconfig.AppConfigService) *AuditLogService { +func NewAuditLogService(db *gorm.DB, emailSender NewLoginEmailSender, geoliteService *GeoLiteService, appConfigService *appconfig.AppConfigService) *AuditLogService { return &AuditLogService{ db: db, - emailService: emailService, + emailSender: emailSender, geoliteService: geoliteService, appConfigService: appConfigService, } @@ -121,16 +125,17 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres return } - innerErr = SendEmail(innerCtx, s.emailService, dbConfig, email.Address{ - Name: user.FullName(), - Email: *user.Email, - }, NewLoginTemplate, &NewLoginTemplateData{ - IPAddress: ipAddress, - Country: createdAuditLog.Country, - City: createdAuditLog.City, - Device: s.DeviceStringFromUserAgent(userAgent), - DateTime: createdAuditLog.CreatedAt.UTC(), - }) + innerErr = s.emailSender.SendNewLogin( + innerCtx, + dbConfig, + user.FullName(), + *user.Email, + ipAddress, + createdAuditLog.Country, + createdAuditLog.City, + s.DeviceStringFromUserAgent(userAgent), + createdAuditLog.CreatedAt.UTC(), + ) if innerErr != nil { slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", *user.Email)) return diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index aacbc1fd..59dd22c3 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -30,6 +30,7 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/api" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/dto" + "github.com/pocket-id/pocket-id/backend/internal/emailverification" "github.com/pocket-id/pocket-id/backend/internal/model" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/oidc" @@ -58,6 +59,9 @@ const ( e2eRefreshTokenClientID = "3654a746-35d4-4321-ac61-0bdcff2b4055" e2eRefreshTokenValidFixtureToken = "ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo" e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4" + e2eEmailVerificationUserID = "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" + e2eEmailVerificationUserEmail = "craig.federighi@test.com" + e2eEmailVerificationToken = "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup" ) func NewTestService(db *gorm.DB, actors *local.Host, appConfigService *appconfig.AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) { @@ -479,31 +483,6 @@ func (s *TestService) SeedDatabase(baseURL string) error { } } - emailVerificationTokens := []model.EmailVerificationToken{ - { - Base: model.Base{ - ID: "ef9ca469-b178-4857-bd39-26639dca45de", - }, - Token: "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup", - ExpiresAt: datatype.DateTime(time.Now().Add(2 * time.Hour)), - UserID: users[1].ID, - }, - { - Base: model.Base{ - ID: "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00", - }, - Token: "EXPIRED1234567890ABCDE", - ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Hour)), - UserID: users[1].ID, - }, - } - - for _, token := range emailVerificationTokens { - if err := tx.Create(&token).Error; err != nil { - return err - } - } - keyValues := []model.KV{ { Key: jwkutils.PrivateKeyDBKey, @@ -525,7 +504,7 @@ func (s *TestService) SeedDatabase(baseURL string) error { return err } - // One-time access tokens and signup tokens live in the actor state store, so they're seeded separately from the DB transaction above. + // Actor-backed token fixtures are seeded separately from the database transaction to avoid invoking actors while SQLite holds a transaction err = s.seedOneTimeAccessTokens(context.Background()) if err != nil { return fmt.Errorf("failed to seed one-time access tokens: %w", err) @@ -536,9 +515,26 @@ func (s *TestService) SeedDatabase(baseURL string) error { return fmt.Errorf("failed to seed signup tokens: %w", err) } + err = s.seedEmailVerificationToken(context.Background()) + if err != nil { + return fmt.Errorf("failed to seed email verification token: %w", err) + } + return nil } +// seedEmailVerificationToken replaces the outstanding verification state so every E2E reset starts from the same valid token +func (s *TestService) seedEmailVerificationToken(ctx context.Context) error { + state := emailverification.State{ + TokenHash: utils.CreateSha256Hash(e2eEmailVerificationToken), + Email: e2eEmailVerificationUserEmail, + ExpiresAt: time.Now().Add(24 * time.Hour).Round(time.Second), + } + + _, err := s.actors.Service().Invoke(ctx, emailverification.ActorType, e2eEmailVerificationUserID, emailverification.MethodIssue, state) + return err +} + // seedSignupTokens seeds the signup tokens used by E2E tests into the signup token singleton actor. // The already-expired fixture token is intentionally not seeded, since the actor would purge it right away via its cleanup alarm. func (s *TestService) seedSignupTokens(ctx context.Context) error { diff --git a/backend/internal/service/email_service.go b/backend/internal/service/email_service.go deleted file mode 100644 index 98ac360a..00000000 --- a/backend/internal/service/email_service.go +++ /dev/null @@ -1,175 +0,0 @@ -package service - -import ( - "context" - "errors" - "fmt" - htemplate "html/template" - "net" - "net/url" - "strings" - ttemplate "text/template" - - "github.com/italypaleale/go-kit/emailer" - "gorm.io/gorm" - - "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/model" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" -) - -type EmailService struct { - db *gorm.DB - htmlTemplates map[string]*htemplate.Template - textTemplates map[string]*ttemplate.Template -} - -func NewEmailService(db *gorm.DB) (*EmailService, error) { - htmlTemplates, err := email.PrepareHTMLTemplates(emailTemplatesPaths) - if err != nil { - return nil, fmt.Errorf("prepare html templates: %w", err) - } - - textTemplates, err := email.PrepareTextTemplates(emailTemplatesPaths) - if err != nil { - return nil, fmt.Errorf("prepare html templates: %w", err) - } - - return &EmailService{ - db: db, - htmlTemplates: htmlTemplates, - textTemplates: textTemplates, - }, nil -} - -func (srv *EmailService) SendTestEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, recipientUserId string) error { - var user model.User - err := srv.db. - WithContext(ctx). - First(&user, "id = ?", recipientUserId). - Error - if err != nil { - return err - } - - if user.Email == nil { - return &common.UserEmailNotSetError{} - } - - return SendEmail(ctx, srv, dbConfig, - email.Address{ - Email: *user.Email, - Name: user.FullName(), - }, TestTemplate, nil) -} - -// SendEmail sends an email using the provided application configuration -func SendEmail[V any](ctx context.Context, srv *EmailService, dbConfig *appconfig.AppConfigModel, toEmail email.Address, template email.Template[V], tData *V) error { - - data := &email.TemplateData[V]{ - AppName: dbConfig.AppName.String(), - LogoURL: common.EnvConfig.AppURL + "/api/application-images/email", - Data: tData, - } - - // Render the text and HTML bodies - text, html, err := renderBody(srv, template, data) - if err != nil { - return fmt.Errorf("prepare email body for '%s': %w", template.Path, err) - } - - // Configure the emailer from the current app config - e, err := srv.getEmailer(ctx, dbConfig) - if err != nil { - return fmt.Errorf("failed to configure emailer: %w", err) - } - - // Send the email - err = e.SendEmail(ctx, emailer.EmailAddress{ - Name: toEmail.Name, - Address: toEmail.Email, - }, template.Title(data), emailer.SendEmailMessage{ - Text: text, - HTML: html, - }) - if err != nil { - return fmt.Errorf("failed to send email: %w", err) - } - - return nil -} - -// getEmailer builds an emailer.Emailer from the current app config. -func (srv *EmailService) getEmailer(ctx context.Context, dbConfig *appconfig.AppConfigModel) (emailer.Emailer, error) { - // We support SMTP only (for now) - connString, err := smtpConnString(dbConfig) - if err != nil { - return nil, err - } - - return emailer.NewEmailer(ctx, emailer.NewEmailerOpts{ - ConnString: connString, - }) -} - -// smtpConnString builds the SMTP connection string that go-kit's emailer expects: -// smtp://:@:?fromAddress=
&fromName=&tls=&insecureSkipVerify= -func smtpConnString(dbConfig *appconfig.AppConfigModel) (string, error) { - host := dbConfig.SmtpHost.String() - if host == "" { - return "", errors.New("SMTP host is not configured") - } - - u := &url.URL{ - Scheme: "smtp", - Host: host, - } - port := dbConfig.SmtpPort.String() - if port != "" { - u.Host = net.JoinHostPort(host, port) - } - - // Include credentials when set - smtpUser := dbConfig.SmtpUser.String() - smtpPassword := dbConfig.SmtpPassword.String() - if smtpUser != "" || smtpPassword != "" { - u.User = url.UserPassword(smtpUser, smtpPassword) - } - - // TLS values from config: none, starttls, tls - tlsMode := dbConfig.SmtpTls.String() - if tlsMode == "" { - tlsMode = "none" - } - - // Build the query string args - q := url.Values{} - q.Set("fromAddress", dbConfig.SmtpFrom.String()) - q.Set("fromName", dbConfig.AppName.String()) - q.Set("tls", tlsMode) - if dbConfig.SmtpSkipCertVerify.IsTrue() { - q.Set("insecureSkipVerify", "true") - } - u.RawQuery = q.Encode() - - // Return the connection string - return u.String(), nil -} - -// renderBody renders the text and HTML templates for the message into strings -func renderBody[V any](srv *EmailService, template email.Template[V], data *email.TemplateData[V]) (text string, html string, err error) { - textBuilder := &strings.Builder{} - err = email.GetTemplate(srv.textTemplates, template).ExecuteTemplate(textBuilder, "root", data) - if err != nil { - return "", "", fmt.Errorf("execute text template: %w", err) - } - - htmlBuilder := &strings.Builder{} - err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlBuilder, "root", data) - if err != nil { - return "", "", fmt.Errorf("execute html template: %w", err) - } - - return textBuilder.String(), htmlBuilder.String(), nil -} diff --git a/backend/internal/service/email_service_templates.go b/backend/internal/service/email_service_templates.go deleted file mode 100644 index 694e6613..00000000 --- a/backend/internal/service/email_service_templates.go +++ /dev/null @@ -1,86 +0,0 @@ -package service - -import ( - "fmt" - "time" - - "github.com/pocket-id/pocket-id/backend/internal/utils/email" -) - -/** -How to add new template: -- pick unique and descriptive template ${name} (for example "login-with-new-device") -- in backend/resources/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl" -- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData) - - Path *must* be ${name} -- add xxxTemplate.Path to "emailTemplatePaths" at the end - -Notes: -- backend app must be restarted to reread all the template files -- root "." object in templates is `email.TemplateData` -- xxxxTemplateData structure is visible under .Data in templates -*/ - -var NewLoginTemplate = email.Template[NewLoginTemplateData]{ - Path: "login-with-new-device", - Title: func(data *email.TemplateData[NewLoginTemplateData]) string { - return fmt.Sprintf("New device login with %s", data.AppName) - }, -} - -var OneTimeAccessTemplate = email.Template[OneTimeAccessTemplateData]{ - Path: "one-time-access", - Title: func(data *email.TemplateData[OneTimeAccessTemplateData]) string { - return "Login Code" - }, -} - -var TestTemplate = email.Template[struct{}]{ - Path: "test", - Title: func(data *email.TemplateData[struct{}]) string { - return "Test email" - }, -} - -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) - }, -} - -var EmailVerificationTemplate = email.Template[EmailVerificationTemplateData]{ - Path: "email-verification", - Title: func(data *email.TemplateData[EmailVerificationTemplateData]) string { - return "Verify your " + data.AppName + " email address" - }, -} - -type NewLoginTemplateData struct { - IPAddress string - Country string - City string - Device string - DateTime time.Time -} - -type OneTimeAccessTemplateData = struct { - Code string - LoginLink string - LoginLinkWithCode string - ExpirationString string -} - -type ApiKeyExpiringSoonTemplateData struct { - Name string - ApiKeyName string - ExpiresAt time.Time -} - -type EmailVerificationTemplateData struct { - UserFullName string - VerificationLink string -} - -// this is list of all template paths used for preloading templates -var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path, EmailVerificationTemplate.Path} diff --git a/backend/internal/service/ldap_service_test.go b/backend/internal/service/ldap_service_test.go index 6f935fc9..d29fa799 100644 --- a/backend/internal/service/ldap_service_test.go +++ b/backend/internal/service/ldap_service_test.go @@ -326,7 +326,6 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.App db, nil, nil, - nil, NewCustomClaimService(db), NewAppImagesService(map[string]string{}, fileStorage), nil, diff --git a/backend/internal/service/one_time_access_email_sender.go b/backend/internal/service/one_time_access_email_sender.go deleted file mode 100644 index 1c558c4d..00000000 --- a/backend/internal/service/one_time_access_email_sender.go +++ /dev/null @@ -1,29 +0,0 @@ -package service - -import ( - "context" - - "github.com/pocket-id/pocket-id/backend/internal/appconfig" - "github.com/pocket-id/pocket-id/backend/internal/onetimeaccess" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" -) - -// OneTimeAccessEmailSender sends the one-time access email. -// It adapts the email service, which owns the email templates, to the interface the onetimeaccess module depends on. -type OneTimeAccessEmailSender struct { - emailService *EmailService -} - -func NewOneTimeAccessEmailSender(emailService *EmailService) *OneTimeAccessEmailSender { - return &OneTimeAccessEmailSender{emailService: emailService} -} - -// SendOneTimeAccessEmail implements onetimeaccess.EmailSender -func (s *OneTimeAccessEmailSender) SendOneTimeAccessEmail(ctx context.Context, dbConfig *appconfig.AppConfigModel, to email.Address, data onetimeaccess.EmailData) error { - return SendEmail(ctx, s.emailService, dbConfig, to, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ - Code: data.Code, - LoginLink: data.LoginLink, - LoginLinkWithCode: data.LoginLinkWithCode, - ExpirationString: data.ExpirationString, - }) -} diff --git a/backend/internal/service/user_service.go b/backend/internal/service/user_service.go index 09740f83..2b4674e0 100644 --- a/backend/internal/service/user_service.go +++ b/backend/internal/service/user_service.go @@ -23,7 +23,6 @@ import ( datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" "github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/utils" - "github.com/pocket-id/pocket-id/backend/internal/utils/email" profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image" ) @@ -31,19 +30,17 @@ type UserService struct { db *gorm.DB jwtService *JwtService auditLogService *AuditLogService - emailService *EmailService customClaimService *CustomClaimService appImagesService *AppImagesService scimService *ScimService fileStorage storage.FileStorage } -func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService { +func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService { return &UserService{ db: db, jwtService: jwtService, auditLogService: auditLogService, - emailService: emailService, customClaimService: customClaimService, appImagesService: appImagesService, scimService: scimService, @@ -641,73 +638,3 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user return nil } - -func (s *UserService) SendEmailVerification(ctx context.Context, dbConfig *appconfig.AppConfigModel, userID string) error { - user, err := s.GetUser(ctx, userID) - if err != nil { - return err - } - - if user.Email == nil { - return &common.UserEmailNotSetError{} - } - - randomToken, err := utils.GenerateRandomAlphanumericString(32) - if err != nil { - return err - } - - expiration := time.Now().Add(24 * time.Hour) - emailVerificationToken := &model.EmailVerificationToken{ - UserID: user.ID, - Token: randomToken, - ExpiresAt: datatype.DateTime(expiration), - } - - err = s.db.WithContext(ctx).Create(emailVerificationToken).Error - if err != nil { - return err - } - - return SendEmail(ctx, s.emailService, dbConfig, email.Address{ - Name: user.FullName(), - Email: *user.Email, - }, EmailVerificationTemplate, &EmailVerificationTemplateData{ - UserFullName: user.FullName(), - VerificationLink: common.EnvConfig.AppURL + "/verify-email?token=" + emailVerificationToken.Token, - }) -} - -func (s *UserService) VerifyEmail(ctx context.Context, userID string, token string) error { - tx := s.db.Begin() - defer tx.Rollback() - - var emailVerificationToken model.EmailVerificationToken - err := tx.WithContext(ctx).Where("token = ? AND user_id = ? AND expires_at > ?", - token, userID, datatype.DateTime(time.Now())).First(&emailVerificationToken).Error - - if errors.Is(err, gorm.ErrRecordNotFound) { - return &common.InvalidEmailVerificationTokenError{} - } else if err != nil { - return err - } - - user, err := s.getUserInternal(ctx, emailVerificationToken.UserID, tx) - if err != nil { - return err - } - - user.EmailVerified = true - user.UpdatedAt = new(datatype.DateTime(time.Now())) - err = tx.WithContext(ctx).Save(&user).Error - if err != nil { - return err - } - - err = tx.WithContext(ctx).Delete(&emailVerificationToken).Error - if err != nil { - return err - } - - return tx.Commit().Error -} diff --git a/backend/internal/service/user_service_test.go b/backend/internal/service/user_service_test.go index 3b885d69..541e7a40 100644 --- a/backend/internal/service/user_service_test.go +++ b/backend/internal/service/user_service_test.go @@ -24,7 +24,6 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) { db, nil, nil, - nil, NewCustomClaimService(db), NewAppImagesService(map[string]string{}, fileStorage), nil, diff --git a/backend/internal/utils/email/composer.go b/backend/internal/utils/email/composer.go deleted file mode 100644 index eb03bb9f..00000000 --- a/backend/internal/utils/email/composer.go +++ /dev/null @@ -1,215 +0,0 @@ -package email - -import ( - "fmt" - "strings" - "unicode" -) - -const maxLineLength = 78 -const continuePrefix = " " -const addressSeparator = ", " - -type Composer struct { - isClosed bool - content strings.Builder -} - -func NewComposer() *Composer { - return &Composer{} -} - -type Address struct { - Name string - Email string -} - -func (c *Composer) AddAddressHeader(name string, addresses []Address) { - c.content.WriteString(genAddressHeader(name, addresses, maxLineLength)) - c.content.WriteString("\n") -} - -func genAddressHeader(name string, addresses []Address, maxLength int) string { - hl := &headerLine{ - maxLineLength: maxLength, - continuePrefix: continuePrefix, - } - - hl.Write(name) - hl.Write(": ") - - for i, addr := range addresses { - var email string - if i < len(addresses)-1 { - email = fmt.Sprintf("<%s>%s", addr.Email, addressSeparator) - } else { - email = fmt.Sprintf("<%s>", addr.Email) - } - if isPrintableASCII(addr.Name) { - writeHeaderAtom(hl, addr.Name) - } else { - writeHeaderQ(hl, addr.Name) - } - writeHeaderAtom(hl, " ") - writeHeaderAtom(hl, email) - } - hl.EndLine() - return hl.String() -} - -func (c *Composer) AddHeader(name, value string) { - if isPrintableASCII(value) && len(value)+len(name)+len(": ") < maxLineLength { - c.AddHeaderRaw(name, value) - return - } - - c.content.WriteString(genHeader(name, value, maxLineLength)) - c.content.WriteString("\n") -} - -func genHeader(name, value string, maxLength int) string { - // add content as raw header when it is printable ASCII and shorter than maxLineLength - hl := &headerLine{ - maxLineLength: maxLength, - continuePrefix: continuePrefix, - } - - hl.Write(name) - hl.Write(": ") - writeHeaderQ(hl, value) - hl.EndLine() - return hl.String() -} - -const qEncStart = "=?utf-8?q?" -const qEncEnd = "?=" - -type headerLine struct { - buffer strings.Builder - line strings.Builder - maxLineLength int - continuePrefix string -} - -func (h *headerLine) FitsLine(length int) bool { - return h.line.Len()+len(h.continuePrefix)+length+2 < h.maxLineLength -} - -func (h *headerLine) Write(str string) { - h.line.WriteString(str) -} - -func (h *headerLine) EndLineWith(str string) { - h.line.WriteString(str) - h.EndLine() -} - -func (h *headerLine) EndLine() { - if h.line.Len() == 0 { - return - } - - if h.buffer.Len() != 0 { - h.buffer.WriteString("\n") - h.buffer.WriteString(h.continuePrefix) - } - h.buffer.WriteString(h.line.String()) - h.line.Reset() -} - -func (h *headerLine) String() string { - return h.buffer.String() -} - -func writeHeaderQ(header *headerLine, value string) { - - // current line does not fit event the first character - do \n - if !header.FitsLine(len(qEncStart) + len(convertRunes(value[0:1])[0]) + len(qEncEnd)) { - header.EndLineWith("") - } - - header.Write(qEncStart) - - for _, token := range convertRunes(value) { - if header.FitsLine(len(token) + len(qEncEnd)) { - header.Write(token) - } else { - header.EndLineWith(qEncEnd) - header.Write(qEncStart) - header.Write(token) - } - } - - header.Write(qEncEnd) -} - -func writeHeaderAtom(header *headerLine, value string) { - if !header.FitsLine(len(value)) { - header.EndLine() - } - header.Write(value) -} - -func (c *Composer) AddHeaderRaw(name, value string) { - if c.isClosed { - panic("composer had already written body!") - } - header := fmt.Sprintf("%s: %s\n", name, value) - c.content.WriteString(header) -} - -func (c *Composer) Body(body string) { - c.content.WriteString("\n") - c.content.WriteString(body) - c.isClosed = true -} - -func (c *Composer) String() string { - return c.content.String() -} - -func convertRunes(str string) []string { - var enc = make([]string, 0, len(str)) - for _, r := range str { - switch { - case r == ' ': - enc = append(enc, "_") - case isPrintableASCIIRune(r) && r != '=' && r != '?' && r != '_': - enc = append(enc, string(r)) - default: - enc = append(enc, string(toHex([]byte(string(r))))) - } - } - return enc -} - -func toHex(in []byte) []byte { - enc := make([]byte, 0, len(in)*2) - for _, b := range in { - enc = append(enc, '=') - enc = append(enc, hex(b/16)) - enc = append(enc, hex(b%16)) - } - return enc -} - -func hex(n byte) byte { - if n > 9 { - return n + (65 - 10) - } else { - return n + 48 - } -} - -func isPrintableASCII(str string) bool { - for _, r := range str { - if !unicode.IsPrint(r) || r >= unicode.MaxASCII { - return false - } - } - return true -} - -func isPrintableASCIIRune(r rune) bool { - return r > 31 && r < 127 -} diff --git a/backend/internal/utils/email/composer_test.go b/backend/internal/utils/email/composer_test.go deleted file mode 100644 index bc261601..00000000 --- a/backend/internal/utils/email/composer_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package email - -import ( - "strings" - "testing" -) - -func TestConvertRunes(t *testing.T) { - var testData = map[string]string{ - "=??=_.": "=3D=3F=3F=3D=5F.", - "Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎": "P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3dy_=F0=9F=90=8E", - } - for input, expected := range testData { - got := strings.Join(convertRunes(input), "") - if got != expected { - t.Errorf("Input: '%s', expected '%s', got: '%s'", input, expected, got) - } - } -} - -type genHeaderTestData struct { - name string - value string - expected string - maxWidth int -} - -func TestGenHeaderQ(t *testing.T) { - var testData = []genHeaderTestData{ - { - name: "Subject", - value: "Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎", - expected: "Subject: =?utf-8?q?P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk?=\n" + - " =?utf-8?q?=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3?=\n" + - " =?utf-8?q?dy_=F0=9F=90=8E?=", - maxWidth: 80, - }, - } - for _, data := range testData { - got := genHeader(data.name, data.value, data.maxWidth) - if got != data.expected { - t.Errorf("Input: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.value, data.expected, got) - } - - } -} - -type genAddressHeaderTestData struct { - name string - addresses []Address - expected string - maxLength int -} - -func TestGenAddressHeader(t *testing.T) { - var testData = []genAddressHeaderTestData{ - { - name: "To", - addresses: []Address{ - { - Name: "Oldřich Jánský", - Email: "olrd@example.com", - }, - }, - expected: "To: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= ", - maxLength: 80, - }, - { - name: "Subject", - addresses: []Address{ - { - Name: "Oldřich Jánský", - Email: "olrd@example.com", - }, - { - Name: "Jan Novák", - Email: "novak@example.com", - }, - }, - expected: "Subject: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= , \n" + - " =?utf-8?q?Jan_Nov=C3=A1k?= ", - maxLength: 80, - }, - } - for _, data := range testData { - got := genAddressHeader(data.name, data.addresses, data.maxLength) - if got != data.expected { - t.Errorf("Test: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.name, data.expected, got) - } - - } -} diff --git a/backend/internal/utils/email/email_service_templates.go b/backend/internal/utils/email/email_service_templates.go deleted file mode 100644 index dbae900f..00000000 --- a/backend/internal/utils/email/email_service_templates.go +++ /dev/null @@ -1,61 +0,0 @@ -package email - -import ( - "fmt" - htemplate "html/template" - "path" - ttemplate "text/template" - - "github.com/pocket-id/pocket-id/backend/resources" -) - -type Template[V any] struct { - Path string - Title func(data *TemplateData[V]) string -} - -type TemplateData[V any] struct { - AppName string - LogoURL string - Data *V -} - -type TemplateMap[V any] map[string]*V - -func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V]) *U { - return templateMap[template.Path] -} - -func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) { - textTemplates := make(map[string]*ttemplate.Template, len(templates)) - for _, tmpl := range templates { - filename := tmpl + "_text.tmpl" - templatePath := path.Join("email-templates", filename) - - parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath) - if err != nil { - return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err) - } - - textTemplates[tmpl] = parsedTemplate - } - - return textTemplates, nil -} - -func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) { - htmlTemplates := make(map[string]*htemplate.Template, len(templates)) - for _, tmpl := range templates { - filename := tmpl + "_html.tmpl" - templatePath := path.Join("email-templates", filename) - - parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath) - if err != nil { - return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err) - } - - htmlTemplates[tmpl] = parsedTemplate - } - - return htmlTemplates, nil -} diff --git a/backend/resources/migrations/postgres/20260727120000_email_verification_actors.down.sql b/backend/resources/migrations/postgres/20260727120000_email_verification_actors.down.sql new file mode 100644 index 00000000..226343ea --- /dev/null +++ b/backend/resources/migrations/postgres/20260727120000_email_verification_actors.down.sql @@ -0,0 +1,10 @@ +CREATE TABLE email_verification_tokens +( + id UUID PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE +); + +CREATE INDEX idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at); diff --git a/backend/resources/migrations/postgres/20260727120000_email_verification_actors.up.sql b/backend/resources/migrations/postgres/20260727120000_email_verification_actors.up.sql new file mode 100644 index 00000000..b81f770b --- /dev/null +++ b/backend/resources/migrations/postgres/20260727120000_email_verification_actors.up.sql @@ -0,0 +1 @@ +DROP TABLE email_verification_tokens; diff --git a/backend/resources/migrations/sqlite/20260727120000_email_verification_actors.down.sql b/backend/resources/migrations/sqlite/20260727120000_email_verification_actors.down.sql new file mode 100644 index 00000000..cf18c3a1 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260727120000_email_verification_actors.down.sql @@ -0,0 +1,17 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +CREATE TABLE email_verification_tokens +( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL, + token TEXT NOT NULL UNIQUE, + expires_at DATETIME NOT NULL, + user_id TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE +); + +CREATE INDEX idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at); + +COMMIT; +PRAGMA foreign_keys=ON; diff --git a/backend/resources/migrations/sqlite/20260727120000_email_verification_actors.up.sql b/backend/resources/migrations/sqlite/20260727120000_email_verification_actors.up.sql new file mode 100644 index 00000000..ebfa88f3 --- /dev/null +++ b/backend/resources/migrations/sqlite/20260727120000_email_verification_actors.up.sql @@ -0,0 +1,7 @@ +PRAGMA foreign_keys=OFF; +BEGIN; + +DROP TABLE email_verification_tokens; + +COMMIT; +PRAGMA foreign_keys=ON; \ No newline at end of file diff --git a/tests/resources/export/database.json b/tests/resources/export/database.json index 1cfde9a1..2051531a 100644 --- a/tests/resources/export/database.json +++ b/tests/resources/export/database.json @@ -436,22 +436,6 @@ "id": "267f6907-7bc8-4ea1-9d47-c42a172dc1c7", "user_verification": "preferred" } - ], - "email_verification_tokens": [ - { - "created_at": "2025-11-25T12:39:02Z", - "expires_at": "2025-11-26T12:39:02Z", - "id": "ef9ca469-b178-4857-bd39-26639dca45de", - "token": "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup", - "user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" - }, - { - "created_at": "2025-11-24T12:39:02Z", - "expires_at": "2025-11-25T12:39:02Z", - "id": "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00", - "token": "EXPIRED1234567890ABCDE", - "user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036" - } ] } }