refactor: modularize email module and use actor for email verification (#1625)

This commit is contained in:
Elias Schneider
2026-07-27 20:00:59 +02:00
committed by GitHub
parent 6bd4679bab
commit 43aaccd5bf
36 changed files with 1562 additions and 923 deletions
@@ -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()
@@ -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)
}
@@ -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
@@ -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
+1 -51
View File
@@ -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)
}
+265
View File
@@ -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
}
+345
View File
@@ -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, "<sender@example.test>", session.mailFrom)
assert.Equal(t, "<recipient@example.test>", session.rcptTo)
assert.Contains(t, session.message, "From: Pocket ID Test <sender@example.test>\r\n")
assert.Contains(t, session.message, "To: Test User <recipient@example.test>\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 <CR><LF>.<CR><LF>")
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
}
}
}
+77
View File
@@ -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,
}
+196
View File
@@ -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
}
@@ -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)
}
@@ -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)
}
@@ -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))
}
}
@@ -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)
}
+17 -12
View File
@@ -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),
-14
View File
@@ -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
}
@@ -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)
@@ -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
}
+1 -10
View File
@@ -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 {
+10 -10
View File
@@ -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
@@ -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
}
+19 -14
View File
@@ -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
+22 -26
View File
@@ -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 {
-175
View File
@@ -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://<username>:<password>@<host>:<port>?fromAddress=<address>&fromName=<name>&tls=<none|starttls|tls>&insecureSkipVerify=<true|false>
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
}
@@ -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}
@@ -326,7 +326,6 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *appconfig.App
db,
nil,
nil,
nil,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
nil,
@@ -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,
})
}
+1 -74
View File
@@ -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
}
@@ -24,7 +24,6 @@ func newTestUserService(t *testing.T) (*UserService, *UserGroupService) {
db,
nil,
nil,
nil,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
nil,
-215
View File
@@ -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
}
@@ -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?= <olrd@example.com>",
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?= <olrd@example.com>, \n" +
" =?utf-8?q?Jan_Nov=C3=A1k?= <novak@example.com>",
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)
}
}
}
@@ -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
}
@@ -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);
@@ -0,0 +1 @@
DROP TABLE email_verification_tokens;
@@ -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;
@@ -0,0 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP TABLE email_verification_tokens;
COMMIT;
PRAGMA foreign_keys=ON;
-16
View File
@@ -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"
}
]
}
}