feat(profile): send confirmation invitations
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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{}{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"), "/")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user