Compare commits

...
Author SHA1 Message Date
Sojan Jose 899fce1c92 Merge branch 'release/4.12.1'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-03-25 16:21:28 -07:00
Sojan Jose 7144d55334 Bump version to 4.12.1 2026-03-25 16:20:58 -07:00
250650dd7a feat(platform): Add email channel migration endpoint for bulk OAuth channel creation (#13902)
Adds a Platform API endpoint that allows migrating existing Google and
Microsoft email channels (with OAuth credentials) into Chatwoot without
requiring end-users to re-authenticate. This enables customers who lack
Rails console access to programmatically migrate email channels from
legacy systems.
    
 ### How to test

1. Create a Platform App and grant it permissible access to a target
account
2. `POST /platform/api/v1/accounts/:account_id/email_channel_migrations`
with a payload like:
  ```json
  {
    "migrations": [
      {
        "email": "support@example.com",
        "provider": "google",
        "provider_config": {
          "access_token": "...",
          "refresh_token": "...",
          "expires_on": "..."
        },
        "inbox_name": "Migrated Support"
      }
    ]
  }
```
  3. Verify channels are created with correct provider, provider_config, and IMAP defaults
  4. Verify partial failures (e.g. duplicate email) don't roll back other migrations in the batch
  5. Verify unauthenticated and non-permissible requests return 401

---------

Co-authored-by: Sojan Jose <sojan@pepalo.com>
2026-03-25 15:58:08 -07:00
608be1036b fix: Send raw content in webhook payloads instead of channel-rendered markdown (#13896)
Webhook payloads (`message_created`, `message_updated`) started sending
channel-rendered HTML in the `content` field instead of the original raw
message content after PR #12878. This broke downstream agent bots and
integrations that expected plain text or markdown. 
Closes https://linear.app/chatwoot/issue/PLA-109/webhook-payloads-send-channel-rendered-html-instead-of-raw-content

## How to reproduce

1. Connect an agent bot to a WebWidget inbox
2. Send a message with markdown formatting (e.g. `**bold**`) from the
widget
3. Observe the agent bot webhook payload — `content` field contains
`<p><strong>bold</strong></p>` instead of `**bold**`

## What changed

Split `MessageContentPresenter` into two public methods:
- `outgoing_content` — renders markdown for the target channel (used by
channel delivery services)
- `webhook_content` — returns raw content with CSAT survey URL when
applicable, no markdown rendering (used by `webhook_data`)

Updated `Message#webhook_data` to use `webhook_content` instead of
`outgoing_content`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2026-03-25 16:56:22 +04:00
salmonumbrellaandGitHub 6ff643b045 fix(i18n): add zh_TW snooze parser locale (#13822) 2026-03-25 16:54:18 +05:30
Vishnu NarayananandGitHub 775b73d1f9 fix: raise open file descriptor limit to prevent EMFILE errors (#13895)
## Summary
- Adds `LimitNOFILE=65536` to both web and worker systemd service units
- Fixes recurring `Errno::EMFILE` (Too many open files) errors during
peak traffic after deploys

## Context
Puma workers idle at 720-770 open FDs against the default soft limit of
1024, leaving ~250 FDs of headroom. During deploy-triggered instance
refreshes at peak traffic, concurrent requests exhaust the remaining
FDs, causing EMFILE across all web instances.

3 incidents in March 2026 with escalating event counts. The hard limit
is already 524288, so this just raises the soft limit to a standard
production value.

Self-hosted instances pick this up automatically via `cwctl --upgrade`.

Fixes
https://linear.app/chatwoot/issue/CW-6685/errnoemfile-too-many-open-files-rb-sysopen
2026-03-24 17:37:07 -07:00
14df7b3bc1 fix: ai-assist 404 on CE (#13891)
# Pull Request Template

## Description

Relocate controller from enterprise/ to app/ and add
Api::V1::Accounts::Captain::TasksController.prepend_mod_with for EE
overrides.

Fixes: Ai assist giving 404 on CE

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Please describe the tests that you ran to verify your changes. Provide
instructions so we can reproduce. Please also list any relevant details
for your test configuration.

before:
<img width="482" height="130" alt="image"
src="https://github.com/user-attachments/assets/f51dc28a-ac54-45c4-9015-6f956fdf5057"
/>

after:
<img width="458" height="182" alt="image"
src="https://github.com/user-attachments/assets/eb86a679-5482-4157-9f4e-f3e9953d8649"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2026-03-24 16:58:11 +05:30
Sivin VargheseandGitHub 6946859ba4 fix: normalize "in less than a minute" to "now" in chat list timestamp (#13874)
# Pull Request Template

## Description

This PR fixes the conversation list showing raw "**in less than a
minute**" text instead of "**now**" for very recent conversations.

Fixes https://linear.app/chatwoot/issue/CW-6666/issue-with-timestamps

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [ ] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
2026-03-24 16:16:35 +05:30
Sivin VargheseandGitHub c129ab00ba fix: normalize "in less than a minute" to "now" in chat list timestamp (#13874) 2026-03-24 15:40:31 +05:30
7edae93ee8 fix(agent-bot): Include payload in webhook retry failure logs (#13879)
Webhook retry failure logs for agent-bot now include the event payload,
making it easier to identify which event failed when debugging transient
upstream errors (429/500).

Previously the log only showed:
`[AgentBots::WebhookJob] attempt 1 failed
RestClient::InternalServerError`

Now it includes the payload:
`[AgentBots::WebhookJob] attempt 1 failed
RestClient::InternalServerError payload={"event":"message_created",...}`

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 10:52:37 +04:00
Sojan Jose b6b856260f Merge branch 'release/4.12.0'
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot EE docker images / merge (push) Blocked by required conditions
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Waiting to run
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Waiting to run
Publish Chatwoot CE docker images / merge (push) Blocked by required conditions
2026-03-17 16:23:05 -07:00
Shivam Mishra 79b18e7009 Merge branch 'hotfix/4.11.2' 2026-03-09 21:19:50 +05:30
21 changed files with 558 additions and 75 deletions
+1 -1
View File
@@ -1 +1 @@
4.12.0
4.12.1
@@ -57,7 +57,7 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
if result.nil?
render json: { message: nil }
elsif result[:error]
render json: { error: result[:error] }, status: :unprocessable_entity
render json: { error: result[:error] }, status: :unprocessable_content
else
response_data = { message: result[:message] }
response_data[:follow_up_context] = result[:follow_up_context] if result[:follow_up_context]
@@ -69,3 +69,5 @@ class Api::V1::Accounts::Captain::TasksController < Api::V1::Accounts::BaseContr
authorize(:'captain/tasks')
end
end
Api::V1::Accounts::Captain::TasksController.prepend_mod_with('Api::V1::Accounts::Captain::TasksController')
@@ -0,0 +1,101 @@
class Platform::Api::V1::EmailChannelMigrationsController < PlatformController
before_action :set_account
before_action :validate_account_permissible
before_action :validate_feature_flag
before_action :validate_params
def create
results = migrate_email_channels
render json: { results: results }, status: :ok
end
private
def set_account
@account = Account.find(params[:account_id])
end
def validate_account_permissible
return if @platform_app.platform_app_permissibles.find_by(permissible: @account)
render json: { error: 'Non permissible resource' }, status: :unauthorized
end
def validate_feature_flag
return if ActiveModel::Type::Boolean.new.cast(ENV.fetch('EMAIL_CHANNEL_MIGRATION', false))
render json: { error: 'Email channel migration is not enabled' }, status: :forbidden
end
def validate_params
return render json: { error: 'Missing migrations parameter' }, status: :unprocessable_entity if migration_params.blank?
return unless migration_params.size > MAX_MIGRATIONS
return render json: { error: "Too many migrations (max #{MAX_MIGRATIONS})" },
status: :unprocessable_entity
end
def migrate_email_channels
migration_params.map { |entry| migrate_single(entry) }
end
MAX_MIGRATIONS = 25
SUPPORTED_PROVIDERS = %w[google microsoft].freeze
def migrate_single(entry)
validate_provider!(entry[:provider])
ActiveRecord::Base.transaction do
channel = create_channel(entry)
inbox = create_inbox(channel, entry)
{ email: entry[:email], inbox_id: inbox.id, channel_id: channel.id, status: 'success' }
end
rescue StandardError => e
{ email: entry[:email], status: 'error', message: e.message }
end
def create_channel(entry)
Channel::Email.create!(
account_id: @account.id,
email: entry[:email],
provider: entry[:provider],
provider_config: entry[:provider_config]&.to_h,
imap_enabled: entry.fetch(:imap_enabled, true),
imap_address: entry[:imap_address] || default_imap_address(entry[:provider]),
imap_port: entry[:imap_port] || 993,
imap_login: entry[:imap_login] || entry[:email],
imap_enable_ssl: entry.fetch(:imap_enable_ssl, true)
)
end
def create_inbox(channel, entry)
@account.inboxes.create!(
name: entry[:inbox_name] || "Migrated #{entry[:provider]&.capitalize}: #{entry[:email]}",
channel: channel
)
end
def validate_provider!(provider)
return if SUPPORTED_PROVIDERS.include?(provider)
raise ArgumentError, "Unsupported provider '#{provider}'. Must be one of: #{SUPPORTED_PROVIDERS.join(', ')}"
end
def default_imap_address(provider)
case provider
when 'google' then 'imap.gmail.com'
when 'microsoft' then 'outlook.office365.com'
else ''
end
end
def migration_params
params.permit(migrations: [
:email, :provider, :inbox_name,
:imap_enabled, :imap_address, :imap_port, :imap_login, :imap_enable_ssl,
{ provider_config: {} }
])[:migrations]
end
end
@@ -166,6 +166,8 @@ const TOD_TO_MERIDIEM = {
evening: 'pm',
night: 'pm',
};
const CJK_CHAR_RE =
/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u;
// ─── Translation Cache ──────────────────────────────────────────────────────
@@ -278,8 +280,13 @@ const escapeRegex = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const substituteLocalTokens = (text, pairs) => {
let r = text;
pairs.forEach(([local, en]) => {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
if (CJK_CHAR_RE.test(local)) {
const re = new RegExp(escapeRegex(local), 'g');
r = r.replace(re, ` ${en} `);
} else {
const re = new RegExp(`(?<=^|\\s)${escapeRegex(local)}(?=\\s|$)`, 'g');
r = r.replace(re, en);
}
});
return r;
};
@@ -82,6 +82,9 @@ const ORDINAL_RE = `(\\d{1,2}(?:st|nd|rd|th)?|${ORDINAL_WORDS})`;
const HALF_UNIT_RE = /^(?:in\s+)?half\s+(?:an?\s+)?(hour|day|week|month|year)$/;
const RELATIVE_DURATION_RE = new RegExp(`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}$`);
const RELATIVE_DURATION_AFTER_RE = new RegExp(
`^(?:in\\s+)?${NUM_RE}\\s+${UNIT_RE}\\s+after$`
);
const DURATION_FROM_NOW_RE = new RegExp(
`^${NUM_RE}\\s+${UNIT_RE}\\s+from\\s+now$`
);
@@ -89,6 +92,9 @@ const RELATIVE_DAY_ONLY_RE = new RegExp(`^(${RELATIVE_DAYS})$`);
const RELATIVE_DAY_TOD_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})$`
);
const RELATIVE_DAY_MERIDIEM_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(am|pm)$`
);
const RELATIVE_DAY_TOD_TIME_RE = new RegExp(
`^(${RELATIVE_DAYS})\\s+(?:at\\s+)?(${TIME_OF_DAY_NAMES})\\s+(\\d{1,2}(?::\\d{2})?)$`
);
@@ -245,6 +251,7 @@ const matchDuration = (text, now) => {
return (
parseDuration(text.match(DURATION_FROM_NOW_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_AFTER_RE), now) ||
parseDuration(text.match(RELATIVE_DURATION_RE), now)
);
};
@@ -303,6 +310,13 @@ const matchRelativeDay = (text, now) => {
);
}
const dayMeridiemMatch = text.match(RELATIVE_DAY_MERIDIEM_RE);
if (dayMeridiemMatch) {
const [, dayKey, meridiem] = dayMeridiemMatch;
const hours = meridiem === 'am' ? 9 : 14;
return applyTimeWithRollover(RELATIVE_DAY_MAP[dayKey], hours, 0, now);
}
const dayAtTimeMatch = text.match(RELATIVE_DAY_AT_TIME_RE);
if (dayAtTimeMatch) {
const [, dayKey, timeRaw] = dayAtTimeMatch;
@@ -1626,6 +1626,24 @@ describe('generateDateSuggestions — localized input regressions', () => {
},
};
const zhTWSnoozeTranslations = {
UNITS: {
HOUR: '小時',
HOURS: '小時',
DAY: '天',
DAYS: '天',
},
HALF: '半',
RELATIVE: {
TOMORROW: '明天',
},
MERIDIEM: {
AM: '上午',
PM: '下午',
},
AFTER: '後',
};
describe('P1: short non-English tokens must NOT produce spurious half-duration suggestions', () => {
it('Arabic "غد" does not produce half-duration suggestions', () => {
const results = generateDateSuggestions('غد', now, {
@@ -1721,6 +1739,37 @@ describe('generateDateSuggestions — localized input regressions', () => {
expect(results[0].date.getHours()).toBe(6);
});
});
describe('zh_TW compact CJK inputs', () => {
const options = {
translations: zhTWSnoozeTranslations,
locale: 'zh-TW',
};
it('parses "2小時後" (2 hours from now) without spaces', () => {
const results = generateDateSuggestions('2小時後', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(16);
expect(results[0].date.getHours()).toBe(12);
expect(results[0].date.getMinutes()).toBe(0);
});
it('parses "半天" (half day) without spaces', () => {
const results = generateDateSuggestions('半天', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(16);
expect(results[0].date.getHours()).toBe(22);
expect(results[0].date.getMinutes()).toBe(0);
});
it('parses "明天 上午" (tomorrow AM) into tomorrow 9am', () => {
const results = generateDateSuggestions('明天 上午', now, options);
expect(results.length).toBeGreaterThan(0);
expect(results[0].date.getDate()).toBe(17);
expect(results[0].date.getHours()).toBe(9);
expect(results[0].date.getMinutes()).toBe(0);
});
});
});
describe('no-space duration suggestions', () => {
@@ -34,6 +34,7 @@ import setNewPassword from './setNewPassword.json';
import settings from './settings.json';
import signup from './signup.json';
import sla from './sla.json';
import snooze from './snooze.json';
import teamsSettings from './teamsSettings.json';
import whatsappTemplates from './whatsappTemplates.json';
@@ -74,6 +75,7 @@ export default {
...settings,
...signup,
...sla,
...snooze,
...teamsSettings,
...whatsappTemplates,
};
@@ -1,72 +1,72 @@
{
"SNOOZE_PARSER": {
"UNITS": {
"MINUTE": "minute",
"MINUTES": "minutes",
"HOUR": "hour",
"MINUTE": "分鐘",
"MINUTES": "分鐘",
"HOUR": "小時",
"HOURS": "小時",
"DAY": "day",
"DAYS": "days",
"WEEK": "week",
"WEEKS": "weeks",
"MONTH": "month",
"MONTHS": "months",
"YEAR": "month",
"YEARS": "years"
"DAY": "",
"DAYS": "",
"WEEK": "",
"WEEKS": "",
"MONTH": "",
"MONTHS": "",
"YEAR": "",
"YEARS": ""
},
"HALF": "half",
"NEXT": "next",
"THIS": "this",
"AT": "at",
"IN": "in",
"FROM_NOW": "from now",
"NEXT_YEAR": "next year",
"HALF": "",
"NEXT": "下一個",
"THIS": "這個",
"AT": "",
"IN": "",
"FROM_NOW": "之後",
"NEXT_YEAR": "明年",
"MERIDIEM": {
"AM": "am",
"PM": "pm"
"AM": "上午",
"PM": "下午"
},
"RELATIVE": {
"TOMORROW": "明天",
"DAY_AFTER_TOMORROW": "day after tomorrow",
"DAY_AFTER_TOMORROW": "後天",
"NEXT_WEEK": "下週",
"NEXT_MONTH": "next month",
"THIS_WEEKEND": "this weekend",
"NEXT_WEEKEND": "next weekend"
"NEXT_MONTH": "下個月",
"THIS_WEEKEND": "這個週末",
"NEXT_WEEKEND": "下個週末"
},
"TIME_OF_DAY": {
"MORNING": "morning",
"AFTERNOON": "afternoon",
"EVENING": "evening",
"NIGHT": "night",
"NOON": "noon",
"MIDNIGHT": "midnight"
"MORNING": "早上",
"AFTERNOON": "下午",
"EVENING": "晚上",
"NIGHT": "夜晚",
"NOON": "中午",
"MIDNIGHT": "午夜"
},
"WORD_NUMBERS": {
"ONE": "one",
"TWO": "two",
"THREE": "three",
"FOUR": "four",
"FIVE": "five",
"SIX": "six",
"SEVEN": "seven",
"EIGHT": "eight",
"NINE": "nine",
"TEN": "ten",
"TWELVE": "twelve",
"FIFTEEN": "fifteen",
"TWENTY": "twenty",
"THIRTY": "thirty"
"ONE": "",
"TWO": "",
"THREE": "",
"FOUR": "",
"FIVE": "",
"SIX": "",
"SEVEN": "",
"EIGHT": "",
"NINE": "",
"TEN": "",
"TWELVE": "十二",
"FIFTEEN": "十五",
"TWENTY": "二十",
"THIRTY": "三十"
},
"ORDINALS": {
"FIRST": "first",
"SECOND": "second",
"THIRD": "third",
"FOURTH": "fourth",
"FIFTH": "fifth"
"FIRST": "第一",
"SECOND": "第二",
"THIRD": "第三",
"FOURTH": "第四",
"FIFTH": "第五"
},
"OF": "of",
"AFTER": "after",
"WEEK": "week",
"DAY": "day"
"OF": "",
"AFTER": "",
"WEEK": "",
"DAY": ""
}
}
@@ -54,6 +54,7 @@ describe('#dateFormat', () => {
describe('#shortTimestamp', () => {
// Test cases when withAgo is false or not provided
it('returns correct value without ago', () => {
expect(shortTimestamp('in less than a minute')).toEqual('now');
expect(shortTimestamp('less than a minute ago')).toEqual('now');
expect(shortTimestamp('1 minute ago')).toEqual('1m');
expect(shortTimestamp('12 minutes ago')).toEqual('12m');
@@ -68,6 +68,7 @@ export const shortTimestamp = (time, withAgo = false) => {
const suffix = withAgo ? ' ago' : '';
const timeMappings = {
'less than a minute ago': 'now',
'in less than a minute': 'now',
'a minute ago': `1m${suffix}`,
'an hour ago': `1h${suffix}`,
'a day ago': `1d${suffix}`,
+1 -1
View File
@@ -8,7 +8,7 @@ class AgentBots::WebhookJob < WebhookJob
def perform(url, payload, webhook_type = :agent_bot_webhook)
super(url, payload, webhook_type)
rescue RestClient::TooManyRequests, RestClient::InternalServerError => e
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name}")
Rails.logger.warn("[AgentBots::WebhookJob] attempt #{executions} failed #{e.class.name} payload=#{payload.to_json}")
raise
end
end
+6 -1
View File
@@ -176,7 +176,7 @@ class Message < ApplicationRecord
additional_attributes: additional_attributes,
content_attributes: content_attributes,
content_type: content_type,
content: outgoing_content,
content: webhook_content,
conversation: conversation.webhook_data,
created_at: created_at,
id: id,
@@ -195,6 +195,11 @@ class Message < ApplicationRecord
MessageContentPresenter.new(self).outgoing_content
end
# Raw content with survey URL (no markdown rendering) for webhook consumers
def webhook_content
MessageContentPresenter.new(self).webhook_content
end
def email_notifiable_message?
return false if private?
return false if %w[outgoing template].exclude?(message_type)
+15 -9
View File
@@ -1,22 +1,28 @@
class MessageContentPresenter < SimpleDelegator
def outgoing_content
content_to_send = if should_append_survey_link?
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
else
content
end
Messages::MarkdownRendererService.new(
content_to_send,
content_with_survey_link,
conversation.inbox.channel_type,
conversation.inbox.channel
).render
end
def webhook_content
content_with_survey_link
end
private
def content_with_survey_link
if should_append_survey_link?
survey_link = survey_url(conversation.uuid)
custom_message = inbox.csat_config&.dig('message')
custom_message.present? ? "#{custom_message} #{survey_link}" : I18n.t('conversations.survey.response', link: survey_link)
else
content
end
end
def should_append_survey_link?
input_csat? && !inbox.web_widget?
end
+1 -1
View File
@@ -1,5 +1,5 @@
shared: &shared
version: '4.12.0'
version: '4.12.1'
development:
<<: *shared
+1
View File
@@ -507,6 +507,7 @@ Rails.application.routes.draw do
delete :destroy
end
end
resources :email_channel_migrations, only: [:create]
end
end
end
+1
View File
@@ -15,6 +15,7 @@ TimeoutStopSec=30
KillMode=mixed
StandardInput=null
SyslogIdentifier=%p
LimitNOFILE=65536
Environment="PATH=/home/chatwoot/.rvm/gems/ruby-3.4.4/bin:/home/chatwoot/.rvm/gems/ruby-3.4.4@global/bin:/home/chatwoot/.rvm/rubies/ruby-3.4.4/bin:/home/chatwoot/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin:/home/chatwoot/.rvm/bin:/home/chatwoot/.rvm/bin"
Environment="PORT=3000"
+1
View File
@@ -15,6 +15,7 @@ TimeoutStopSec=30
KillMode=mixed
StandardInput=null
SyslogIdentifier=%p
LimitNOFILE=65536
MemoryMax=1.2G
MemoryHigh=infinity
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@chatwoot/chatwoot",
"version": "4.12.0",
"version": "4.12.1",
"license": "MIT",
"scripts": {
"eslint": "eslint app/**/*.{js,vue}",
@@ -0,0 +1,266 @@
require 'rails_helper'
RSpec.describe 'Platform Email Channel Migrations API', type: :request do
let!(:account) { create(:account) }
let(:platform_app) { create(:platform_app) }
let(:base_url) { "/platform/api/v1/accounts/#{account.id}/email_channel_migrations" }
let(:headers) { { api_access_token: platform_app.access_token.token } }
let(:google_provider_config) do
{ access_token: 'ya29.test-access-token', refresh_token: '1//test-refresh-token', expires_on: 1.hour.from_now.to_s }
end
let(:valid_migration_params) do
{
migrations: [
{
email: 'support@example.com',
provider: 'google',
provider_config: google_provider_config,
inbox_name: 'Migrated Support'
}
]
}
end
before do
create(:platform_app_permissible, platform_app: platform_app, permissible: account)
end
describe 'POST /platform/api/v1/accounts/:account_id/email_channel_migrations' do
context 'when unauthenticated' do
it 'returns unauthorized without token' do
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
post base_url, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
it 'returns unauthorized with invalid token' do
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
post base_url, params: valid_migration_params, headers: { api_access_token: 'invalid' }, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
context 'when account is not permissible' do
let(:other_account) { create(:account) }
let(:other_url) { "/platform/api/v1/accounts/#{other_account.id}/email_channel_migrations" }
it 'returns unauthorized' do
with_modified_env EMAIL_CHANNEL_MIGRATION: other_account.id.to_s do
post other_url, params: valid_migration_params, headers: headers, as: :json
expect(response).to have_http_status(:unauthorized)
end
end
end
context 'when account is not in allowed list' do
it 'returns forbidden' do
with_modified_env EMAIL_CHANNEL_MIGRATION: '' do
post base_url, params: valid_migration_params, headers: headers, as: :json
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('Email channel migration is not enabled')
end
end
end
context 'when authenticated with permissible account' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
it 'creates a google email channel and inbox' do
expect do
post base_url, params: valid_migration_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(1).and change(Inbox, :count).by(1)
expect(response).to have_http_status(:ok)
result = response.parsed_body['results'].first
expect(result['status']).to eq('success')
expect(result['email']).to eq('support@example.com')
expect(result['inbox_id']).to be_present
expect(result['channel_id']).to be_present
end
it 'sets correct google channel attributes' do
post base_url, params: valid_migration_params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.provider).to eq('google')
expect(channel.imap_enabled).to be(true)
expect(channel.imap_address).to eq('imap.gmail.com')
expect(channel.imap_port).to eq(993)
expect(channel.imap_login).to eq('support@example.com')
expect(channel.provider_config['refresh_token']).to eq('1//test-refresh-token')
end
it 'sets correct inbox attributes' do
post base_url, params: valid_migration_params, headers: headers, as: :json
inbox = Inbox.find(response.parsed_body['results'].first['inbox_id'])
expect(inbox.name).to eq('Migrated Support')
expect(inbox.account_id).to eq(account.id)
end
it 'creates a microsoft email channel with correct defaults' do
params = {
migrations: [
{
email: 'support@outlook.com',
provider: 'microsoft',
provider_config: { access_token: 'test', refresh_token: 'test', expires_on: 1.hour.from_now.to_s }
}
]
}
post base_url, params: params, headers: headers, as: :json
expect(response).to have_http_status(:ok)
result = response.parsed_body['results'].first
channel = Channel::Email.find(result['channel_id'])
expect(channel.provider).to eq('microsoft')
expect(channel.imap_address).to eq('outlook.office365.com')
end
it 'uses default inbox name when not provided' do
params = { migrations: [{ email: 'test@example.com', provider: 'google', provider_config: google_provider_config }] }
post base_url, params: params, headers: headers, as: :json
inbox = Inbox.find(response.parsed_body['results'].first['inbox_id'])
expect(inbox.name).to eq('Migrated Google: test@example.com')
end
it 'defaults imap_login to email address' do
post base_url, params: valid_migration_params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.imap_login).to eq('support@example.com')
end
it 'allows overriding imap settings' do
params = {
migrations: [
{
email: 'custom@example.com',
provider: 'google',
provider_config: google_provider_config,
imap_address: 'custom.imap.server.com',
imap_port: 143,
imap_login: 'custom-login@example.com',
imap_enable_ssl: false
}
]
}
post base_url, params: params, headers: headers, as: :json
channel = Channel::Email.find(response.parsed_body['results'].first['channel_id'])
expect(channel.imap_address).to eq('custom.imap.server.com')
expect(channel.imap_port).to eq(143)
expect(channel.imap_login).to eq('custom-login@example.com')
expect(channel.imap_enable_ssl).to be(false)
end
end
context 'when migrating multiple channels' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
let(:bulk_params) do
{
migrations: [
{ email: 'first@example.com', provider: 'google', provider_config: google_provider_config },
{ email: 'second@example.com', provider: 'google', provider_config: google_provider_config },
{ email: 'third@example.com', provider: 'microsoft',
provider_config: { access_token: 'test', refresh_token: 'test', expires_on: 1.hour.from_now.to_s } }
]
}
end
it 'creates all channels and inboxes' do
expect do
post base_url, params: bulk_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(3).and change(Inbox, :count).by(3)
results = response.parsed_body['results']
expect(results.map { |r| r['status'] }).to all(eq('success'))
expect(results.map { |r| r['email'] }).to match_array(%w[first@example.com second@example.com third@example.com])
end
it 'continues processing when one migration fails' do
create(:channel_email, email: 'first@example.com', account: account)
expect do
post base_url, params: bulk_params, headers: headers, as: :json
end.to change(Channel::Email, :count).by(2).and change(Inbox, :count).by(2)
results = response.parsed_body['results']
failed = results.find { |r| r['email'] == 'first@example.com' }
succeeded = results.reject { |r| r['email'] == 'first@example.com' }
expect(failed['status']).to eq('error')
expect(failed['message']).to include('Email has already been taken')
expect(succeeded.map { |r| r['status'] }).to all(eq('success'))
end
end
context 'when params are invalid' do
around do |example|
with_modified_env EMAIL_CHANNEL_MIGRATION: 'true' do
example.run
end
end
it 'returns unprocessable entity when migrations param is missing' do
post base_url, params: {}, headers: headers, as: :json
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns unprocessable entity when migrations exceed max batch size' do
params = {
migrations: Array.new(26) { |i| { email: "user#{i}@example.com", provider: 'google', provider_config: google_provider_config } }
}
post base_url, params: params, headers: headers, as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to include('Too many migrations')
end
it 'returns error for unsupported provider' do
params = {
migrations: [{ email: 'test@example.com', provider: 'Yahoo', provider_config: google_provider_config }]
}
post base_url, params: params, headers: headers, as: :json
result = response.parsed_body['results'].first
expect(result['status']).to eq('error')
expect(result['message']).to include("Unsupported provider 'Yahoo'")
end
it 'returns error for duplicate email' do
create(:channel_email, email: 'existing@example.com', account: account)
params = {
migrations: [{ email: 'existing@example.com', provider: 'google', provider_config: google_provider_config }]
}
post base_url, params: params, headers: headers, as: :json
result = response.parsed_body['results'].first
expect(result['status']).to eq('error')
expect(result['message']).to include('Email has already been taken')
end
end
end
end
+3 -5
View File
@@ -401,12 +401,11 @@ RSpec.describe Message do
expect(message.webhook_data.key?(:attachments)).to be false
end
it 'uses outgoing_content for webhook content' do
message = create(:message, content: 'Test content')
expect(message).to receive(:outgoing_content).and_return('Outgoing test content')
it 'uses raw content without markdown rendering for webhook content' do
message = create(:message, content: 'Test **bold** content')
webhook_data = message.webhook_data
expect(webhook_data[:content]).to eq('Outgoing test content')
expect(webhook_data[:content]).to eq('Test **bold** content')
end
it 'includes CSAT survey link in webhook content for input_csat messages' do
@@ -414,7 +413,6 @@ RSpec.describe Message do
conversation = create(:conversation, inbox: inbox)
message = create(:message, conversation: conversation, content_type: 'input_csat', content: 'Rate your experience')
expect(message.outgoing_content).to include('survey/responses/')
expect(message.webhook_data[:content]).to include('survey/responses/')
end
end
@@ -55,6 +55,34 @@ RSpec.describe MessageContentPresenter do
end
end
describe '#webhook_content' do
context 'when message is not input_csat' do
let(:content_type) { 'text' }
let(:content) { 'Regular **bold** message' }
it 'returns raw content without markdown rendering' do
expect(presenter.webhook_content).to eq('Regular **bold** message')
end
end
context 'when message is input_csat and inbox is not web widget' do
let(:content_type) { 'input_csat' }
let(:content) { 'Rate your experience' }
before do
allow(message.inbox).to receive(:web_widget?).and_return(false)
end
it 'includes CSAT survey URL without markdown rendering' do
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
expect(presenter.webhook_content).to include(expected_url)
expect(presenter.webhook_content).not_to include('<p>')
end
end
end
end
describe 'delegation' do
let(:content_type) { 'text' }
let(:content) { 'Test message' }