diff --git a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md index b4fdabfc..d523718d 100644 --- a/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md +++ b/docs/CHATWOOT_PARITY_DEVELOPMENT_PLAN.md @@ -49,10 +49,10 @@ Hermes task landing checklist: ## Current Baseline -- Current tracking checkpoint: 2026-06-06 documentation checkpoint, prepared as `docs: land invitation parity tracker`. -- Latest implementation checkpoint: `2acd938 feat(account-users): align inviter id`. -- Latest documentation/tooling checkpoint: this checkpoint, prepared as `docs: land invitation parity tracker`; this document is the active follow-up plan and supersedes `.hermes/plans/*`. -- Worktree status at this documentation checkpoint: clean before docs edits. P3.2 account-user invitation column drift from Chatwoot `account_users.inviter_id`, `AgentBuilder`, `_user.json.jbuilder`, and enterprise audit metadata is implemented. GoChat now persists AccountUser invitation source through the Chatwoot `inviter_id` column/JSON name, migrates existing `invited_by` values forward, keeps agent serializers from leaking inviter fields, and preserves profile `inviter_id` output through the existing `_user` serializer. The next active implementation slice is P3.2a invitation and confirmation mail parity: `ProfileService.ResendConfirmation` still only logs, and invited unconfirmed users need Chatwoot-shaped confirmation/invitation delivery behavior. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. +- Current tracking checkpoint: 2026-06-06 implementation checkpoint, prepared as `feat(profile): send confirmation invitations`. +- Latest implementation checkpoint: this checkpoint, prepared as `feat(profile): send confirmation invitations`. +- Latest documentation/tooling checkpoint: `34fdb07 docs: land invitation parity tracker`; this document is the active follow-up plan and supersedes `.hermes/plans/*`. +- Worktree status at this implementation checkpoint: P3.2a invitation/confirmation mail parity is implemented. `ProfileService.ResendConfirmation` now generates confirmation or invitation tokens and calls a fakeable mailer; invited unconfirmed users with `inviter_id` receive workspace invitation copy with account/inviter context and an `Accept invitation` reset-password action; normal unconfirmed users receive confirmation copy; confirmed users remain a no-op. Agent creation now sends the same invitation mail for newly created invited users, and the app bootstrap wires the account agent handler plus environment-backed mailers. SSO/SAML/LDAP/OIDC mail variants remain excluded. Phase 6 placeholder audit remains in Review; B12 live API/browser/enterprise smoke still needs the full PostgreSQL/Redis/Meilisearch/GoChat/Vite/Chrome stack. Next active slice is Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or a fresh reference/smoke finding. - `go test ./...` passes. - Route dump succeeds with `TOTAL: 905` after removing the non-Chatwoot `GET /platform/api/v1/users/:id/token` route and keeping the Chatwoot `GET /platform/api/v1/users/:id/login` route. - Route parity artifacts now exist under `docs/parity/` and are generated by `cmd/route_parity`. @@ -78,19 +78,17 @@ Next ordered checkpoints: | Order | Slice | Required outcome | Primary verification | | --- | --- | --- | --- | -| 1 | P3.2a invitation/confirmation mail parity | `ProfileService.ResendConfirmation` sends or queues Chatwoot-shaped confirmation mail, including invitation wording for users with `inviter_id`; SSO/SAML/OIDC variants remain excluded. | Focused profile/agent invitation mail tests plus full `go test ./...`. | -| 2 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. | -| 3 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. | -| 4 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. | +| 1 | Phase 2/3 audit pass | Route/controller/serializer drift found by B12 or new reference inspection is captured as named slices, not free-form TODOs. | Regenerated route parity artifacts and fixture-backed serializer tests. | +| 2 | Phase 6 placeholder burn-down | Remaining account/contact/conversation/message/inbox placeholder handlers are either real Chatwoot-compatible flows or explicitly tracked as unsupported reference gaps. | `rg` placeholder audit, route smoke, and endpoint-family tests. | +| 3 | B12 optional live smoke | Run the checked smoke harness in a full PostgreSQL/Redis/Meilisearch/Vite/Chrome environment and turn failures into named slices. | `docs/parity/frontend_smoke_report.md` pass/fail entries linked to owners. | Execution queue for the next agent turn: | Order | Slice ID | Why now | Required commit contents | | --- | --- | --- | --- | -| 1 | P3.2a invitation/confirmation mail parity | Fresh reference audit found the next concrete reused-frontend/account-user gap: confirmation resend is a TODO-only log, while Chatwoot sends invitation-style confirmation mail for invited unconfirmed users. | Mail delivery boundary, profile resend behavior, focused tests, tracker update, and no route artifacts unless route wiring changes. | -| 2 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. | -| 3 | Phase 6 placeholder audit | `chatwootParityStub` remains only as webhook nil-handler fallback, but every audit result must stay recorded so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. | -| 4 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. | +| 1 | Phase 2/3 drift audit | Route parity is currently green for the tracked set, but only fresh reference/frontend inspection proves the next missing reused-frontend path. | Named drift rows with inspected reference files, fixture tests where behavior is known, regenerated route artifacts if the route set changes. | +| 2 | Phase 6 placeholder audit | `chatwootParityStub` remains only as webhook nil-handler fallback, but every audit result must stay recorded so no frontend-critical stub becomes ownerless. | `rg` audit result, either a burn-down implementation or explicit non-frontend fallback classification, focused tests if code changes. | +| 3 | B12 live smoke | The harness is checked in; live pass/fail still needs the full PostgreSQL/Redis/Meilisearch/Vite/Chrome stack. | Updated smoke report with command, environment, failures, and linked owner rows. | Slice lifecycle: @@ -140,7 +138,7 @@ This table is the shortest authoritative handoff view. If an older lower section | Priority | Workstream | Current state | Next checkpoint | Commit close rule | | --- | --- | --- | --- | --- | -| 1 | P3.2a invitation/confirmation mail parity | Account-user inviter persistence is implemented, but `ProfileService.ResendConfirmation` still logs instead of delivering confirmation mail. Chatwoot sends invitation wording for invited unconfirmed users with blank `unconfirmed_email`; normal unconfirmed users get confirmation wording. Enterprise SAML/OIDC variants are explicitly out of scope. | Implement a fakeable mail delivery boundary and wire resend/create invitation paths to Chatwoot-shaped confirmation/invitation email payloads. | Focused profile and agent invitation tests cover confirmed no-op, normal confirmation, invited workspace invitation, inviter/account wording, and excluded SSO variants; full `go test ./...` and `git diff --check` pass. | +| 1 | P3.2a invitation/confirmation mail parity | Implemented for current non-SSO reference behavior: profile resend is no longer a TODO-only log, new invited agents and unconfirmed invited profile resends generate reset-password invitation links, normal unconfirmed profile resends generate confirmation links, `users.unconfirmed_email` is modeled for email-update routing, and fakeable/environment SMTP mailers keep default tests offline. | Keep in Review; reopen only if reused frontend smoke or fresh reference evidence exposes additional Devise confirmation states outside excluded SSO/SAML/LDAP/OIDC variants. | Focused profile and agent invitation tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed. | | 2 | Phase 2/3 drift | Tracked route parity is 0 missing for the current 362-route critical set; dashboard `/app` shell routes from `routes.rb:19-20`, `.well-known` app association and custom-domain challenge routes from `routes.rb:657-660`, Twilio callback routes from `routes.rb:639-640`, enterprise Twilio voice routes from `routes.rb:643-646`, root Linear/Shopify/Notion OAuth callback routes from `routes.rb:630/634/654`, root Twitter/Google/Microsoft/Instagram/TikTok callback routes from `routes.rb:626/649-652`, assignment policy routes from `routes.rb:306-313`, help-center portal/category/article routes from `routes.rb:385-404`, public help-center portal/sitemap/article/category/search/article-detail routes from `routes.rb:590-601`, enterprise contact outbound voice call from `routes.rb:216`, account agent-bot routes from `routes.rb:94-97`, account webhook routes from `routes.rb:342`, and account integration app/hook routes from `routes.rb:345-348` are now explicitly tracked. Notification list/action serializers, user notification-settings raw payloads, campaigns raw payload/display-id routes, Devise password reset/confirmation payloads, CRM shared attachment payloads plus fixed 100-row attachment pagination, account/settings payloads, assignable-agent payloads, agent index full-list behavior, agent create/update/delete defaults/errors/scope, account agent-bot route/payload/mutation behavior, account webhook payload/mutation behavior, integration app/hook payload behavior, label CRUD payloads, custom filters, custom attribute definitions, contact outbound voice calls, assignment policy CRUD and inbox binding payloads, Twilio inbound/status callbacks, enterprise Twilio voice callbacks, Linear/Shopify/Notion root integration callbacks, Shopify OAuth auth redirects, root channel OAuth callbacks, help-center portal/category/article payloads, dashboard app shell route behavior, app association JSON payloads, Cloudflare custom hostname verification, public widget popular-article lists, public help-center category list/show payloads, public portal show/default-locale payloads, public portal search payloads, public article show/markdown/tracking routes, and public sitemap XML now match the inspected Chatwoot contract. | Continue the next evidence-backed route/controller/serializer drift after P3.2a or from B12 findings. | Regenerate parity artifacts when routes change and add endpoint-family fixture tests. | | 3 | Phase 6 placeholder audit | Widget/public/webhook critical placeholders are burned down; inbox WhatsApp health/register-webhook and sync-template drift are closed; refreshed `docs/parity/placeholder_audit.md` shows only webhook nil-handler fallbacks still call `chatwootParityStub`; dashboard conversation transcript/custom-attribute response drift and message retry status drift are closed. | Keep in Review; reopen only if fresh `rg`, route smoke, or B12 finds a frontend-reachable placeholder/stub in account/contact/conversation/message/inbox/widget/public paths. | `rg` placeholder audit and `scripts/parity_frontend_smoke.sh --check` are recorded; no reused-frontend blocker is ownerless. | | 4 | P3.9 account agent-bot API | Implemented for the reused dashboard AgentBots settings route with no-trailing-slash routes, PATCH update, raw Jbuilder-style payloads, account mutation scope, system-bot show/list visibility, empty `200 OK` delete, and full reset/avatar action payloads. | Keep in Review; reopen only if live settings smoke exposes avatar upload storage or administrator-secret gating drift. | Focused AgentBot handler tests, service/router focused tests, route dump/parity regeneration, full `go test ./...`, and `git diff --check` passed. | @@ -157,7 +155,7 @@ These rows are the executable development plan from this point forward. A checkp | ID | Owner files | Reference files | Work to land | Exit gate | | --- | --- | --- | --- | --- | -| P3.2a invitation/confirmation mail parity | `internal/service/profile_service.go`, `internal/handler/api/v1/profile_handler.go`, `internal/service/agent_service.go`, `internal/repository/agent_repo.go`, existing fakeable mail delivery boundaries, profile/agent handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/agents_controller.rb`, `reference/chatwoot/app/builders/agent_builder.rb`, `reference/chatwoot/app/views/devise/mailer/confirmation_instructions.html.erb`, `reference/chatwoot/spec/mailers/confirmation_instructions_spec.rb`, `reference/chatwoot/spec/enterprise/mailers/devise_mailer_spec.rb` for non-SAML invitation wording only | Replace TODO-only resend behavior with durable or fakeable confirmation mail delivery. For confirmed users, resend remains a no-op. For ordinary unconfirmed users, send confirmation-style mail. For invited unconfirmed users whose account membership has `inviter_id` and whose `unconfirmed_email` is blank, send workspace invitation-style confirmation mail with account and inviter context plus `Accept invitation` action text. Keep enterprise SSO/SAML/LDAP/OIDC mail variants out of scope per user decision. | Review by `feat(profile): send confirmation invitations`; focused tests cover resend no-op for confirmed users, normal confirmation mail, invited-user workspace invitation mail, agent creation/inviter context where applicable, and no network in default tests; full `go test ./...` and `git diff --check` pass. | +| P3.2a invitation/confirmation mail parity | `internal/service/profile_service.go`, `internal/service/profile_confirmation_mailer.go`, `internal/handler/api/v1/profile_handler.go`, `internal/service/agent_service.go`, `internal/repository/agent_repo.go`, `internal/model/user.go`, `migrations/000032_add_users_unconfirmed_email.*`, profile/agent handler tests | `reference/chatwoot/app/controllers/api/v1/accounts/agents_controller.rb`, `reference/chatwoot/app/builders/agent_builder.rb`, `reference/chatwoot/app/views/devise/mailer/confirmation_instructions.html.erb`, `reference/chatwoot/spec/mailers/confirmation_instructions_spec.rb`, `reference/chatwoot/spec/enterprise/mailers/devise_mailer_spec.rb` for non-SAML invitation wording only | Done. A shared fakeable confirmation mailer builds Chatwoot-shaped confirmation/invitation payloads; profile resend persists confirmation/reset tokens and delivers no-op/confirmation/invitation states; newly created invited agents get workspace invitation mail; `unconfirmed_email` is modeled for email-update branch routing; environment SMTP remains a no-op when not configured. SSO/SAML/LDAP/OIDC mail variants stay excluded. | Review by `feat(profile): send confirmation invitations`; focused tests cover confirmed no-op, normal confirmation mail, invited workspace invitation mail, agent creation/inviter context, hashed reset-token persistence, and no network in default tests; full `go test ./...` and `git diff --check` passed. | | P5.11a Captain document crawl/schedule | `internal/service/captain_document_service.go`, `internal/service/captain_document_worker.go`, `internal/app/bootstrap.go` | `reference/chatwoot/enterprise/app/jobs/captain/documents/crawl_job.rb`, `schedule_syncs_job.rb`, `perform_sync_job.rb`, Firecrawl/simple parser jobs | Durable schedule/crawl producers and handlers with fakeable crawl/parser boundaries. Missing provider config is a failed `crawl_disabled` state, not placeholder success. | Review by `feat(captain): queue document crawl jobs`; focused worker tests prove enqueue, replay, account scope, idempotent scheduler, and disabled/failure states. | | P5.11b Captain response/embedding fan-out | Captain document/assistant-response services and repositories, Meilisearch/embedding boundaries | `response_builder_job.rb`, `enterprise/app/jobs/captain/llm/update_embedding_job.rb`, FAQ generator/embedding services | Queue FAQ response generation after successful document content changes, reset unedited responses, create/update assistant responses, and fan out embedding update work behind fakeable LLM gates. | Review by `feat(captain): queue response embedding jobs`; tests cover response reset/create, embedding-disabled retry, fake embedding success, account scope, and no external network in default tests. | | P5.11c Copilot and conversation response jobs | `internal/service/copilot_service.go`, `internal/service/copilot_response_worker.go`, `internal/service/captain_conversation_service.go`, `internal/service/message_service.go`, `internal/app/bootstrap.go` | `enterprise/app/jobs/captain/copilot/response_job.rb`, `enterprise/app/jobs/captain/conversation/response_builder_job.rb`, `enterprise/app/services/captain/copilot/chat_service.rb`, `enterprise/app/services/enterprise/message_templates/hook_execution_service.rb`, `enterprise/app/models/copilot_message.rb` | Queue assistant replies after Copilot user messages and Captain pending-conversation triggers. Persist assistant messages, enqueue Captain conversation replies/handoff messages, open handoff conversations, and keep fakeable provider disabled/failure states observable through durable retry. | Review by `feat(captain): queue copilot response jobs`; focused tests cover Copilot enqueue/persist/fallback/retry, Captain conversation enqueue/handoff/retry/non-pending skip, and service/worker/app package replay. | @@ -213,6 +211,7 @@ This ledger records the committed parity checkpoints that future slices should b | Commit | Scope | Verification summary | Follow-up state | | --- | --- | --- | --- | +| `feat(profile): send confirmation invitations` | Advances P3.2a invitation/confirmation mail parity by matching Chatwoot `AgentBuilder`, Devise confirmation instructions view/specs, and profile resend controller behavior for non-SSO flows. GoChat now has a fakeable/environment SMTP confirmation mail boundary; profile resend persists confirmation or reset-password tokens and sends normal confirmation or workspace invitation payloads; confirmed resends remain no-op; newly created invited agents send invitation mail; `users.unconfirmed_email` is modeled for email-update branch routing; and bootstrap wires the account agent handler plus confirmation mailers. | `go test ./internal/handler/api/v1 -run 'ProfileHandlerSuite/TestResendConfirmation\|AgentHandlerSuite/TestCreateAgent' -count=1`; `go test ./internal/service ./internal/repository ./cmd/migrate ./internal/app -run 'Profile\|Agent\|App\|Migrate' -count=1`; `go test ./internal/handler/api/v1 ./internal/service ./internal/repository ./internal/router ./cmd/migrate ./internal/app -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | P3.2a moves to Review for current non-SSO reference evidence; continue Phase 2/3 drift audit, Phase 6 placeholder audit, B12 live smoke, or fresh reference/smoke drift. | | `docs: land invitation parity tracker` | Documentation-only checkpoint requested before continuing implementation. It records the clean `2acd938 feat(account-users): align inviter id` baseline, promotes P3.2a invitation/confirmation mail parity to the top active slice, maps the inspected Chatwoot Devise/AgentBuilder references into an executable row, and keeps SSO/SAML/LDAP/OIDC mail variants excluded. | `git diff --check`. No Go code changed and no route artifacts changed. | Start `feat(profile): send confirmation invitations`, then continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke from concrete reference/smoke evidence. | | `feat(account-users): align inviter id` | Advances P3.2 invitation/account-user parity by matching Chatwoot `account_users.inviter_id`, `AgentBuilder#create_account_user`, `app/views/api/v1/models/_user.json.jbuilder`, and enterprise `Audit::AccountUser`. GoChat now maps `model.AccountUser.InvitedBy` to the Chatwoot `inviter_id` column and JSON name, emits account-user lifecycle payloads with `inviter_id`, adds migration `000031` to copy existing `invited_by` values forward, and keeps account agent serializers free of inviter fields. | `go test ./internal/model -run AccountUserDefaultValues -count=1`; `go test ./internal/handler/api/v1 -run 'TestAgentHandlerSuite/TestCreateAgent' -count=1`; `go test ./internal/service ./internal/repository -run 'AccountUser\|Agent' -count=1`; `go test ./cmd/migrate ./internal/app -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | AccountUser inviter persistence moves to Review for current reference evidence; continue remaining P3.2 invitation mail/confirmation drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. | | `feat(profile): expose hmac identifier` | Advances P3.1 profile `_user` serializer parity by matching Chatwoot `app/views/api/v1/models/_user.json.jbuilder`, `UserAttributeHelpers#hmac_identifier`, `spec/models/user_spec.rb`, and reused frontend `scriptHelpers.js`. GoChat profile responses now omit `hmac_identifier` when `CHATWOOT_INBOX_HMAC_KEY` is not configured and include the SHA-256 HMAC of the user email keyed by that installation config when present. | `go test ./internal/handler/api/v1 -run 'ProfileHandlerSuite/TestGet' -count=1`; `go test ./internal/service -run Profile -count=1`; full `go test ./...`; `git diff --check`. No route artifacts changed. | Profile `_user` HMAC field moves to Review for current reference evidence; continue remaining P3.2 invitations drift, B12 live smoke, or the next evidence-backed route/controller/serializer drift. | @@ -2379,3 +2378,4 @@ Verification milestone gates: - 2026-06-06: Placeholder/smoke audit checkpoint prepared as `docs: record placeholder smoke audit`; `docs/parity/placeholder_audit.md` now records the `chatwootParityStub` audit and classifies all remaining call sites as public webhook nil-handler guards, with no reused dashboard account/contact/conversation/message/inbox/widget/public critical-path stubs. `scripts/parity_frontend_smoke.sh --check` passed and refreshed `docs/parity/frontend_smoke_report.md`; live API/browser/enterprise smoke remains pending on the full external stack. Continue B12 live smoke when available or the next evidence-backed P2/P3 drift. - 2026-06-06: P3.2 agent serializer-field checkpoint prepared as `feat(agents): align serializer fields`; audited Chatwoot `AgentsController` Jbuilder views and `app/views/api/v1/models/_agent.json.jbuilder`. Account agent list/create/show/update responses now remove local `invited_by` and `account_user_id` fields and keep only Chatwoot `_agent` fields plus enterprise `custom_role_id`, preserving account-user role, availability, auto-offline, and custom-role behavior behind the serializer. Focused agent handler/service/repository/router tests, escalated full `go test ./...`, and `git diff --check` passed; continue remaining P3.2 account users/invitations drift or B12 live smoke. - 2026-06-06: Documentation checkpoint prepared as `docs: land invitation parity tracker`; worktree was clean at `2acd938 feat(account-users): align inviter id`, and the active plan now makes P3.2a invitation/confirmation mail parity the first executable slice. The row records Chatwoot AgentBuilder/Devise confirmation references, current Go owner files, normal confirmation vs invited workspace invitation behavior, excluded SSO/SAML/LDAP/OIDC mail variants, and focused exit gates. Verification for this docs-only checkpoint: `git diff --check`; next slice is `feat(profile): send confirmation invitations`. +- 2026-06-06: P3.2a invitation/confirmation mail checkpoint prepared as `feat(profile): send confirmation invitations`; audited Chatwoot `AgentBuilder`, Devise confirmation mail view/specs, and profile resend specs. GoChat now persists confirmation/reset tokens, sends fakeable Chatwoot-shaped confirmation or workspace invitation mail from profile resend, sends invitation mail for newly created invited agents, models `users.unconfirmed_email`, wires the account agent handler in bootstrap, and keeps SSO/SAML/LDAP/OIDC variants excluded. Focused profile/agent tests, combined handler/service/repository/router/migrate/app tests, full `go test ./...`, and `git diff --check` passed; continue Phase 2/3 drift audit, Phase 6 placeholder audit, or B12 live smoke. diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go index 98b76228..6b2f7caa 100644 --- a/internal/app/bootstrap.go +++ b/internal/app/bootstrap.go @@ -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 diff --git a/internal/handler/api/v1/agent_handler_test.go b/internal/handler/api/v1/agent_handler_test.go index 04b1d121..12181d45 100644 --- a/internal/handler/api/v1/agent_handler_test.go +++ b/internal/handler/api/v1/agent_handler_test.go @@ -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{}{ diff --git a/internal/handler/api/v1/profile_handler_test.go b/internal/handler/api/v1/profile_handler_test.go index 87f3ed7f..5a40a8fd 100644 --- a/internal/handler/api/v1/profile_handler_test.go +++ b/internal/handler/api/v1/profile_handler_test.go @@ -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() { diff --git a/internal/model/user.go b/internal/model/user.go index a4190563..4d03661d 100644 --- a/internal/model/user.go +++ b/internal/model/user.go @@ -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"` diff --git a/internal/repository/agent_repo.go b/internal/repository/agent_repo.go index ac261472..aa7ef93c 100644 --- a/internal/repository/agent_repo.go +++ b/internal/repository/agent_repo.go @@ -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 } diff --git a/internal/service/agent_service.go b/internal/service/agent_service.go index 4de59468..a4280c54 100644 --- a/internal/service/agent_service.go +++ b/internal/service/agent_service.go @@ -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 { diff --git a/internal/service/profile_confirmation_mailer.go b/internal/service/profile_confirmation_mailer.go new file mode 100644 index 00000000..94729286 --- /dev/null +++ b/internal/service/profile_confirmation_mailer.go @@ -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 " + } + 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"), "/") +} diff --git a/internal/service/profile_service.go b/internal/service/profile_service.go index bfd99764..e07e36f9 100644 --- a/internal/service/profile_service.go +++ b/internal/service/profile_service.go @@ -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. diff --git a/migrations/000032_add_users_unconfirmed_email.down.sql b/migrations/000032_add_users_unconfirmed_email.down.sql new file mode 100644 index 00000000..0993a724 --- /dev/null +++ b/migrations/000032_add_users_unconfirmed_email.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_users_unconfirmed_email; + +ALTER TABLE users DROP COLUMN IF EXISTS unconfirmed_email; diff --git a/migrations/000032_add_users_unconfirmed_email.up.sql b/migrations/000032_add_users_unconfirmed_email.up.sql new file mode 100644 index 00000000..59f2753b --- /dev/null +++ b/migrations/000032_add_users_unconfirmed_email.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS unconfirmed_email VARCHAR(255); + +CREATE INDEX IF NOT EXISTS idx_users_unconfirmed_email ON users(unconfirmed_email) WHERE unconfirmed_email IS NOT NULL AND unconfirmed_email <> '';