diff --git a/.circleci/config.yml b/.circleci/config.yml index 65ceda04c..804c63857 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,6 +3,7 @@ orbs: node: circleci/node@6.1.0 qlty-orb: qltysh/qlty-orb@0.0 +# Shared defaults for setup steps defaults: &defaults working_directory: ~/build machine: @@ -12,10 +13,106 @@ defaults: &defaults RAILS_LOG_TO_STDOUT: false COVERAGE: true LOG_LEVEL: warn - parallelism: 4 jobs: - build: + # Separate job for linting (no parallelism needed) + lint: + <<: *defaults + steps: + - checkout + + # Install minimal system dependencies for linting + - run: + name: Install System Dependencies + command: | + sudo apt-get update + DEBIAN_FRONTEND=noninteractive sudo apt-get install -y \ + libpq-dev \ + build-essential \ + git \ + curl \ + libssl-dev \ + zlib1g-dev \ + libreadline-dev \ + libyaml-dev \ + openjdk-11-jdk \ + jq \ + software-properties-common \ + ca-certificates \ + imagemagick \ + libxml2-dev \ + libxslt1-dev \ + file \ + g++ \ + gcc \ + autoconf \ + gnupg2 \ + patch \ + ruby-dev \ + liblzma-dev \ + libgmp-dev \ + libncurses5-dev \ + libffi-dev \ + libgdbm6 \ + libgdbm-dev \ + libvips + + - run: + name: Install RVM and Ruby 3.4.4 + command: | + sudo apt-get install -y gpg + gpg --keyserver hkp://keyserver.ubuntu.com --recv-keys 409B6B1796C275462A1703113804BB82D39DC0E3 7D2BAF1CF37B13E2069D6956105BD0E739499BDB + \curl -sSL https://get.rvm.io | bash -s stable + echo 'source ~/.rvm/scripts/rvm' >> $BASH_ENV + source ~/.rvm/scripts/rvm + rvm install "3.4.4" + rvm use 3.4.4 --default + gem install bundler -v 2.5.16 + + - run: + name: Install Application Dependencies + command: | + source ~/.rvm/scripts/rvm + bundle install + + - node/install: + node-version: '23.7' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + + # Swagger verification + - run: + name: Verify swagger API specification + command: | + bundle exec rake swagger:build + if [[ `git status swagger/swagger.json --porcelain` ]] + then + echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." + exit 1 + fi + mkdir -p ~/tmp + curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar + java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json + + # Bundle audit + - run: + name: Bundle audit + command: bundle exec bundle audit update && bundle exec bundle audit check -v + + # Rubocop linting + - run: + name: Rubocop + command: bundle exec rubocop --parallel + + # ESLint linting + - run: + name: eslint + command: pnpm run eslint + + # Separate job for frontend tests + frontend-tests: <<: *defaults steps: - checkout @@ -25,8 +122,38 @@ jobs: - node/install-packages: pkg-manager: pnpm override-ci-command: pnpm i - - run: node --version - - run: pnpm --version + + - run: + name: Run frontend tests (with coverage) + command: pnpm run test:coverage + + - run: + name: Move coverage files if they exist + command: | + if [ -d "coverage" ]; then + mkdir -p ~/build/coverage + cp -r coverage ~/build/coverage/frontend || true + fi + when: always + + - persist_to_workspace: + root: ~/build + paths: + - coverage + + # Backend tests with parallelization + backend-tests: + <<: *defaults + parallelism: 16 + steps: + - checkout + - node/install: + node-version: '23.7' + - node/install-pnpm + - node/install-packages: + pkg-manager: pnpm + override-ci-command: pnpm i + - run: name: Add PostgreSQL repository and update command: | @@ -91,20 +218,6 @@ jobs: source ~/.rvm/scripts/rvm bundle install - # Swagger verification - - run: - name: Verify swagger API specification - command: | - bundle exec rake swagger:build - if [[ `git status swagger/swagger.json --porcelain` ]] - then - echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'." - exit 1 - fi - mkdir -p ~/tmp - curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar - java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json - # Configure environment and database - run: name: Database Setup and Configure Environment Variables @@ -127,57 +240,91 @@ jobs: name: Run DB migrations command: bundle exec rails db:chatwoot_prepare - # Bundle audit - - run: - name: Bundle audit - command: bundle exec bundle audit update && bundle exec bundle audit check -v - - # Rubocop linting - - run: - name: Rubocop - command: bundle exec rubocop - - # ESLint linting - - run: - name: eslint - command: pnpm run eslint - - - run: - name: Run frontend tests (with coverage) - command: | - mkdir -p ~/build/coverage/frontend - pnpm run test:coverage - - # Run backend tests + # Run backend tests (parallelized) - run: name: Run backend tests command: | mkdir -p ~/tmp/test-results/rspec mkdir -p ~/tmp/test-artifacts mkdir -p ~/build/coverage/backend - TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) + + # Use round-robin distribution (same as GitHub Actions) for better test isolation + # This prevents tests with similar timing from being grouped on the same runner + SPEC_FILES=($(find spec -name '*_spec.rb' | sort)) + TESTS="" + + for i in "${!SPEC_FILES[@]}"; do + if [ $(( i % $CIRCLE_NODE_TOTAL )) -eq $CIRCLE_NODE_INDEX ]; then + TESTS="$TESTS ${SPEC_FILES[$i]}" + fi + done + bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \ --format RspecJunitFormatter \ --out ~/tmp/test-results/rspec.xml \ - -- ${TESTFILES} + -- $TESTS no_output_timeout: 30m - # Qlty coverage publish - - qlty-orb/coverage_publish: - files: | - coverage/coverage.json - coverage/lcov.info + # Store test results for better splitting in future runs + - store_test_results: + path: ~/tmp/test-results - run: - name: List coverage directory contents + name: Move coverage files if they exist command: | - ls -R ~/build/coverage + if [ -d "coverage" ]; then + mkdir -p ~/build/coverage + cp -r coverage ~/build/coverage/backend || true + fi + when: always - persist_to_workspace: root: ~/build paths: - coverage + # Collect coverage from all jobs + coverage: + <<: *defaults + steps: + - checkout + - attach_workspace: + at: ~/build + + # Qlty coverage publish + - qlty-orb/coverage_publish: + files: | + coverage/frontend/lcov.info + + - run: + name: List coverage directory contents + command: | + ls -R ~/build/coverage || echo "No coverage directory" + - store_artifacts: path: coverage destination: coverage + + build: + <<: *defaults + steps: + - run: + name: Legacy build aggregator + command: | + echo "All main jobs passed; build job kept only for GitHub required check compatibility." + +workflows: + version: 2 + build: + jobs: + - lint + - frontend-tests + - backend-tests + - coverage: + requires: + - frontend-tests + - backend-tests + - build: + requires: + - lint + - coverage diff --git a/.env.example b/.env.example index de671599c..d5c7a76f9 100644 --- a/.env.example +++ b/.env.example @@ -105,6 +105,7 @@ MAILER_INBOUND_EMAIL_DOMAIN= # mandrill for Mandrill # postmark for Postmark # sendgrid for Sendgrid +# ses for Amazon SES RAILS_INBOUND_EMAIL_SERVICE= # Use one of the following based on the email ingress service # Ref: https://edgeguides.rubyonrails.org/action_mailbox_basics.html @@ -114,6 +115,10 @@ RAILS_INBOUND_EMAIL_PASSWORD= MAILGUN_INGRESS_SIGNING_KEY= MANDRILL_INGRESS_API_KEY= +# SNS topic ARN for ActionMailbox (format: arn:aws:sns:region:account-id:topic-name) +# Configure only if the rails_inbound_email_service = ses +ACTION_MAILBOX_SES_SNS_TOPIC= + # Creating Your Inbound Webhook Instructions for Postmark and Sendgrid: # Inbound webhook URL format: # https://actionmailbox:[YOUR_RAILS_INBOUND_EMAIL_PASSWORD]@[YOUR_CHATWOOT_DOMAIN.COM]/rails/action_mailbox/[RAILS_INBOUND_EMAIL_SERVICE]/inbound_emails @@ -215,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38: ## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables # DD_TRACE_AGENT_URL= + # MaxMindDB API key to download GeoLite2 City database # IP_LOOKUP_API_KEY= @@ -256,6 +262,8 @@ AZURE_APP_SECRET= ## Change these values to fine tune performance # control the concurrency setting of sidekiq # SIDEKIQ_CONCURRENCY=10 +# Enable verbose logging each time a job is dequeued in Sidekiq +# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false # AI powered features diff --git a/.github/workflows/run_foss_spec.yml b/.github/workflows/run_foss_spec.yml index 385feddfc..011f862b0 100644 --- a/.github/workflows/run_foss_spec.yml +++ b/.github/workflows/run_foss_spec.yml @@ -1,4 +1,6 @@ name: Run Chatwoot CE spec +permissions: + contents: read on: push: branches: @@ -8,11 +10,58 @@ on: workflow_dispatch: jobs: - test: - runs-on: ubuntu-22.04 + # Separate linting jobs for faster feedback + lint-backend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: ruby/setup-ruby@v1 + with: + bundler-cache: true + - name: Run Rubocop + run: bundle exec rubocop --parallel + + lint-frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 23 + cache: 'pnpm' + - name: Install pnpm dependencies + run: pnpm i + - name: Run ESLint + run: pnpm run eslint + + # Frontend tests run in parallel with backend + frontend-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 23 + cache: 'pnpm' + - name: Install pnpm dependencies + run: pnpm i + - name: Run frontend tests + run: pnpm run test:coverage + + # Backend tests with parallelization + backend-tests: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + ci_node_total: [16] + ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15] + services: postgres: - image: pgvector/pgvector:pg15 + image: pgvector/pgvector:pg16 env: POSTGRES_USER: postgres POSTGRES_PASSWORD: '' @@ -20,8 +69,6 @@ jobs: POSTGRES_HOST_AUTH_METHOD: trust ports: - 5432:5432 - # needed because the postgres container does not provide a healthcheck - # tmpfs makes DB faster by using RAM options: >- --mount type=tmpfs,destination=/var/lib/postgresql/data --health-cmd pg_isready @@ -29,7 +76,7 @@ jobs: --health-timeout 5s --health-retries 5 redis: - image: redis + image: redis:alpine ports: - 6379:6379 options: --entrypoint redis-server @@ -43,7 +90,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: - bundler-cache: true # runs 'bundle install' and caches installed gems automatically + bundler-cache: true - uses: actions/setup-node@v4 with: @@ -64,19 +111,36 @@ jobs: - name: Seed database run: bundle exec rake db:schema:load - - name: Run frontend tests - run: pnpm run test:coverage - - # Run rails tests - - name: Run backend tests + - name: Run backend tests (parallelized) run: | - bundle exec rspec --profile=10 --format documentation + # Get all spec files and split them using round-robin distribution + # This ensures slow tests are distributed evenly across all nodes + SPEC_FILES=($(find spec -name '*_spec.rb' | sort)) + TESTS="" + + for i in "${!SPEC_FILES[@]}"; do + # Assign spec to this node if: index % total == node_index + if [ $(( i % ${{ matrix.ci_node_total }} )) -eq ${{ matrix.ci_node_index }} ]; then + TESTS="$TESTS ${SPEC_FILES[$i]}" + fi + done + + if [ -n "$TESTS" ]; then + bundle exec rspec --profile=10 --format progress --format json --out tmp/rspec_results.json $TESTS + fi env: NODE_OPTIONS: --openssl-legacy-provider - - name: Upload rails log folder + - name: Upload test results uses: actions/upload-artifact@v4 if: always() with: - name: rails-log-folder + name: rspec-results-${{ matrix.ci_node_index }} + path: tmp/rspec_results.json + + - name: Upload rails log folder + uses: actions/upload-artifact@v4 + if: failure() + with: + name: rails-log-folder-${{ matrix.ci_node_index }} path: log diff --git a/.github/workflows/test_docker_build.yml b/.github/workflows/test_docker_build.yml index 460c4ba1f..b27d90408 100644 --- a/.github/workflows/test_docker_build.yml +++ b/.github/workflows/test_docker_build.yml @@ -36,5 +36,5 @@ jobs: platforms: ${{ matrix.platform }} push: false load: false - cache-from: type=gha - cache-to: type=gha,mode=max + cache-from: type=gha,scope=${{ matrix.platform }} + cache-to: type=gha,mode=max,scope=${{ matrix.platform }} diff --git a/.gitignore b/.gitignore index 4a03b35fa..892935c4b 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,5 @@ CLAUDE.local.md # Histoire deployment .netlify .histoire +.pnpm-store/* +local/ diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml index 57981a5f7..780b38374 100644 --- a/.qlty/qlty.toml +++ b/.qlty/qlty.toml @@ -39,7 +39,7 @@ exclude_patterns = [ "**/target/**", "**/templates/**", "**/testdata/**", - "**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js", + "**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js", ] test_patterns = [ diff --git a/.rubocop.yml b/.rubocop.yml index e30a71ee9..f5b8a2c1c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -7,6 +7,7 @@ plugins: require: - ./rubocop/use_from_email.rb - ./rubocop/custom_cop_location.rb + - ./rubocop/one_class_per_file.rb Layout/LineLength: Max: 150 @@ -23,7 +24,7 @@ Metrics/MethodLength: - 'enterprise/lib/captain/agent.rb' RSpec/ExampleLength: - Max: 25 + Max: 50 Style/Documentation: Enabled: false @@ -87,7 +88,7 @@ Metrics/ModuleLength: Rails/HelperInstanceVariable: Exclude: - enterprise/app/helpers/captain/chat_helper.rb - + - 'enterprise/app/helpers/captain/tool_execution_helper.rb' Rails/ApplicationController: Exclude: - 'app/controllers/api/v1/widget/messages_controller.rb' @@ -205,6 +206,9 @@ UseFromEmail: CustomCopLocation: Enabled: true +Style/OneClassPerFile: + Enabled: true + AllCops: NewCops: enable Exclude: @@ -336,4 +340,4 @@ FactoryBot/RedundantFactoryOption: Enabled: false FactoryBot/FactoryAssociationWithStrategy: - Enabled: false \ No newline at end of file + Enabled: false diff --git a/AGENTS.md b/AGENTS.md index e3b022a2e..ef1d3b26d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,9 @@ - **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb` - **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER` - **Run Project**: `overmind start -f Procfile.dev` +- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`) +- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used +- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.) ## Code Style @@ -37,6 +40,8 @@ - MVP focus: Least code change, happy-path only - No unnecessary defensive programming +- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate +- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness - Break down complex tasks into small, testable units - Iterate after confirmation - Avoid writing specs unless explicitly asked diff --git a/Gemfile b/Gemfile index 18442e3b0..d5e844382 100644 --- a/Gemfile +++ b/Gemfile @@ -21,6 +21,7 @@ gem 'telephone_number' gem 'time_diff' gem 'tzinfo-data' gem 'valid_email2' +gem 'email-provider-info' # compress javascript config.assets.js_compressor gem 'uglifier' ##-- used for single column multiple binary flags in notification settings/feature flagging --## @@ -54,6 +55,9 @@ gem 'azure-storage-blob', git: 'https://github.com/chatwoot/azure-storage-ruby', gem 'google-cloud-storage', '>= 1.48.0', require: false gem 'image_processing' +##-- for actionmailbox --## +gem 'aws-actionmailbox-ses', '~> 0' + ##-- gems for database --# gem 'groupdate' gem 'pg' @@ -192,6 +196,10 @@ gem 'ai-agents', '>= 0.4.3' # TODO: Move this gem as a dependency of ai-agents gem 'ruby_llm-schema' +# OpenTelemetry for LLM observability +gem 'opentelemetry-sdk' +gem 'opentelemetry-exporter-otlp' + gem 'shopify_api' ### Gems required only in specific deployment environments ### diff --git a/Gemfile.lock b/Gemfile.lock index 80a78c6b6..7ce74a223 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -136,9 +136,13 @@ GEM audited (5.4.1) activerecord (>= 5.0, < 7.7) activesupport (>= 5.0, < 7.7) + aws-actionmailbox-ses (0.1.0) + actionmailbox (>= 7.1.0) + aws-sdk-s3 (~> 1, >= 1.123.0) + aws-sdk-sns (~> 1, >= 1.61.0) aws-eventstream (1.2.0) aws-partitions (1.760.0) - aws-sdk-core (3.171.1) + aws-sdk-core (3.188.0) aws-eventstream (~> 1, >= 1.0.2) aws-partitions (~> 1, >= 1.651.0) aws-sigv4 (~> 1.5) @@ -146,10 +150,13 @@ GEM aws-sdk-kms (1.64.0) aws-sdk-core (~> 3, >= 3.165.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.122.0) - aws-sdk-core (~> 3, >= 3.165.0) + aws-sdk-s3 (1.126.0) + aws-sdk-core (~> 3, >= 3.174.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.4) + aws-sdk-sns (1.70.0) + aws-sdk-core (~> 3, >= 3.188.0) + aws-sigv4 (~> 1.1) aws-sigv4 (1.5.2) aws-eventstream (~> 1, >= 1.0.2) barnes (0.0.9) @@ -270,6 +277,7 @@ GEM concurrent-ruby (~> 1.0) http (>= 3.0) ruby2_keywords + email-provider-info (0.0.1) email_reply_trimmer (0.1.13) erubi (1.13.0) et-orbi (1.2.11) @@ -594,7 +602,7 @@ GEM oj (3.16.10) bigdecimal (>= 3.0) ostruct (>= 0.2) - omniauth (2.1.3) + omniauth (2.1.4) hashie (>= 3.4.6) logger rack (>= 2.2.3) @@ -617,6 +625,25 @@ GEM faraday (>= 1.0, < 3) multi_json (>= 1.0) openssl (3.2.0) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp (0.31.1) + google-protobuf (>= 3.18) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-sdk (~> 1.10) + opentelemetry-semantic_conventions + opentelemetry-registry (0.4.0) + opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.10.0) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.36.0) + opentelemetry-api (~> 1.0) orm_adapter (0.5.0) os (1.1.4) ostruct (0.6.1) @@ -653,7 +680,7 @@ GEM rack (>= 2.0.0) rack-mini-profiler (3.2.0) rack (>= 1.2.0) - rack-protection (4.1.1) + rack-protection (4.2.1) base64 (>= 0.1.0) logger (>= 1.6.0) rack (>= 3.0.0, < 4) @@ -994,6 +1021,7 @@ DEPENDENCIES annotate attr_extras audited (~> 5.4, >= 5.4.1) + aws-actionmailbox-ses (~> 0) aws-sdk-s3 azure-storage-blob! barnes @@ -1016,6 +1044,7 @@ DEPENDENCIES dotenv-rails (>= 3.0.0) down elastic-apm + email-provider-info email_reply_trimmer facebook-messenger factory_bot_rails (>= 6.4.3) @@ -1063,6 +1092,8 @@ DEPENDENCIES omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2) omniauth-saml opensearch-ruby + opentelemetry-exporter-otlp + opentelemetry-sdk pg pg_search pgvector diff --git a/VERSION_CW b/VERSION_CW index fdc669880..88f181192 100644 --- a/VERSION_CW +++ b/VERSION_CW @@ -1 +1 @@ -4.4.0 +4.8.0 diff --git a/app.json b/app.json index 08e725c8e..91fb0fbd5 100644 --- a/app.json +++ b/app.json @@ -36,6 +36,10 @@ "REDIS_OPENSSL_VERIFY_MODE":{ "description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues", "value": "none" + }, + "NODE_OPTIONS": { + "description": "Increase V8 heap for Vite build to avoid OOM", + "value": "--max-old-space-size=4096" } }, "formation": { diff --git a/app/builders/contact_inbox_builder.rb b/app/builders/contact_inbox_builder.rb index 788ae39d1..40e571f43 100644 --- a/app/builders/contact_inbox_builder.rb +++ b/app/builders/contact_inbox_builder.rb @@ -103,3 +103,5 @@ class ContactInboxBuilder @inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp? end end + +ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder') diff --git a/app/builders/messages/message_builder.rb b/app/builders/messages/message_builder.rb index 12a74ed9c..7df72e14a 100644 --- a/app/builders/messages/message_builder.rb +++ b/app/builders/messages/message_builder.rb @@ -1,5 +1,8 @@ class Messages::MessageBuilder include ::FileTypeHelper + include ::EmailHelper + include ::DataHelper + attr_reader :message def initialize(user, conversation, params) @@ -23,7 +26,7 @@ class Messages::MessageBuilder process_emails # When the message has no quoted content, it will just be rendered as a regular message # The frontend is equipped to handle this case - process_email_content if @account.feature_enabled?(:quoted_email_reply) + process_email_content @message.save! @message end @@ -38,30 +41,12 @@ class Messages::MessageBuilder params = convert_to_hash(@params) content_attributes = params.fetch(:content_attributes, {}) - return parse_json(content_attributes) if content_attributes.is_a?(String) + return safe_parse_json(content_attributes) if content_attributes.is_a?(String) return content_attributes if content_attributes.is_a?(Hash) {} end - # Converts the given object to a hash. - # If it's an instance of ActionController::Parameters, converts it to an unsafe hash. - # Otherwise, returns the object as-is. - def convert_to_hash(obj) - return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters) - - obj - end - - # Attempts to parse a string as JSON. - # If successful, returns the parsed hash with symbolized names. - # If unsuccessful, returns nil. - def parse_json(content) - JSON.parse(content, symbolize_names: true) - rescue JSON::ParserError - {} - end - def process_attachments return if @attachments.blank? @@ -110,12 +95,6 @@ class Messages::MessageBuilder email_string.gsub(/\s+/, '').split(',') end - def validate_email_addresses(all_emails) - all_emails&.each do |email| - raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP) - end - end - def message_type if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming' raise StandardError, 'Incoming messages are only allowed in Api inboxes' @@ -159,6 +138,7 @@ class Messages::MessageBuilder private: @private, sender: sender, content_type: @params[:content_type], + content_attributes: content_attributes.presence, items: @items, in_reply_to: @in_reply_to, echo_id: @params[:echo_id], @@ -178,14 +158,17 @@ class Messages::MessageBuilder email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {}) normalized_content = normalize_email_body(@message.content) + # Process liquid templates in normalized content with code block protection + processed_content = process_liquid_in_email_body(normalized_content) + # Use custom HTML content if provided, otherwise generate from message content email_attributes[:html_content] = if custom_email_content_provided? build_custom_html_content else - build_html_content(normalized_content) + build_html_content(processed_content) end - email_attributes[:text_content] = build_text_content(normalized_content) + email_attributes[:text_content] = build_text_content(processed_content) email_attributes end @@ -204,22 +187,6 @@ class Messages::MessageBuilder text_content end - def ensure_indifferent_access(hash) - return {} if hash.blank? - - hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash - end - - def normalize_email_body(content) - content.to_s.gsub("\r\n", "\n") - end - - def render_email_html(content) - return '' if content.blank? - - ChatwootMarkdownRenderer.new(content).render_message.to_s - end - def custom_email_content_provided? @params[:email_html_content].present? end @@ -232,4 +199,29 @@ class Messages::MessageBuilder html_content end + + # Liquid processing methods for email content + def process_liquid_in_email_body(content) + return content if content.blank? + return content unless should_process_liquid? + + # Protect code blocks from liquid processing + modified_content = modified_liquid_content(content) + template = Liquid::Template.parse(modified_content) + template.render(drops_with_sender) + rescue Liquid::Error + content + end + + def should_process_liquid? + @message_type == 'outgoing' || @message_type == 'template' + end + + def drops_with_sender + message_drops(@conversation).merge({ + 'agent' => UserDrop.new(sender) + }) + end end + +Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder') diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index 64c35d33d..c2f919659 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -4,7 +4,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController before_action :agent_bot, except: [:index, :create] def index - @agent_bots = AgentBot.where(account_id: [nil, Current.account.id]) + @agent_bots = AgentBot.accessible_to(Current.account) end def show; end @@ -37,7 +37,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController private def agent_bot - @agent_bot = AgentBot.where(account_id: [nil, Current.account.id]).find(params[:id]) if params[:action] == 'show' + @agent_bot = AgentBot.accessible_to(Current.account).find(params[:id]) if params[:action] == 'show' @agent_bot ||= Current.account.agent_bots.find(params[:id]) end diff --git a/app/controllers/api/v1/accounts/articles_controller.rb b/app/controllers/api/v1/accounts/articles_controller.rb index da2be2312..8a6fd61f8 100644 --- a/app/controllers/api/v1/accounts/articles_controller.rb +++ b/app/controllers/api/v1/accounts/articles_controller.rb @@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController def edit; end def create - @article = @portal.articles.create!(article_params) + params_with_defaults = article_params + params_with_defaults[:status] ||= :draft + @article = @portal.articles.create!(params_with_defaults) @article.associate_root_article(article_params[:associated_article_id]) - @article.draft! render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid? end diff --git a/app/controllers/api/v1/accounts/bulk_actions_controller.rb b/app/controllers/api/v1/accounts/bulk_actions_controller.rb index 34db47861..222c66714 100644 --- a/app/controllers/api/v1/accounts/bulk_actions_controller.rb +++ b/app/controllers/api/v1/accounts/bulk_actions_controller.rb @@ -1,13 +1,12 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseController - before_action :type_matches? - def create - if type_matches? - ::BulkActionsJob.perform_later( - account: @current_account, - user: current_user, - params: permitted_params - ) + case normalized_type + when 'Conversation' + enqueue_conversation_job + head :ok + when 'Contact' + check_authorization_for_contact_action + enqueue_contact_job head :ok else render json: { success: false }, status: :unprocessable_entity @@ -16,11 +15,54 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll private - def type_matches? - ['Conversation'].include?(params[:type]) + def normalized_type + params[:type].to_s.camelize end - def permitted_params - params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []]) + def enqueue_conversation_job + ::BulkActionsJob.perform_later( + account: @current_account, + user: current_user, + params: conversation_params + ) + end + + def enqueue_contact_job + Contacts::BulkActionJob.perform_later( + @current_account.id, + current_user.id, + contact_params + ) + end + + def delete_contact_action? + params[:action_name] == 'delete' + end + + def check_authorization_for_contact_action + authorize(Contact, :destroy?) if delete_contact_action? + end + + def conversation_params + # TODO: Align conversation payloads with the `{ action_name, action_attributes }` + # and then remove this method in favor of a common params method. + base = params.permit( + :snoozed_until, + fields: [:status, :assignee_id, :team_id] + ) + append_common_bulk_attributes(base) + end + + def contact_params + # TODO: remove this method in favor of a common params method. + # once legacy conversation payloads are migrated. + append_common_bulk_attributes({}) + end + + def append_common_bulk_attributes(base_params) + # NOTE: Conversation payloads historically diverged per action. Going forward we + # want all objects to share a common contract: `{ action_name, action_attributes }` + common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []]) + base_params.merge(common) end end diff --git a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb index 1fb2095e3..49806e97c 100644 --- a/app/controllers/api/v1/accounts/conversations/assignments_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/assignments_controller.rb @@ -1,7 +1,7 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Accounts::Conversations::BaseController # assigns agent/team to a conversation def create - if params.key?(:assignee_id) + if params.key?(:assignee_id) || agent_bot_assignment? set_agent elsif params.key?(:team_id) set_team @@ -13,17 +13,23 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account private def set_agent - @agent = Current.account.users.find_by(id: params[:assignee_id]) - @conversation.assignee = @agent - @conversation.save! - render_agent + resource = Conversations::AssignmentService.new( + conversation: @conversation, + assignee_id: params[:assignee_id], + assignee_type: params[:assignee_type] + ).perform + + render_agent(resource) end - def render_agent - if @agent.nil? - render json: nil + def render_agent(resource) + case resource + when User + render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: resource } + when AgentBot + render partial: 'api/v1/models/agent_bot_slim', formats: [:json], locals: { resource: resource } else - render partial: 'api/v1/models/agent', formats: [:json], locals: { resource: @agent } + render json: nil end end @@ -32,4 +38,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account @conversation.update!(team: @team) render json: @team end + + def agent_bot_assignment? + params[:assignee_type].to_s == 'AgentBot' + end end diff --git a/app/controllers/api/v1/accounts/conversations/base_controller.rb b/app/controllers/api/v1/accounts/conversations/base_controller.rb index 500c7772f..223530e27 100644 --- a/app/controllers/api/v1/accounts/conversations/base_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/base_controller.rb @@ -5,6 +5,6 @@ class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::Base def conversation @conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id]) - authorize @conversation.inbox, :show? + authorize @conversation, :show? end end diff --git a/app/controllers/api/v1/accounts/conversations_controller.rb b/app/controllers/api/v1/accounts/conversations_controller.rb index e27869d82..4301eaa4a 100644 --- a/app/controllers/api/v1/accounts/conversations_controller.rb +++ b/app/controllers/api/v1/accounts/conversations_controller.rb @@ -160,7 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro def conversation @conversation ||= Current.account.conversations.find_by!(display_id: params[:id]) - authorize @conversation.inbox, :show? + authorize @conversation, :show? end def inbox diff --git a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb index c5f795d34..845caab5e 100644 --- a/app/controllers/api/v1/accounts/integrations/dyte_controller.rb +++ b/app/controllers/api/v1/accounts/integrations/dyte_controller.rb @@ -22,7 +22,7 @@ class Api::V1::Accounts::Integrations::DyteController < Api::V1::Accounts::BaseC private def authorize_request - authorize @conversation.inbox, :show? + authorize @conversation, :show? end def render_response(response) diff --git a/app/controllers/api/v1/accounts/webhooks_controller.rb b/app/controllers/api/v1/accounts/webhooks_controller.rb index 7ea257ed2..9f8e94821 100644 --- a/app/controllers/api/v1/accounts/webhooks_controller.rb +++ b/app/controllers/api/v1/accounts/webhooks_controller.rb @@ -23,7 +23,7 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController private def webhook_params - params.require(:webhook).permit(:inbox_id, :url, subscriptions: []) + params.require(:webhook).permit(:inbox_id, :name, :url, subscriptions: []) end def fetch_webhook diff --git a/app/controllers/api/v1/widget/configs_controller.rb b/app/controllers/api/v1/widget/configs_controller.rb index ecbddd905..458d0486c 100644 --- a/app/controllers/api/v1/widget/configs_controller.rb +++ b/app/controllers/api/v1/widget/configs_controller.rb @@ -9,7 +9,13 @@ class Api::V1::Widget::ConfigsController < Api::V1::Widget::BaseController private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'INSTALLATION_NAME') + @global_config = GlobalConfig.get( + 'LOGO_THUMBNAIL', + 'BRAND_NAME', + 'WIDGET_BRAND_URL', + 'MAXIMUM_FILE_UPLOAD_SIZE', + 'INSTALLATION_NAME' + ) end def set_contact diff --git a/app/controllers/concerns/access_token_auth_helper.rb b/app/controllers/concerns/access_token_auth_helper.rb index 9b0f9021f..338b290da 100644 --- a/app/controllers/concerns/access_token_auth_helper.rb +++ b/app/controllers/concerns/access_token_auth_helper.rb @@ -14,6 +14,7 @@ module AccessTokenAuthHelper ensure_access_token render_unauthorized('Invalid Access Token') && return if @access_token.blank? + # NOTE: This ensures that current_user is set and available for the rest of the controller actions @resource = @access_token.owner Current.user = @resource if allowed_current_user_type?(@resource) end diff --git a/app/controllers/concerns/ensure_current_account_helper.rb b/app/controllers/concerns/ensure_current_account_helper.rb index 3baf9ee1e..ea36a48f2 100644 --- a/app/controllers/concerns/ensure_current_account_helper.rb +++ b/app/controllers/concerns/ensure_current_account_helper.rb @@ -25,6 +25,9 @@ module EnsureCurrentAccountHelper end def account_accessible_for_bot?(account) - render_unauthorized('Bot is not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id) + return if @resource.account_id == account.id + return if @resource.agent_bot_inboxes.find_by(account_id: account.id) + + render_unauthorized('Bot is not authorized to access this account') end end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index d81b4c9da..5003c4c70 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -1,6 +1,31 @@ class DashboardController < ActionController::Base include SwitchLocale + GLOBAL_CONFIG_KEYS = %w[ + LOGO + LOGO_DARK + LOGO_THUMBNAIL + INSTALLATION_NAME + WIDGET_BRAND_URL + TERMS_URL + BRAND_URL + BRAND_NAME + PRIVACY_URL + DISPLAY_MANIFEST + CREATE_NEW_ACCOUNT_FROM_DASHBOARD + CHATWOOT_INBOX_TOKEN + API_CHANNEL_NAME + API_CHANNEL_THUMBNAIL + ANALYTICS_TOKEN + DIRECT_UPLOADS_ENABLED + MAXIMUM_FILE_UPLOAD_SIZE + HCAPTCHA_SITE_KEY + LOGOUT_REDIRECT_LINK + DISABLE_USER_PROFILE_UPDATE + DEPLOYMENT_ENV + INSTALLATION_PRICING_PLAN + ].freeze + before_action :set_application_pack before_action :set_global_config before_action :set_dashboard_scripts @@ -19,25 +44,7 @@ class DashboardController < ActionController::Base end def set_global_config - @global_config = GlobalConfig.get( - 'LOGO', 'LOGO_DARK', 'LOGO_THUMBNAIL', - 'INSTALLATION_NAME', - 'WIDGET_BRAND_URL', 'TERMS_URL', - 'BRAND_URL', 'BRAND_NAME', - 'PRIVACY_URL', - 'DISPLAY_MANIFEST', - 'CREATE_NEW_ACCOUNT_FROM_DASHBOARD', - 'CHATWOOT_INBOX_TOKEN', - 'API_CHANNEL_NAME', - 'API_CHANNEL_THUMBNAIL', - 'ANALYTICS_TOKEN', - 'DIRECT_UPLOADS_ENABLED', - 'HCAPTCHA_SITE_KEY', - 'LOGOUT_REDIRECT_LINK', - 'DISABLE_USER_PROFILE_UPDATE', - 'DEPLOYMENT_ENV', - 'INSTALLATION_PRICING_PLAN' - ).merge(app_config) + @global_config = GlobalConfig.get(*GLOBAL_CONFIG_KEYS).merge(app_config) end def set_dashboard_scripts @@ -71,10 +78,18 @@ class DashboardController < ActionController::Base WHATSAPP_CONFIGURATION_ID: GlobalConfigService.load('WHATSAPP_CONFIGURATION_ID', ''), IS_ENTERPRISE: ChatwootApp.enterprise?, AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''), - GIT_SHA: GIT_HASH + GIT_SHA: GIT_HASH, + ALLOWED_LOGIN_METHODS: allowed_login_methods } end + def allowed_login_methods + methods = ['email'] + methods << 'google_oauth' if GlobalConfigService.load('ENABLE_GOOGLE_OAUTH_LOGIN', 'true').to_s != 'false' + methods << 'saml' if ChatwootHub.pricing_plan != 'community' && GlobalConfigService.load('ENABLE_SAML_SSO_LOGIN', 'true').to_s != 'false' + methods + end + def set_application_pack @application_pack = if request.path.include?('/auth') || request.path.include?('/login') 'v3app' diff --git a/app/controllers/super_admin/app_configs_controller.rb b/app/controllers/super_admin/app_configs_controller.rb index 5cf158b98..1a9539bb6 100644 --- a/app/controllers/super_admin/app_configs_controller.rb +++ b/app/controllers/super_admin/app_configs_controller.rb @@ -15,14 +15,20 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController end def create + errors = [] params['app_config'].each do |key, value| next unless @allowed_configs.include?(key) i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false) i.value = value - i.save! + errors.concat(i.errors.full_messages) unless i.save + end + + if errors.any? + redirect_to super_admin_app_config_path(config: @config), alert: errors.join(', ') + else + redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" end - redirect_to super_admin_settings_path, notice: "App Configs - #{@config.titleize} updated successfully" end private @@ -42,10 +48,13 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController 'instagram' => %w[INSTAGRAM_APP_ID INSTAGRAM_APP_SECRET INSTAGRAM_VERIFY_TOKEN INSTAGRAM_API_VERSION ENABLE_INSTAGRAM_CHANNEL_HUMAN_AGENT], 'whatsapp_embedded' => %w[WHATSAPP_APP_ID WHATSAPP_APP_SECRET WHATSAPP_CONFIGURATION_ID WHATSAPP_API_VERSION], 'notion' => %w[NOTION_CLIENT_ID NOTION_CLIENT_SECRET], - 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI] + 'google' => %w[GOOGLE_OAUTH_CLIENT_ID GOOGLE_OAUTH_CLIENT_SECRET GOOGLE_OAUTH_REDIRECT_URI ENABLE_GOOGLE_OAUTH_LOGIN] } - @allowed_configs = mapping.fetch(@config, %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]) + @allowed_configs = mapping.fetch( + @config, + %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS WEBHOOK_TIMEOUT MAXIMUM_FILE_UPLOAD_SIZE] + ) end end diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 9a6a376f7..7f45ce636 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -14,7 +14,14 @@ class WidgetsController < ActionController::Base private def set_global_config - @global_config = GlobalConfig.get('LOGO_THUMBNAIL', 'BRAND_NAME', 'WIDGET_BRAND_URL', 'DIRECT_UPLOADS_ENABLED', 'INSTALLATION_NAME') + @global_config = GlobalConfig.get( + 'LOGO_THUMBNAIL', + 'BRAND_NAME', + 'WIDGET_BRAND_URL', + 'DIRECT_UPLOADS_ENABLED', + 'MAXIMUM_FILE_UPLOAD_SIZE', + 'INSTALLATION_NAME' + ) end def set_web_widget diff --git a/app/helpers/data_helper.rb b/app/helpers/data_helper.rb new file mode 100644 index 000000000..66f5a9508 --- /dev/null +++ b/app/helpers/data_helper.rb @@ -0,0 +1,24 @@ +# Provides utility methods for data transformation, hash manipulation, and JSON parsing. +# This module contains helper methods for converting between different data types, +# normalizing hashes, and safely handling JSON operations. +module DataHelper + # Ensures a hash supports indifferent access (string or symbol keys). + # Returns an empty hash if the input is blank. + def ensure_indifferent_access(hash) + return {} if hash.blank? + + hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash + end + + def convert_to_hash(obj) + return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters) + + obj + end + + def safe_parse_json(content) + JSON.parse(content, symbolize_names: true) + rescue JSON::ParserError + {} + end +end diff --git a/app/helpers/email_helper.rb b/app/helpers/email_helper.rb index 05b6a53e3..fcc8b463d 100644 --- a/app/helpers/email_helper.rb +++ b/app/helpers/email_helper.rb @@ -4,6 +4,19 @@ module EmailHelper domain.split('.').first end + def render_email_html(content) + return '' if content.blank? + + ChatwootMarkdownRenderer.new(content).render_message.to_s + end + + # Raise a standard error if any email address is invalid + def validate_email_addresses(emails_to_test) + emails_to_test&.each do |email| + raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP) + end + end + # ref: https://www.rfc-editor.org/rfc/rfc5233.html # This is not a mandatory requirement for email addresses, but it is a common practice. # john+test@xyc.com is the same as john@xyc.com @@ -21,6 +34,10 @@ module EmailHelper end end + def normalize_email_body(content) + content.to_s.gsub("\r\n", "\n") + end + def modified_liquid_content(email) # This regex is used to match the code blocks in the content # We don't want to process liquid in code blocks @@ -29,7 +46,10 @@ module EmailHelper def message_drops(conversation) { - 'contact' => ContactDrop.new(conversation.contact) + 'contact' => ContactDrop.new(conversation.contact), + 'conversation' => ConversationDrop.new(conversation), + 'inbox' => InboxDrop.new(conversation.inbox), + 'account' => AccountDrop.new(conversation.account) } end end diff --git a/app/helpers/super_admin/features.yml b/app/helpers/super_admin/features.yml index f49004e79..b05c603cd 100644 --- a/app/helpers/super_admin/features.yml +++ b/app/helpers/super_admin/features.yml @@ -9,6 +9,13 @@ captain: icon: 'icon-captain' config_key: 'captain' enterprise: true +saml: + name: 'SAML SSO' + description: 'Configuration for controlling SAML Single Sign-On availability' + enabled: <%= ChatwootApp.enterprise? %> + icon: 'icon-lock-line' + config_key: 'saml' + enterprise: true custom_branding: name: 'Custom Branding' description: 'Apply your own branding to this installation.' diff --git a/app/javascript/dashboard/App.vue b/app/javascript/dashboard/App.vue index 0fcb8c9fe..a63cb1a90 100644 --- a/app/javascript/dashboard/App.vue +++ b/app/javascript/dashboard/App.vue @@ -1,6 +1,5 @@ + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue new file mode 100644 index 000000000..f0dc4255f --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/CompanyHeader.vue @@ -0,0 +1,55 @@ + + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue new file mode 100644 index 000000000..768123b9f --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesHeader/components/CompanySortMenu.vue @@ -0,0 +1,116 @@ + + + diff --git a/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue b/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue new file mode 100644 index 000000000..b69de343e --- /dev/null +++ b/app/javascript/dashboard/components-next/Companies/CompaniesListLayout.vue @@ -0,0 +1,51 @@ + + + diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue index 47b779b61..041f06410 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue @@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core'; import Button from 'dashboard/components-next/button/Button.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; defineProps({ selectedContact: { @@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => { diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue index 0e893b767..12bed151d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue @@ -8,6 +8,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Flag from 'dashboard/components-next/flag/Flag.vue'; import ContactDeleteSection from 'dashboard/components-next/Contacts/ContactsCard/ContactDeleteSection.vue'; +import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; import countries from 'shared/constants/countries'; const props = defineProps({ @@ -20,9 +21,17 @@ const props = defineProps({ availabilityStatus: { type: String, default: null }, isExpanded: { type: Boolean, default: false }, isUpdating: { type: Boolean, default: false }, + selectable: { type: Boolean, default: false }, + isSelected: { type: Boolean, default: false }, }); -const emit = defineEmits(['toggle', 'updateContact', 'showContact']); +const emit = defineEmits([ + 'toggle', + 'updateContact', + 'showContact', + 'select', + 'avatarHover', +]); const { t } = useI18n(); @@ -88,111 +97,148 @@ const onClickExpand = () => { }; const onClickViewDetails = () => emit('showContact', props.id); + +const toggleSelect = checked => { + emit('select', checked); +}; + +const handleAvatarHover = isHovered => { + emit('avatarHover', isHovered); +}; diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue index 4c7b9249e..fd755022d 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsDetailsLayout.vue @@ -102,6 +102,7 @@ const closeMobileSidebar = () => { /> diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue index aec21a976..b7014c42f 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactDetails.vue @@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue'; import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue'; import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue'; import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue'; +import Policy from 'dashboard/components/policy.vue'; const props = defineProps({ selectedContact: { @@ -174,27 +175,29 @@ const handleAvatarDelete = async () => { @click="updateContact" /> -
-
-
- {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} -
- - {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} - + +
+
+
+ {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }} +
+ + {{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }} + +
+
-
- +
diff --git a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue index 7acc2ff55..72bc2c7dd 100644 --- a/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue +++ b/app/javascript/dashboard/components-next/Contacts/Pages/ContactsList.vue @@ -10,7 +10,15 @@ import { } from 'shared/helpers/CustomErrors'; import ContactsCard from 'dashboard/components-next/Contacts/ContactsCard/ContactsCard.vue'; -defineProps({ contacts: { type: Array, required: true } }); +const props = defineProps({ + contacts: { type: Array, required: true }, + selectedContactIds: { + type: Array, + default: () => [], + }, +}); + +const emit = defineEmits(['toggleContact']); const { t } = useI18n(); const store = useStore(); @@ -20,6 +28,9 @@ const route = useRoute(); const uiFlags = useMapGetter('contacts/getUIFlags'); const isUpdating = computed(() => uiFlags.value.isUpdating); const expandedCardId = ref(null); +const hoveredAvatarId = ref(null); + +const selectedIdsSet = computed(() => new Set(props.selectedContactIds || [])); const updateContact = async updatedData => { try { @@ -58,25 +69,43 @@ const onClickViewDetails = async id => { const toggleExpanded = id => { expandedCardId.value = expandedCardId.value === id ? null : id; }; + +const isSelected = id => selectedIdsSet.value.has(id); + +const shouldShowSelection = id => { + return hoveredAvatarId.value === id || isSelected(id); +}; + +const handleSelect = (id, value) => { + emit('toggleContact', { id, value }); +}; + +const handleAvatarHover = (id, isHovered) => { + hoveredAvatarId.value = isHovered ? id : null; +}; diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 98e56ff8f..85738d9de 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -1,15 +1,18 @@ @@ -55,6 +96,8 @@ const onPickInbox = () => { v-if="shouldRender" v-tooltip.top-end="tooltipLabel || null" v-bind="attrs" + :disabled="isInitiatingCall" + :is-loading="isInitiatingCall" :label="label" :icon="icon" :size="size" diff --git a/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue b/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue index 1ee969275..5e96df3c6 100644 --- a/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue +++ b/app/javascript/dashboard/components-next/Conversation/SidepanelSwitch.vue @@ -1,5 +1,6 @@ diff --git a/app/javascript/dashboard/components-next/Editor/Editor.vue b/app/javascript/dashboard/components-next/Editor/Editor.vue index a2f139bdc..67936fa59 100644 --- a/app/javascript/dashboard/components-next/Editor/Editor.vue +++ b/app/javascript/dashboard/components-next/Editor/Editor.vue @@ -21,6 +21,10 @@ const props = defineProps({ enableCannedResponses: { type: Boolean, default: true }, enabledMenuOptions: { type: Array, default: () => [] }, enableCaptainTools: { type: Boolean, default: false }, + signature: { type: String, default: '' }, + allowSignature: { type: Boolean, default: false }, + sendWithSignature: { type: Boolean, default: false }, + channelType: { type: String, default: '' }, }); const emit = defineEmits(['update:modelValue']); @@ -100,6 +104,10 @@ watch( :enable-canned-responses="enableCannedResponses" :enabled-menu-options="enabledMenuOptions" :enable-captain-tools="enableCaptainTools" + :signature="signature" + :allow-signature="allowSignature" + :send-with-signature="sendWithSignature" + :channel-type="channelType" @input="handleInput" @focus="handleFocus" @blur="handleBlur" diff --git a/app/javascript/dashboard/components-next/EmptyStateLayout.vue b/app/javascript/dashboard/components-next/EmptyStateLayout.vue index f2744dde5..39eda2d85 100644 --- a/app/javascript/dashboard/components-next/EmptyStateLayout.vue +++ b/app/javascript/dashboard/components-next/EmptyStateLayout.vue @@ -14,6 +14,10 @@ defineProps({ type: Array, default: () => [], }, + showBackdrop: { + type: Boolean, + default: true, + }, }); @@ -25,14 +29,24 @@ defineProps({ class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[28rem]" >
-
+

{{ subtitle }} diff --git a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue index 2d8b219f7..bec54593f 100644 --- a/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue +++ b/app/javascript/dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorHeader.vue @@ -14,6 +14,7 @@ import { } from 'dashboard/helper/portalHelper'; import wootConstants from 'dashboard/constants/globals'; +import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; @@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => { :disabled="!articleId" @click="previewArticle" /> -

+
-

+
diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue index 773ebe315..92c5850de 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ActionButtons.vue @@ -92,7 +92,6 @@ const setSignature = () => { const toggleMessageSignature = () => { setSignatureFlagForInbox(props.channelType, !sendWithSignature.value); - setSignature(); }; // Added this watch to dynamically set signature on target inbox change. diff --git a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue index 4d6d41dac..a02d6d495 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/ComposeNewConversationForm.vue @@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => { state.attachedFiles = []; }; -const removeTargetInbox = value => { - v$.value.$reset(); - // Remove the signature from message content - // Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp) - if (props.sendWithSignature) { - const signatureToRemove = inboxTypes.value.isEmailOrWebWidget - ? props.messageSignature - : extractTextFromMarkdown(props.messageSignature); +const removeSignatureFromMessage = () => { + // Always remove the signature from message content when inbox/contact is removed + // to ensure no leftover signature content remains + const signatureToRemove = inboxTypes.value.isEmailOrWebWidget + ? props.messageSignature + : extractTextFromMarkdown(props.messageSignature); + if (signatureToRemove) { state.message = removeSignature(state.message, signatureToRemove); } +}; + +const removeTargetInbox = value => { + v$.value.$reset(); + removeSignatureFromMessage(); emit('updateTargetInbox', value); state.attachedFiles = []; }; @@ -216,6 +220,7 @@ const removeTargetInbox = value => { const clearSelectedContact = () => { emit('clearSelectedContact'); state.attachedFiles = []; + removeSignatureFromMessage(); }; const onClickInsertEmoji = emoji => { @@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => { :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :has-errors="validationStates.isMessageInvalid" :has-attachments="state.attachedFiles.length > 0" + :channel-type="inboxChannelType" /> { " enable-variables :show-character-count="false" + :signature="messageSignature" + allow-signature + :send-with-signature="sendWithSignature" + :channel-type="channelType" />