feat(profile): send confirmation invitations
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
DROP INDEX IF EXISTS idx_users_unconfirmed_email;
|
||||
|
||||
ALTER TABLE users DROP COLUMN IF EXISTS unconfirmed_email;
|
||||
@@ -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 <> '';
|
||||
Reference in New Issue
Block a user