feat(profile): send confirmation invitations

This commit is contained in:
2026-06-06 12:13:56 +08:00
parent 34fdb077be
commit 682d0ab78b
11 changed files with 512 additions and 30 deletions
+5
View File
@@ -500,6 +500,7 @@ func Bootstrap(env string) (*App, error) {
inboxMemberService := service.NewInboxMemberService(inboxMemberRepo)
accountUserRepo := repository.NewAccountUserRepo(db)
agentRepo := repository.NewAgentRepo(db)
conversationService := service.NewConversationService(conversationRepo, messageRepo, channelDispatcher, inboxMemberService, accountUserRepo, teamRepo, teamMemberRepo)
appliedSlaService := service.NewAppliedSlaService(appliedSlaRepo, slaEventRepo, slaPolicyRepo, conversationRepo)
conversationService.SetAppliedSlaService(appliedSlaService)
@@ -645,7 +646,10 @@ func Bootstrap(env string) (*App, error) {
// Team + Profile services (P5 — Teams + Team Members + User Profiles)
teamService := service.NewTeamService(teamRepo, teamMemberRepo, db)
agentService := service.NewAgentService(agentRepo, db)
agentService.SetConfirmationMailer(service.NewEnvProfileConfirmationMailer())
profileService := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
profileService.SetConfirmationMailer(service.NewEnvProfileConfirmationMailer())
// Campaign + AutoAssignment services
campaignInternalSvc := campaign.NewCampaignService(db)
@@ -841,6 +845,7 @@ func Bootstrap(env string) (*App, error) {
Upload: uploadHandler,
// Lane B: AssignableAgent handler (find agents available for assignment)
AssignableAgent: v1.NewAssignableAgentHandler(assignableAgentService),
Agent: v1.NewAgentHandler(agentService),
AgentBulk: v1.NewAgentBulkHandler(conversationService),
BulkAction: v1.NewBulkActionHandler(conversationService, contactService).WithWorkerPool(workerPool),
// Lane C: CSAT template (singular per inbox) + Inbox limits
+28 -1
View File
@@ -24,6 +24,7 @@ type AgentHandlerTestSuite struct {
suite.Suite
db *gorm.DB
handler *AgentHandler
mailer *fakeProfileConfirmationMailer
account *model.Account
user *model.User
}
@@ -34,11 +35,13 @@ func (s *AgentHandlerTestSuite) SetupSuite() {
Logger: logger.Default.LogMode(logger.Silent),
})
s.Require().NoError(err)
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}))
s.Require().NoError(db.AutoMigrate(&model.Account{}, &model.User{}, &model.AccountUser{}, &model.InstallationConfig{}))
s.db = db
agentRepo := repository.NewAgentRepo(db)
svc := service.NewAgentService(agentRepo, db)
s.mailer = &fakeProfileConfirmationMailer{}
svc.SetConfirmationMailer(s.mailer)
s.handler = NewAgentHandler(svc)
s.account = &model.Account{Name: "test-agent-account"}
@@ -58,6 +61,7 @@ func (s *AgentHandlerTestSuite) SetupTest() {
// Don't delete users — we need the inviter user to persist
// Only delete agent users (not the inviter)
s.db.Exec("DELETE FROM users WHERE id != ?", s.user.ID)
s.mailer.Reset()
}
func (s *AgentHandlerTestSuite) TearDownSuite() {
@@ -188,6 +192,29 @@ func (s *AgentHandlerTestSuite) TestCreateAgent() {
assert.Equal(s.T(), s.user.ID, membership.InvitedBy)
}
func (s *AgentHandlerTestSuite) TestCreateAgentSendsWorkspaceInvitation() {
req := service.CreateAgentRequest{Email: "invite-mail@test.com", Name: "Invite Mail", Role: "agent"}
w, c := s.makeRequest("POST", "/api/v1/accounts/1/agents", req, s.account.ID, s.user.ID)
s.handler.Create(c)
assert.Equal(s.T(), http.StatusOK, w.Code, w.Body.String())
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "invitation", mail.Kind)
assert.Equal(s.T(), "invite-mail@test.com", mail.ToEmail)
assert.Equal(s.T(), "You're invited to join test-agent-account", mail.Heading)
assert.Equal(s.T(), "Inviter Admin invited you to join the test-agent-account workspace on Chatwoot.", mail.IntroText)
assert.Equal(s.T(), "Accept invitation", mail.ActionText)
assert.Contains(s.T(), mail.ActionURL, "/app/auth/password/edit?reset_password_token=")
assert.NotEmpty(s.T(), mail.ResetPasswordToken)
var invited model.User
s.Require().NoError(s.db.Where("email = ?", "invite-mail@test.com").First(&invited).Error)
assert.NotEmpty(s.T(), invited.ResetPasswordToken)
assert.NotEqual(s.T(), mail.ResetPasswordToken, invited.ResetPasswordToken)
assert.NotNil(s.T(), invited.ResetPasswordSentAt)
}
func (s *AgentHandlerTestSuite) TestCreateAgentDefaultsBlankNameFromEmail() {
req := map[string]interface{}{
"agent": map[string]interface{}{
+102 -10
View File
@@ -2,6 +2,7 @@ package v1
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
@@ -11,6 +12,7 @@ import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -34,6 +36,7 @@ type ProfileHandlerTestSuite struct {
router *gin.Engine
handler *ProfileHandler
db *gorm.DB
mailer *fakeProfileConfirmationMailer
user *model.User
account *model.Account
@@ -100,6 +103,8 @@ func (s *ProfileHandlerTestSuite) SetupSuite() {
accessTokenRepo := repository.NewAccessTokenRepo(db)
installationConfigRepo := repository.NewInstallationConfigRepo(db)
profileSvc := service.NewProfileService(userRepo, accountUserRepo, accessTokenRepo, installationConfigRepo)
s.mailer = &fakeProfileConfirmationMailer{}
profileSvc.SetConfirmationMailer(s.mailer)
s.handler = NewProfileHandler(profileSvc)
// Build router with profile routes and auth middleware
@@ -135,26 +140,49 @@ func (s *ProfileHandlerTestSuite) SetupTest() {
s.Require().NoError(err)
// Reset user to original state before each test
s.db.Model(&model.User{}).Where("id = ?", s.userID).Updates(map[string]interface{}{
"name": "ProfileUser",
"email": "profile@example.com",
"password": passwordDigest,
"password_digest": passwordDigest,
"avatar_url": "",
"available": false,
"display_name": "Profile Display",
"message_signature": "Regards",
"pubsub_token": "pubsub-profile-user",
"name": "ProfileUser",
"email": "profile@example.com",
"password": passwordDigest,
"password_digest": passwordDigest,
"avatar_url": "",
"available": false,
"display_name": "Profile Display",
"message_signature": "Regards",
"pubsub_token": "pubsub-profile-user",
"confirmation_token": "",
"unconfirmed_email": "",
})
s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).Updates(map[string]interface{}{
s.db.Model(&model.User{}).Where("id = ?", s.userID).UpdateColumns(map[string]interface{}{
"confirmed_at": nil,
"confirmation_sent_at": nil,
"reset_password_token": "",
"reset_password_sent_at": nil,
})
s.db.Model(&model.AccountUser{}).Where("account_id = ? AND user_id = ?", s.accountID, s.userID).UpdateColumns(map[string]interface{}{
"role": "administrator",
"custom_role_id": 0,
"availability": "offline",
"auto_offline": true,
"inviter_id": 0,
})
s.db.Unscoped().Where("account_id = ?", s.accountID).Delete(&model.CustomRole{})
s.db.Unscoped().Where("owner_type = ? AND owner_id = ?", model.AccessTokenOwnerTypeUser, s.userID).Delete(&model.AccessToken{})
s.db.Unscoped().Where("name = ?", "CHATWOOT_INBOX_HMAC_KEY").Delete(&model.InstallationConfig{})
s.Require().NoError(s.db.Create(&model.AccessToken{OwnerType: model.AccessTokenOwnerTypeUser, OwnerID: s.userID, Token: "profile-token-1", TokenPrefix: "profile-", Name: "Personal Access Token"}).Error)
s.mailer.Reset()
}
type fakeProfileConfirmationMailer struct {
calls []service.ProfileConfirmationMailRequest
}
func (m *fakeProfileConfirmationMailer) SendConfirmationInstructions(_ context.Context, req service.ProfileConfirmationMailRequest) error {
m.calls = append(m.calls, req)
return nil
}
func (m *fakeProfileConfirmationMailer) Reset() {
m.calls = nil
}
func (s *ProfileHandlerTestSuite) decodeProfileBody(w *httptest.ResponseRecorder) map[string]interface{} {
@@ -724,6 +752,70 @@ func (s *ProfileHandlerTestSuite) TestResetAccessToken_RegeneratesTokenInChatwoo
assert.NotEqual(s.T(), "profile-token-1", token)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_DoesNotSendForConfirmedUser() {
now := time.Now().UTC()
s.Require().NoError(s.db.Model(&model.User{}).Where("id = ?", s.userID).Update("confirmed_at", now).Error)
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
assert.Empty(s.T(), s.mailer.calls)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_SendsConfirmationInstructions() {
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "confirmation", mail.Kind)
assert.Equal(s.T(), "profile@example.com", mail.ToEmail)
assert.Equal(s.T(), "Confirm your email to get started", mail.Heading)
assert.Equal(s.T(), "Confirm my account", mail.ActionText)
assert.Contains(s.T(), mail.ActionURL, "/app/auth/confirmation?confirmation_token=")
assert.NotEmpty(s.T(), mail.ConfirmationToken)
assert.Empty(s.T(), mail.ResetPasswordToken)
var user model.User
s.Require().NoError(s.db.First(&user, s.userID).Error)
assert.Equal(s.T(), mail.ConfirmationToken, user.ConfirmationToken)
assert.NotNil(s.T(), user.ConfirmationSentAt)
}
func (s *ProfileHandlerTestSuite) TestResendConfirmation_SendsWorkspaceInvitationForInvitedUser() {
inviter := &model.User{Name: "Inviter Admin", Email: "inviter-profile@example.com", Provider: "email", Active: true}
s.Require().NoError(s.db.Create(inviter).Error)
s.Require().NoError(s.db.Model(&model.AccountUser{}).
Where("account_id = ? AND user_id = ?", s.accountID, s.userID).
Update("inviter_id", inviter.ID).Error)
req, _ := http.NewRequest("POST", "/api/v1/profile/resend_confirmation", nil)
w := httptest.NewRecorder()
s.router.ServeHTTP(w, req)
assert.Equal(s.T(), http.StatusNoContent, w.Code)
s.Require().Len(s.mailer.calls, 1)
mail := s.mailer.calls[0]
assert.Equal(s.T(), "invitation", mail.Kind)
assert.Equal(s.T(), "Workspace invitation", mail.Eyebrow)
assert.Equal(s.T(), "You're invited to join TestAccount", mail.Heading)
assert.Equal(s.T(), "Inviter Admin invited you to join the TestAccount workspace on Chatwoot.", mail.IntroText)
assert.Equal(s.T(), "Accept invitation", mail.ActionText)
assert.Contains(s.T(), mail.ActionURL, "/app/auth/password/edit?reset_password_token=")
assert.Empty(s.T(), mail.ConfirmationToken)
assert.NotEmpty(s.T(), mail.ResetPasswordToken)
var user model.User
s.Require().NoError(s.db.First(&user, s.userID).Error)
assert.NotEmpty(s.T(), user.ResetPasswordToken)
assert.NotEqual(s.T(), mail.ResetPasswordToken, user.ResetPasswordToken)
assert.NotNil(s.T(), user.ResetPasswordSentAt)
}
// ===================== Edge Cases =====================
func (s *ProfileHandlerTestSuite) TestNewProfileHandler() {
+1
View File
@@ -37,6 +37,7 @@ type User struct {
ResetPasswordSentAt *time.Time `json:"-"`
ConfirmationToken string `gorm:"size:255;index" json:"-"`
ConfirmationSentAt *time.Time `json:"-"`
UnconfirmedEmail string `gorm:"size:255" json:"unconfirmed_email,omitempty"`
ConfirmedAt *time.Time `json:"confirmed_at,omitempty"`
LastSignInAt *time.Time `json:"last_sign_in_at,omitempty"`
CurrentSignInAt *time.Time `json:"current_sign_in_at,omitempty"`
+4
View File
@@ -36,6 +36,7 @@ type AgentDetail struct {
InvitedBy uint `json:"invited_by"`
AccountUserID uint `json:"account_user_id"`
CustomRoleID uint `json:"custom_role_id,omitempty"`
IsNewUser bool `json:"-"`
}
// ListByAccount retrieves all agents (users) for an account with pagination.
@@ -145,6 +146,7 @@ func (r *AgentRepo) FindAgentByID(ctx context.Context, userID, accountID uint) (
func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID uint, name, email, role, availability string, autoOffline bool, customRoleID uint) (*AgentDetail, error) {
// Find or create the user
var user model.User
isNewUser := false
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
if err == gorm.ErrRecordNotFound {
// Create new user
@@ -163,6 +165,7 @@ func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID u
if err := r.db.WithContext(ctx).Create(&user).Error; err != nil {
return nil, err
}
isNewUser = true
} else if err != nil {
return nil, err
}
@@ -201,6 +204,7 @@ func (r *AgentRepo) CreateAgent(ctx context.Context, accountID uint, inviterID u
InvitedBy: au.InvitedBy,
AccountUserID: au.ID,
CustomRoleID: au.CustomRoleID,
IsNewUser: isNewUser,
}, nil
}
+65 -2
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"net/mail"
"strings"
"time"
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
@@ -19,8 +20,9 @@ import (
// Reference: Chatwoot app/controllers/api/v1/accounts/agents_controller.rb
// An "agent" in Chatwoot is a User with an AccountUser association in a specific account.
type AgentService struct {
agentRepo *repository.AgentRepo
db *gorm.DB
agentRepo *repository.AgentRepo
db *gorm.DB
confirmationMailer ProfileConfirmationMailer
}
var ErrAgentNameBlank = errors.New("agent name cannot be blank")
@@ -30,6 +32,10 @@ func NewAgentService(agentRepo *repository.AgentRepo, db *gorm.DB) *AgentService
return &AgentService{agentRepo: agentRepo, db: db}
}
func (s *AgentService) SetConfirmationMailer(mailer ProfileConfirmationMailer) {
s.confirmationMailer = mailer
}
func (s *AgentService) DB() *gorm.DB {
if s == nil {
return nil
@@ -130,10 +136,67 @@ func (s *AgentService) Create(ctx context.Context, accountID uint, inviterID uin
applogger.L().Errorf("AgentService.Create: %v", err)
return nil, err
}
if detail.IsNewUser {
if err := s.sendAgentInvitationConfirmation(ctx, accountID, inviterID, detail); err != nil {
return nil, err
}
}
return detail, nil
}
func (s *AgentService) sendAgentInvitationConfirmation(ctx context.Context, accountID, inviterID uint, detail *repository.AgentDetail) error {
if s == nil || s.db == nil || detail == nil || detail.ConfirmedAt != nil {
return nil
}
resetPasswordToken, err := generateAuthToken()
if err != nil {
return fmt.Errorf("generate invitation reset token: %w", err)
}
now := time.Now().UTC()
digestedResetToken := digestAuthToken(resetPasswordToken)
if err := s.db.WithContext(ctx).Model(&model.User{}).Where("id = ?", detail.ID).Updates(map[string]interface{}{
"reset_password_token": digestedResetToken,
"reset_password_sent_at": now,
}).Error; err != nil {
return fmt.Errorf("persist invitation reset token: %w", err)
}
detail.ResetPasswordToken = digestedResetToken
detail.ResetPasswordSentAt = &now
var account model.Account
if err := s.db.WithContext(ctx).First(&account, accountID).Error; err != nil {
return fmt.Errorf("load invitation account: %w", err)
}
var inviter *model.User
if inviterID != 0 {
var inviterUser model.User
if err := s.db.WithContext(ctx).First(&inviterUser, inviterID).Error; err != nil {
return fmt.Errorf("load invitation inviter: %w", err)
}
inviter = &inviterUser
}
if s.confirmationMailer == nil {
applogger.L().Infof("confirmation mailer not configured for invited user %d (%s)", detail.ID, detail.Email)
return nil
}
brandName := s.confirmationBrandName(ctx)
req := buildProfileConfirmationMailRequest(&detail.User, &account, inviter, brandName, envConfirmationFrontendURL(), "", resetPasswordToken)
return s.confirmationMailer.SendConfirmationInstructions(ctx, req)
}
func (s *AgentService) confirmationBrandName(ctx context.Context) string {
if s == nil || s.db == nil {
return "Chatwoot"
}
var cfg model.InstallationConfig
if err := s.db.WithContext(ctx).Where("name = ?", "BRAND_NAME").First(&cfg).Error; err != nil {
return "Chatwoot"
}
return cfg.Value
}
// Update modifies an agent's details (name on User, role/availability on AccountUser).
func (s *AgentService) Update(ctx context.Context, userID, accountID uint, req UpdateAgentRequest) (*repository.AgentDetail, error) {
if err := pkgvalidator.ValidateStruct(req); err != nil {
@@ -0,0 +1,209 @@
package service
import (
"context"
"fmt"
"net/mail"
"net/smtp"
"os"
"strings"
"github.com/gochat/gochat/internal/model"
)
const confirmationInstructionsSubject = "Confirmation Instructions"
// ProfileConfirmationMailer delivers Devise-compatible confirmation instructions.
// Reference: Chatwoot Devise::Mailer#confirmation_instructions.
type ProfileConfirmationMailer interface {
SendConfirmationInstructions(ctx context.Context, req ProfileConfirmationMailRequest) error
}
type ProfileConfirmationMailRequest struct {
Kind string
UserID uint
AccountID uint
InviterID uint
ToEmail string
RecipientName string
BrandName string
AccountName string
InviterName string
Subject string
Eyebrow string
Heading string
IntroText string
SupportingText string
ActionText string
ActionURL string
ConfirmationToken string
ResetPasswordToken string
DetailRows [][2]string
}
type SMTPProfileConfirmationMailer struct {
Address string
Port int
Username string
Password string
From string
FrontendURL string
}
func NewEnvProfileConfirmationMailer() *SMTPProfileConfirmationMailer {
return &SMTPProfileConfirmationMailer{
Address: strings.TrimSpace(os.Getenv("SMTP_ADDRESS")),
Port: envInt("SMTP_PORT", 587),
Username: firstEnv("SMTP_USERNAME", "SMTP_LOGIN"),
Password: os.Getenv("SMTP_PASSWORD"),
From: firstEnv("MAILER_SENDER_EMAIL", "SMTP_FROM"),
FrontendURL: envConfirmationFrontendURL(),
}
}
func (m *SMTPProfileConfirmationMailer) SendConfirmationInstructions(ctx context.Context, req ProfileConfirmationMailRequest) error {
_ = ctx
if m == nil || strings.TrimSpace(m.Address) == "" || strings.TrimSpace(req.ToEmail) == "" {
return nil
}
fromHeader := strings.TrimSpace(m.From)
if fromHeader == "" {
fromHeader = "Chatwoot <accounts@chatwoot.com>"
}
fromAddress := fromHeader
if parsed, err := mail.ParseAddress(fromHeader); err == nil {
fromAddress = parsed.Address
}
if strings.TrimSpace(req.Subject) == "" {
req.Subject = confirmationInstructionsSubject
}
message := smtpMessage(fromHeader, req.ToEmail, req.Subject, profileConfirmationEmailBody(req))
addr := fmt.Sprintf("%s:%d", strings.TrimSpace(m.Address), m.Port)
var auth smtp.Auth
if strings.TrimSpace(m.Username) != "" {
auth = smtp.PlainAuth("", strings.TrimSpace(m.Username), m.Password, strings.TrimSpace(m.Address))
}
return smtp.SendMail(addr, auth, fromAddress, []string{req.ToEmail}, []byte(message))
}
func buildProfileConfirmationMailRequest(user *model.User, account *model.Account, inviter *model.User, brandName, frontendURL, confirmationToken, resetPasswordToken string) ProfileConfirmationMailRequest {
recipientName := strings.TrimSpace(user.Name)
if recipientName == "" {
recipientName = strings.TrimSpace(user.Email)
}
req := ProfileConfirmationMailRequest{
Kind: "confirmation",
UserID: user.ID,
ToEmail: strings.TrimSpace(user.Email),
RecipientName: recipientName,
BrandName: brandName,
Subject: confirmationInstructionsSubject,
Eyebrow: "Welcome",
Heading: "Confirm your email to get started",
IntroText: fmt.Sprintf("Welcome to %s. We just need to verify your email address before you can start using your account.", brandName),
SupportingText: "This only takes a moment.",
ActionText: "Confirm my account",
ActionURL: frontendActionURL(frontendURL, "auth/confirmation", "confirmation_token", confirmationToken),
ConfirmationToken: confirmationToken,
}
if strings.TrimSpace(user.UnconfirmedEmail) != "" {
req.Kind = "email_update"
req.Eyebrow = "Email update"
req.Heading = "Confirm your new email address"
req.IntroText = fmt.Sprintf("We received a request to update the email address on your %s account.", brandName)
req.SupportingText = "Confirm the new address below to finish the change."
req.ActionText = "Confirm email address"
req.DetailRows = append(req.DetailRows, [2]string{"New email", user.UnconfirmedEmail})
return req
}
if user.ConfirmedAt != nil {
req.Kind = "already_confirmed"
req.Eyebrow = "Account ready"
req.Heading = "Your account is ready"
req.IntroText = fmt.Sprintf("Your %s account is already active.", brandName)
req.SupportingText = "Use the button below to sign in and continue where you left off."
req.ActionText = "Open my account"
req.ActionURL = frontendActionURL(frontendURL, "auth/sign_in", "", "")
req.ConfirmationToken = ""
return req
}
if inviter != nil {
req.Kind = "invitation"
req.InviterID = inviter.ID
req.InviterName = strings.TrimSpace(inviter.Name)
if req.InviterName == "" {
req.InviterName = strings.TrimSpace(inviter.Email)
}
req.Eyebrow = "Workspace invitation"
req.ActionText = "Accept invitation"
req.ActionURL = frontendActionURL(frontendURL, "auth/password/edit", "reset_password_token", resetPasswordToken)
req.ConfirmationToken = ""
req.ResetPasswordToken = resetPasswordToken
req.SupportingText = "Create your account to start collaborating with your team."
req.DetailRows = append(req.DetailRows, [2]string{"Invited by", req.InviterName})
if account != nil {
req.AccountID = account.ID
req.AccountName = strings.TrimSpace(account.Name)
}
if req.AccountName != "" {
req.Heading = fmt.Sprintf("You're invited to join %s", req.AccountName)
req.IntroText = fmt.Sprintf("%s invited you to join the %s workspace on %s.", req.InviterName, req.AccountName, brandName)
req.DetailRows = append(req.DetailRows, [2]string{"Workspace", req.AccountName})
} else {
req.Heading = fmt.Sprintf("You're invited to try %s", brandName)
req.IntroText = fmt.Sprintf("%s invited you to try %s.", req.InviterName, brandName)
}
}
return req
}
func profileConfirmationEmailBody(req ProfileConfirmationMailRequest) string {
var b strings.Builder
b.WriteString(fmt.Sprintf("Hi %s,\n\n", req.RecipientName))
if req.Eyebrow != "" {
b.WriteString(req.Eyebrow + "\n")
}
if req.Heading != "" {
b.WriteString(req.Heading + "\n\n")
}
if req.IntroText != "" {
b.WriteString(req.IntroText + "\n")
}
if req.SupportingText != "" {
b.WriteString(req.SupportingText + "\n")
}
for _, row := range req.DetailRows {
if strings.TrimSpace(row[1]) != "" {
b.WriteString(fmt.Sprintf("%s: %s\n", row[0], row[1]))
}
}
if req.ActionText != "" && req.ActionURL != "" {
b.WriteString(fmt.Sprintf("\n%s: %s\n", req.ActionText, req.ActionURL))
}
return b.String()
}
func frontendActionURL(frontendURL, path, key, value string) string {
base := strings.TrimRight(strings.TrimSpace(frontendURL), "/")
if base == "" {
base = "/app"
} else {
base += "/app"
}
url := base + "/" + strings.TrimLeft(path, "/")
if key != "" && value != "" {
url += "?" + key + "=" + value
}
return url
}
func envConfirmationFrontendURL() string {
return strings.TrimRight(os.Getenv("FRONTEND_URL"), "/")
}
+78 -3
View File
@@ -8,6 +8,7 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"gorm.io/datatypes"
@@ -27,6 +28,7 @@ type ProfileService struct {
accountUserRepo *repository.AccountUserRepo
accessTokenRepo *repository.AccessTokenRepo
installationConfigRepo *repository.InstallationConfigRepo
confirmationMailer ProfileConfirmationMailer
}
// NewProfileService creates a new Profile service.
@@ -38,11 +40,17 @@ func NewProfileService(userRepo *repository.UserRepo, accountUserRepo *repositor
svc.accessTokenRepo = repo
case *repository.InstallationConfigRepo:
svc.installationConfigRepo = repo
case ProfileConfirmationMailer:
svc.confirmationMailer = repo
}
}
return svc
}
func (s *ProfileService) SetConfirmationMailer(mailer ProfileConfirmationMailer) {
s.confirmationMailer = mailer
}
// ProfileUserResponse matches Chatwoot app/views/api/v1/models/_user.json.jbuilder.
type ProfileUserResponse struct {
AccessToken string `json:"access_token"`
@@ -303,9 +311,76 @@ func (s *ProfileService) ResendConfirmation(ctx context.Context, userID uint) er
// Already confirmed, do nothing (per chatwoot: skip if confirmed)
return nil
}
// TODO: integrate email sending service for confirmation emails
applogger.L().Infof("ResendConfirmation called for user %d (%s)", userID, user.Email)
return nil
return s.sendConfirmationInstructions(ctx, user)
}
func (s *ProfileService) sendConfirmationInstructions(ctx context.Context, user *model.User) error {
account, inviter, err := s.confirmationAccountContext(ctx, user)
if err != nil {
return err
}
confirmationToken := ""
resetPasswordToken := ""
now := time.Now().UTC()
if inviter != nil && strings.TrimSpace(user.UnconfirmedEmail) == "" {
resetPasswordToken, err = generateAuthToken()
if err != nil {
return fmt.Errorf("generate reset password token: %w", err)
}
user.ResetPasswordToken = digestAuthToken(resetPasswordToken)
user.ResetPasswordSentAt = &now
} else {
confirmationToken, err = generateAuthToken()
if err != nil {
return fmt.Errorf("generate confirmation token: %w", err)
}
user.ConfirmationToken = confirmationToken
user.ConfirmationSentAt = &now
}
if err := s.userRepo.Update(ctx, user); err != nil {
return fmt.Errorf("persist confirmation tokens: %w", err)
}
req := buildProfileConfirmationMailRequest(user, account, inviter, s.confirmationBrandName(ctx), envConfirmationFrontendURL(), confirmationToken, resetPasswordToken)
if s.confirmationMailer == nil {
applogger.L().Infof("confirmation mailer not configured for user %d (%s)", user.ID, user.Email)
return nil
}
return s.confirmationMailer.SendConfirmationInstructions(ctx, req)
}
func (s *ProfileService) confirmationAccountContext(ctx context.Context, user *model.User) (*model.Account, *model.User, error) {
if s.accountUserRepo == nil || user == nil {
return nil, nil, nil
}
accountUsers, err := s.accountUserRepo.FindByUserWithAccounts(ctx, user.ID)
if err != nil {
return nil, nil, fmt.Errorf("load account memberships: %w", err)
}
if len(accountUsers) == 0 {
return nil, nil, nil
}
accountUser := accountUsers[0]
var inviter *model.User
if accountUser.InvitedBy != 0 && strings.TrimSpace(user.UnconfirmedEmail) == "" {
inviter, err = s.userRepo.FindByID(ctx, accountUser.InvitedBy)
if err != nil {
return nil, nil, fmt.Errorf("load inviter: %w", err)
}
}
return &accountUser.Account, inviter, nil
}
func (s *ProfileService) confirmationBrandName(ctx context.Context) string {
if s.installationConfigRepo == nil {
return "Chatwoot"
}
cfg, err := s.installationConfigRepo.FindByName(ctx, "BRAND_NAME")
if err != nil {
return "Chatwoot"
}
return cfg.Value
}
// ResetAccessToken regenerates the user's access token.