mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-08-19 11:26:25 +00:00
refactor: modularize email module and use actor for email verification (#1625)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+10
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user