// Service registration orchestrates new user registration. package registration import ( "bytes" "context" "embed" "errors" htmltemplate "html/template" "net" "net/mail" "strings" texttemplate "text/template" "time" authsvc "encore.app/auth" mailssvc "encore.app/mails" profilessvc "encore.app/profiles" "encore.dev/beta/errs" "encore.dev/storage/sqldb" "encore.dev/types/uuid" ) //go:embed mails/*.tmpl var mailTemplates embed.FS var db = sqldb.NewDatabase("registration", sqldb.DatabaseConfig{ Migrations: "./migrations", }) var profilesDB = sqldb.Named("profiles") const ( welcomeTokenTTL = 24 * time.Hour passwordResetTokenTTL = time.Hour frontendBaseURL = "http://localhost:9000/#" ) type RegisterParams struct { Email string `json:"email"` DisplayName string `json:"display_name"` AvatarURL string `json:"avatar_url"` Password string `json:"password" encore:"sensitive"` } type RegisterResponse struct { Profile *profilessvc.Profile `json:"profile"` } type WelcomeResponse struct { Email string `json:"email"` Confirmed bool `json:"confirmed"` UsedAt time.Time `json:"used_at"` } type PasswordResetRequestParams struct { Email string `json:"email"` } type PasswordResetRequestResponse struct { EmailSent bool `json:"email_sent"` } type PasswordResetConfirmParams struct { Password string `json:"password" encore:"sensitive"` } type PasswordResetConfirmResponse struct { Email string `json:"email"` Reset bool `json:"reset"` } type EmailAvailabilityResponse struct { Email string `json:"email"` Available bool `json:"available"` MXValid bool `json:"mx_valid"` } // CheckEmail reports whether an email can be used for a new registration. // //encore:api public method=GET path=/registration/email/:email func CheckEmail(ctx context.Context, email string) (*EmailAvailabilityResponse, error) { email = strings.TrimSpace(email) if email == "" { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "email is required"} } parsed, err := mail.ParseAddress(email) if err != nil { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"} } email = parsed.Address domain, ok := emailDomain(email) if !ok { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "invalid email domain"} } mxValid, err := hasValidMX(domain) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to check email domain") } var exists bool err = profilesDB.QueryRow(ctx, ` SELECT EXISTS(SELECT 1 FROM user_profiles WHERE email = $1) `, email).Scan(&exists) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to check email") } return &EmailAvailabilityResponse{Email: email, Available: !exists, MXValid: mxValid}, nil } // Register creates a new user and records the welcome transactional email. // //encore:api public method=POST path=/registration func Register(ctx context.Context, p *RegisterParams) (*RegisterResponse, error) { profile, err := profilessvc.Insert(ctx, &profilessvc.RegisterParams{ Email: p.Email, DisplayName: p.DisplayName, AvatarURL: p.AvatarURL, Password: p.Password, }) if err != nil { return nil, err } token, err := createWelcomeToken(ctx, profile) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to create welcome token") } mail, err := welcomeMail(profile, token) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to render welcome email") } if _, err := mailssvc.SendTransactional(ctx, mail); err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to queue welcome email") } return &RegisterResponse{Profile: profile}, nil } // ConfirmWelcome validates the welcome email token. // //encore:api public method=POST path=/registration/welcome/:token func ConfirmWelcome(ctx context.Context, token uuid.UUID) (*WelcomeResponse, error) { var email string var expiresAt time.Time var usedAt *time.Time err := db.QueryRow(ctx, ` SELECT email, expires_at, used_at FROM welcome_tokens WHERE token = $1 `, token).Scan(&email, &expiresAt, &usedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "welcome token not found"} } if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to load welcome token") } if usedAt != nil { return &WelcomeResponse{Email: email, Confirmed: true, UsedAt: *usedAt}, nil } if time.Now().After(expiresAt) { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "welcome token has expired"} } var confirmedAt time.Time err = db.QueryRow(ctx, ` UPDATE welcome_tokens SET used_at = NOW() WHERE token = $1 RETURNING used_at `, token).Scan(&confirmedAt) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to confirm welcome token") } return &WelcomeResponse{Email: email, Confirmed: true, UsedAt: confirmedAt}, nil } // RequestPasswordReset creates a reset token and queues an email when the account exists. // //encore:api public method=POST path=/registration/password-reset func RequestPasswordReset(ctx context.Context, p *PasswordResetRequestParams) (*PasswordResetRequestResponse, error) { email, err := normalizeEmail(p.Email) if err != nil { return nil, err } var profile profilessvc.Profile err = profilesDB.QueryRow(ctx, ` SELECT user_id, email, display_name, avatar_url, role, status, is_artist, created_at, updated_at FROM user_profiles WHERE email = $1 `, email).Scan( &profile.UserID, &profile.Email, &profile.DisplayName, &profile.AvatarURL, &profile.Role, &profile.Status, &profile.IsArtist, &profile.CreatedAt, &profile.UpdatedAt, ) if errors.Is(err, sqldb.ErrNoRows) { return &PasswordResetRequestResponse{EmailSent: true}, nil } if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to load profile") } token, err := createPasswordResetToken(ctx, &profile) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to create password reset token") } mail, err := passwordResetMail(&profile, token) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to render password reset email") } if _, err := mailssvc.SendTransactional(ctx, mail); err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to queue password reset email") } return &PasswordResetRequestResponse{EmailSent: true}, nil } // ConfirmPasswordReset validates a reset token and changes the account password. // //encore:api public method=POST path=/registration/password-reset/:token func ConfirmPasswordReset(ctx context.Context, token uuid.UUID, p *PasswordResetConfirmParams) (*PasswordResetConfirmResponse, error) { var userID uuid.UUID var email string var expiresAt time.Time var usedAt *time.Time err := db.QueryRow(ctx, ` SELECT user_id, email, expires_at, used_at FROM password_reset_tokens WHERE token = $1 `, token).Scan(&userID, &email, &expiresAt, &usedAt) if errors.Is(err, sqldb.ErrNoRows) { return nil, &errs.Error{Code: errs.NotFound, Message: "password reset token not found"} } if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to load password reset token") } if usedAt != nil { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token already used"} } if time.Now().After(expiresAt) { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "password reset token has expired"} } if err := authsvc.SetUserPassword(ctx, userID, &authsvc.SetUserPasswordParams{UserID: userID, Password: p.Password}); err != nil { return nil, err } _, err = db.Exec(ctx, ` UPDATE password_reset_tokens SET used_at = NOW() WHERE token = $1 `, token) if err != nil { return nil, errs.WrapCode(err, errs.Internal, "failed to consume password reset token") } return &PasswordResetConfirmResponse{Email: email, Reset: true}, nil } func createWelcomeToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) { token, err := uuid.NewV4() if err != nil { return uuid.Nil, err } _, err = db.Exec(ctx, ` INSERT INTO welcome_tokens (token, user_id, email, expires_at) VALUES ($1, $2, $3, $4) `, token, profile.UserID, profile.Email, time.Now().Add(welcomeTokenTTL)) return token, err } func createPasswordResetToken(ctx context.Context, profile *profilessvc.Profile) (uuid.UUID, error) { token, err := uuid.NewV4() if err != nil { return uuid.Nil, err } _, err = db.Exec(ctx, ` INSERT INTO password_reset_tokens (token, user_id, email, expires_at) VALUES ($1, $2, $3, $4) `, token, profile.UserID, profile.Email, time.Now().Add(passwordResetTokenTTL)) return token, err } func normalizeEmail(email string) (string, error) { email = strings.TrimSpace(email) if email == "" { return "", &errs.Error{Code: errs.InvalidArgument, Message: "email is required"} } parsed, err := mail.ParseAddress(email) if err != nil { return "", &errs.Error{Code: errs.InvalidArgument, Message: "invalid email"} } return parsed.Address, nil } func emailDomain(email string) (string, bool) { _, domain, ok := strings.Cut(email, "@") domain = strings.TrimSpace(strings.TrimSuffix(domain, ".")) return domain, ok && domain != "" } func hasValidMX(domain string) (bool, error) { records, err := net.LookupMX(domain) if err != nil { if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound { return false, nil } return false, err } for _, record := range records { if strings.TrimSpace(record.Host) != "." { return true, nil } } return false, nil } func welcomeMail(profile *profilessvc.Profile, token uuid.UUID) (*mailssvc.TransactionalMailParams, error) { name := profile.DisplayName if name == "" { name = profile.Email } data := welcomeMailData{ Name: name, Email: profile.Email, UserID: profile.UserID.String(), WelcomeURL: frontendBaseURL + "/welcome?token=" + token.String(), } subject, err := renderTextTemplate("mails/welcome_subject.tmpl", data) if err != nil { return nil, err } textBody, err := renderTextTemplate("mails/welcome_text.tmpl", data) if err != nil { return nil, err } htmlBody, err := renderHTMLTemplate("mails/welcome_html.tmpl", data) if err != nil { return nil, err } return &mailssvc.TransactionalMailParams{ To: profile.Email, Subject: subject, TextBody: textBody, HTMLBody: htmlBody, Metadata: map[string]string{ "kind": "welcome", "user_id": profile.UserID.String(), }, }, nil } type welcomeMailData struct { Name string Email string UserID string WelcomeURL string } func passwordResetMail(profile *profilessvc.Profile, token uuid.UUID) (*mailssvc.TransactionalMailParams, error) { name := profile.DisplayName if name == "" { name = profile.Email } data := passwordResetMailData{ Name: name, Email: profile.Email, UserID: profile.UserID.String(), PasswordResetURL: frontendBaseURL + "/password-reset?token=" + token.String(), } subject, err := renderTextTemplate("mails/password_reset_subject.tmpl", data) if err != nil { return nil, err } textBody, err := renderTextTemplate("mails/password_reset_text.tmpl", data) if err != nil { return nil, err } htmlBody, err := renderHTMLTemplate("mails/password_reset_html.tmpl", data) if err != nil { return nil, err } return &mailssvc.TransactionalMailParams{ To: profile.Email, Subject: subject, TextBody: textBody, HTMLBody: htmlBody, Metadata: map[string]string{ "kind": "password_reset", "user_id": profile.UserID.String(), }, }, nil } type passwordResetMailData struct { Name string Email string UserID string PasswordResetURL string } func renderTextTemplate(name string, data any) (string, error) { tmpl, err := texttemplate.ParseFS(mailTemplates, name) if err != nil { return "", err } var out bytes.Buffer if err := tmpl.Execute(&out, data); err != nil { return "", err } return strings.TrimSpace(out.String()), nil } func renderHTMLTemplate(name string, data any) (string, error) { tmpl, err := htmltemplate.ParseFS(mailTemplates, name) if err != nil { return "", err } var out bytes.Buffer if err := tmpl.Execute(&out, data); err != nil { return "", err } return strings.TrimSpace(out.String()), nil }