This PR limits IMAP email fetching to 500 messages per sync run to avoid
expensive/long-running mailbox scans. It also filters out
already-imported emails and Chatwoot-generated notification emails
during the header fetch phase, before fetching full email bodies,
reducing unnecessary IMAP work.
Fixes #CW-7001 (issue) :
https://linear.app/chatwoot/issue/CW-7001/emails-not-syncing
# Pull Request Template
## Description
This PR fixes an issue where agent variables like
`{{agent.name}}`,`{{agent.first_name}}`, `{{agent.last_name}}`, and
`{{agent.email}}` were not rendering in automation messages.
In automation, these either showed blank or returned `Liquid error:
internal`, while the same variables worked fine in macros.
**Cause**
Automation messages are created without a sender, so agent data was
missing during variable rendering. This also caused errors in name
handling, and `email` was not defined at all.
**Solution**
* Handle missing agent data safely to avoid errors
* Add support for `{{agent.email}}`
* Fallback to conversation assignee when sender is not present
Fixes
https://linear.app/chatwoot/issue/CW-6979/template-variables-not-working-in-automated-messages
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
### Screenshots
**Automation**
<img width="759" height="284" alt="image"
src="https://github.com/user-attachments/assets/61a877b7-4984-4a7f-bbef-b8c510dcbdfe"
/>
**Before**
<img width="404" height="105" alt="image"
src="https://github.com/user-attachments/assets/da665ce8-137d-4249-8ee5-a1acc11391db"
/>
**After**
<img width="564" height="132" alt="image"
src="https://github.com/user-attachments/assets/6a80d67c-49c8-4658-b782-ae4acbc77256"
/>
## 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
Adds the Meta WhatsApp Cloud API surface needed for browser-based
calling. This is the second slice of the WhatsApp calling feature,
sitting on top of `feat/voice-call-model-wiring` and consumed by later
PRs (incoming-webhook pipeline, call service, frontend).
This PR ships only the provider-level HTTP wrapper and one error class.
It is feature-flag-free and does not change any user-visible behaviour
on its own — without later PRs, no caller invokes these methods.
## Linear
-
https://linear.app/chatwoot/issue/PLA-148/pr-2-meta-cloud-api-provider-methods
## What changed
- Add `Whatsapp::Providers::WhatsappCloudCallMethods`
(`enterprise/app/services/whatsapp/providers/whatsapp_cloud_call_methods.rb`)
wrapping six Meta endpoints:
- `pre_accept_call`, `accept_call`, `reject_call`, `terminate_call` —
`POST /{phone_id}/calls` with the relevant action payload.
- `send_call_permission_request` — `POST /{phone_id}/messages`
interactive `call_permission_request`.
- `initiate_call` — `POST /{phone_id}/calls` with `audio`/`offer`
session.
- Prepend the module into `Whatsapp::Providers::WhatsappCloudService`
only if defined, so OSS continues to work without the enterprise
overlay.
- Add `Voice::CallErrors::NoCallPermission`
(`enterprise/lib/voice/call_errors.rb`) — raised when Meta returns error
code `138006` from `initiate_call`. The remaining call-service errors
(`NotRinging`, `AlreadyAccepted`, `CallFailed`) will land with PR-4.
## How to test
There is no UI in this PR. Smoke-test from a Rails console with a
WhatsApp inbox configured for calling:
```ruby
inbox = Inbox.find(<id>)
svc = inbox.channel.provider_service
svc.respond_to?(:initiate_call) # => true
svc.respond_to?(:send_call_permission_request) # => true
# Optional live calls (require a real phone + Meta call-permission opt-in):
svc.send_call_permission_request('15551234567')
svc.initiate_call('15551234567', '<sdp_offer>')
```
Failure path: `initiate_call` against a contact who has not granted call
permission should raise `Voice::CallErrors::NoCallPermission` with
Meta's user-facing message.
When an agent shares a conversation link copied from a custom view (e.g.
/custom_view/{id}/conversations/{id}), the link previously broke for
recipients who didn't have access to that custom view. The conversation
now loads regardless — if the custom view isn't available to the
recipient, they're redirected to the direct conversation URL.
### How to reproduce
1. As Agent A, open a conversation from inside a personal custom view
and copy the URL from the address bar.
2. Share the URL with Agent B who does not have access to that custom
view.
3. Before this fix, the link failed to load the conversation. After this
fix, Agent B lands on the conversation via the direct URL.
### What changed
- Added a beforeEnter guard on the conversations_through_folders route.
It checks the user's available conversation custom views (fetching them
on demand for deep links), and if the foldersId in the URL isn't among
them, redirects to the inbox_conversation route with the same
conversation_id.
---------
Co-authored-by: iamsivin <iamsivin@gmail.com>
### Description
Inbound voice calls now route ownership cleanly: the call widget is
hidden from agents who aren't the conversation assignee, the first agent
to pick up becomes the assignee, and any later join attempt by another
agent is rejected with a clear "<agent> is already handling the call."
alert.
Closes
https://linear.app/chatwoot/issue/PLA-98/inbound-voice-calls-assignment-aware-visibility-auto-assignment-on
### How to test
1. As Agent A and Agent B, open the dashboard for the same voice inbox
in two browsers.
2. Place an inbound call to the inbox with the conversation
**unassigned** — both agents should see the call widget.
3. Have Agent A click **Join**. Agent A's widget transitions to the
active call; Agent B's widget disappears (conversation is now assigned
to Agent A).
4. While the call is in progress, attempt to join from a third agent
(e.g., via the bubble in the conversation timeline) — the join is
rejected with the toast `Agent A is already handling the call.`
5. Resolve the conversation, then place a second call to a conversation
that is already manually assigned to Agent A — only Agent A sees the
widget; nobody else does.
6. Race test: trigger two near-simultaneous join attempts (two agents
click Join within a few hundred ms of each other) — exactly one wins;
the other gets the conflict alert.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Twilio voice now uses first-class `Call` records as the source of truth
for call state, instead of storing it on
`conversation.additional_attributes` and `conversation.identifier`. Each
call gets its own record, its own `voice_call` bubble matched by
`call_sid`, and its own conference name keyed off `Call.id`. Multiple
calls on the same conversation (for `lock_to_single_conversation`
inboxes) now work correctly, and the conversation card stays in sync
with the real latest message.
Fixes https://linear.app/chatwoot/issue/PLA-121/lock-to-single-thread
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
When an agent mentions themselves in a private note, they no longer
receive a redundant notification for their own mention.
Closes: #4096
# Pull Request Template
## Description
Agents who mention themselves in a private note no longer receive a
conversation_mention notification. Previously, the mention service would
generate a notification for every mentioned user without checking
whether the sender and the
mentioned user were the same person.
This PR fixes an issue where Slack emojis are rendered as text
shortcodes (e.g. 🚀) instead of the actual emoji characters in
Chatwoot messages.
It introduces a new EmojiFormatter class that uses the emoji-data
mapping to convert shortcodes to unicode characters.
---------
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
The `ChatwootApp.chatwoot_cloud?` gate on the platform banners route in
#13943 reads `InstallationConfig` from the database. Because `routes.rb`
is evaluated during `Rails.application.initialize!`, this ran before the
database existed on a fresh setup, breaking `bundle exec rake db:create`
in CI and first-time installs with `ActiveRecord::NoDatabaseError: We
could not find your database: chatwoot_test`.
The route is now always mounted, and the cloud check moved to where the
database is guaranteed to be available — the controller
(`before_action`) and the super admin sidebar partial.
Closes the CI failure introduced by #13943.
## How to test
1. Drop your local databases: `bundle exec rake db:drop`
2. Run `bundle exec rake db:create` — it should succeed (previously
failed with `NoDatabaseError`)
3. Bring the DB back: `bundle exec rake db:setup`
4. On a non-cloud install, visit `/super_admin/platform_banners` —
should 404, and the sidebar entry should be hidden
5. With `DEPLOYMENT_ENV=cloud` configured (cloud install), the page and
sidebar entry should work as before
## What changed
- `config/routes.rb` — always mount `resources :platform_banners` (no DB
call at boot)
- `app/controllers/super_admin/platform_banners_controller.rb` —
`before_action` raises `ActionController::RoutingError` (404) when not
on Chatwoot Cloud
- `app/views/super_admin/application/_navigation.html.erb` — hides the
sidebar entry on non-cloud installs
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Adds a platform-wide status banner system to notify all users about
external service outages. Super Admins can create, edit, and manage
banners via the Super Admin console. Banners support markdown for links
and are dismissible by users.
<img width="1099" height="236" alt="image"
src="https://github.com/user-attachments/assets/047a7994-d885-4a8a-b9c4-aeb32f15474a"
/>
## How to test
1. Set `ENABLE_PLATFORM_BANNERS=true` in your environment
2. Go to Super Admin → Platform Banners
3. Create a banner with a message like: `Elevated error rates from Meta
APIs. [Check status](https://metastatus.com)`
4. Select a banner type: `info` (blue), `warning` (amber), or `error`
(red)
5. Visit the dashboard — the banner should appear at the top
6. Click "Dismiss" — the banner hides and stays dismissed across page
reloads
7. Deactivate the banner in Super Admin — it disappears on next page
load
## What changed
- New `PlatformBanner` model with `banner_message`, `banner_type`
(info/warning/error), and `active` flag
- Super Admin CRUD via Administrate (controller, dashboard, routes,
sidebar icon)
- `DashboardController` serves active banners via `globalConfig`
- `StatusBanner.vue` component renders banners with markdown support and
per-banner localStorage dismiss
- Feature gated behind `ENABLE_PLATFORM_BANNERS` env var
🤖 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>
Co-authored-by: iamsivin <iamsivin@gmail.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
# Pull Request Template
## Description
- Wires up Controllers to auto-sync job
- adds plan based sync schedule
- a scheduler that runs every hour to check syncable documents
- guards the whole feature behind feature flag by reclaiming
`twilio_content_templates`
- Adds a global and account level cap on how many documents to enqueue
to prevent sudden burst at first run
- some refactor to simplify code
- specs
Fixes # (issue)
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## 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.
specs and locally
## 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
Voice calling is now a capability on the existing TwilioSms rather than
a separate Voice model. A single Twilio phone number handles both SMS
and voice calls through one inbox.
Fixes
https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel
and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms
**What changed**
- Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms
- Added voice_enabled, twiml_app_sid, api_key_secret columns to
channel_twilio_sms table
- Dropped channel_voice table (no production data)
- All voice logic lives in Enterprise layer via
prepend_mod_with('Channel::TwilioSms')
- Added Voice settings tab on Twilio SMS inbox settings to
enable/disable voice
- Validates Twilio number voice capability before provisioning
- Teardown service cleans up TwiML app and credentials when voice is
disabled
- Frontend voice detection uses isVoiceCallEnabled() /
getVoiceCallProvider() helpers — extensible to future providers
- Gated by channel_voice feature flag
**How to test**
1. Enable feature flag:
Account.find(<id>).enable_features('channel_voice')
2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials →
verify incoming/outgoing calls and SMS work
3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox
→ Voice tab → toggle on → provide API key credentials → verify calls
work
4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted,
credentials cleared, SMS still works
5. Re-enable voice: Toggle on again → must provide api_key_secret again
→ new TwiML app provisioned
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Fixes
https://linear.app/chatwoot/issue/CW-5641/add-the-support-for-variables-in-whatsapp-campaign-templates
This PR adds liquid variable support to WhatsApp campaigns, enabling
dynamic per-contact personalization. It supports the same liquid
variables as SMS campaigns ({{contact.name}}, {{contact.email}}, etc.).
Variables are processed per-contact when the campaign executes, allowing
personalized messages at scale.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
## Description
Adds Playwright E2E testing infrastructure with project configuration
and a login flow test. This is
Phase 1 of the Playwright E2E suite, kept minimal with only the core
setup and login component.
Ref: Discussion #13500, PR #13067
## Type of change
Please delete options that are not relevant.
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
- Verified all imports resolve correctly
(`login-flow-ui-validation.spec.ts` only imports `Login` from
`@components/ui`)
- Ran login flow test locally against a running Chatwoot instance
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] 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
---------
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
## Description
Fix a Postgres planner trap on the "Pending Response: Longest first"
sort that causes the conversation list to hang on busy accounts.
The current `sort_on_waiting_since` generates query with `ORDER BY
waiting_since ASC NULLS LAST, created_at ASC`. That order-by is exactly
the shape of the single-column `index_conversations_on_waiting_since`
btree, so the planner picks a forward index walk thinking `LIMIT 25`
will stop early. In practice the per-account matches are spread along
the global waiting_since timeline, so the scan reads tens of millions of
rows from other accounts and discards them via the filter before
producing any results which in turn causes the requests to time out and
the conversation list spinner never resolves.
DESC direction and every other sort (`priority`, `created_at`,
`last_activity_at`) are unaffected. They fall through to
`conv_acid_inbid_stat_asgnid_idx` (account-scoped composite), which is
the right index for this access pattern.
This change leads the ORDER BY with the expression `(waiting_since IS
NULL)`, which no column-only btree can satisfy. The planner falls back
to the same account-scoped index used by every other sort, and sorts in
memory. Same logical NULLS LAST output for both directions; no behavior
change for users.
Fixes CW-6965
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality not to work as expected)
- [ ] This change requires a documentation update
## How Has This Been Tested?
- [x] Existing specs pass
- [x] Added new specs to cover NULL case
- [x] Verify results and order for old and new query in prod
- [x] Tested in prod since staging data was not sufficient
`EXPLAIN (ANALYZE, BUFFERS)` for the same query (status=0, ASC, LIMIT
25) on a representative production account, before vs after:
| Metric | Before | After |
| --- | --- | --- |
| Execution time | 34,679 ms | 0.71 ms |
| Rows discarded by filter | 36,906,962 | 0 |
| Shared buffer hits | 12,699,924 | 11 |
| Blocks read from disk | 851,226 | 112 |
| I/O read time | 17,785 ms | 0.3 ms |
| Pages dirtied / written | 98 / 31,043 | 0 / 0 |
Verified on two production accounts: identical row IDs in identical
order between the old and new ORDER BY for both ASC and DESC. A
NULL-bucket regression spec was added covering ASC/DESC tail ordering
when some conversations have a null `waiting_since`.
Roughly `49,000×` faster on this query (34,679 ms → 0.71 ms), and
trivially less I/O and buffer pressure on the cluster while it runs.
## 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
- [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
- [ ] Any dependent changes have been merged and published in downstream
modules
Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
# Pull Request Template
## Description
This PR fixes an issue where outgoing Email messages (via API) do not
preserve single line breaks in rendered HTML.
#### Cause
Messages are stored with `\n`, but rendering differs:
* **Other channel** (`markdown-it`, `breaks: true`) → `\n` → `<br>`
* **Email** (CommonMark) without `HARDBREAKS` → `\n` collapsed into
spaces
Result: multi-line messages appear as a single paragraph in Email.
#### Solution
* Added `hardbreaks:` option to `render_message` (default: false)
* Enabled `hardbreaks: true` in `EmailHelper#render_email_html`
This ensures `\n` renders as `<br />` in Email, matching web widget
behavior.
Fixes
https://linear.app/chatwoot/issue/CW-6941/outgoing-email-messages-strip-single-newlines-from-plain-text-content
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
#### Screenshots
**Before**
<img width="604" height="104" alt="image"
src="https://github.com/user-attachments/assets/f9086ffb-a5c7-4688-99aa-97ea5edcccde"
/>
**After**
<img width="604" height="210" alt="image"
src="https://github.com/user-attachments/assets/a8f21c76-bcb8-4058-937a-dd185fb6745c"
/>
## 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
- [ ] Any dependent changes have been merged and published in downstream
modules
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
## Context
Same root-cause as #7822 / #14078, but in a second file that PR #14078
doesn't touch:
\`app/javascript/dashboard/modules/search/components/SearchResultContactItem.vue\`.
\`countries.json\` entries only have \`id\` (e.g. \`"AF"\`) — there is
no \`code\` field. So:
\`\`\`js
const countriesMap = countries.reduce((acc, country) => {
acc[country.code] = country; // country.code is undefined →
acc[undefined] = country
acc[country.id] = country;
return acc;
}, {});
\`\`\`
…overwrites \`acc[undefined]\` on every iteration, leaving whichever
country comes last in the list (Zimbabwe, \`id: "ZW"\`). Later, the
\`countryDetails\` lookup falls back to that value whenever the
contact's \`country\` / \`countryCode\` is missing or unknown, and
Zimbabwe is displayed incorrectly.
## Fix
One-line delete — drop the dead \`acc[country.code] = country\` write.
Lookups by ISO code continue to work via \`country.id\`.
## Scope
Only the search-results card. The Contacts card is already being fixed
in #14078 with the same one-line delete. It's worth patching this
surface too so the same symptom doesn't reappear when the same contact
is accessed via \`Ctrl+K\` search.
## Test plan
- [ ] Reproduce: search for a contact with \`country: null\` /
\`countryCode: null\` — before this patch the flag renders as Zimbabwe;
after, it renders as no country (current expected fallback).
- [ ] Search for a contact with a valid ISO \`countryCode\` (e.g.
\`"IN"\`) — country still resolves correctly.
- [ ] Contacts list page (\`ContactsCard.vue\`, fixed in #14078) — no
regression.
## Follow-up (not in this PR)
Both components keep rebuilding the same \`countriesMap\` per mount. A
small \`shared/constants/countries.js\` export (\`export const byId =
countries.reduce(...)\`, computed once at module load) would save the
per-mount cost and centralise the shape so this bug can't return.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
When a WhatsApp contact starts a new conversation by sending multiple
images at once (an album), each image arrives as a separate webhook.
Because no conversation exists yet, the concurrent workers each pass the
"does a conversation exist?" check and each create their own
conversation — producing one conversation per image instead of one
grouped conversation.
This fix serializes webhook processing per `(inbox, contact)` using a
Redis lock at the job level, so only one webhook at a time can create
the initial conversation for a given contact. Concurrent workers retry
with backoff and append to the same conversation once the lock is
released.
## Closes
- Closes#13261
## How to test
1. On a WhatsApp inbox, ensure there is no active (open) conversation
with a specific test contact — resolve or delete any existing one.
2. From a phone, select 6+ images in the WhatsApp gallery and send them
as a single album to the Chatwoot-connected number.
3. Open the Chatwoot dashboard and confirm exactly **one** new
conversation is created, with all images grouped under it.
4. Repeat the test with a mix of attachment types (XMLs, PDFs, images)
sent in rapid succession — still one conversation.
## What changed
- New Redis key `WHATSAPP_MESSAGE_CREATE_LOCK::<inbox_id>::<sender_id>`
in `lib/redis/redis_keys.rb`.
- `Webhooks::WhatsappEventsJob` now inherits from `MutexApplicationJob`
and wraps event processing in `with_lock(key)`, matching the pattern
already used by `FacebookEventsJob`, `InstagramEventsJob`, and
`TiktokEventsJob`.
- Uses `retry_on LockAcquisitionError, wait: 1.second, attempts: 8` so
concurrent webhooks retry until the lock is free instead of poll-waiting
inside the service.
- Sender ID is derived from the webhook payload (contact's `from`, or
`to` for SMB echo events); status-only webhooks bypass the lock.
- Issue 1 from the report (same `source_id` redelivery) was already
handled previously by `Whatsapp::MessageDedupLock` (atomic `SET NX EX`);
no changes needed there.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a customer responds to a bot's interactive prompt (input_select,
input_csat, form, input_email) from the widget, the response shows up in
the Chatwoot agent UI but is not reflected in the linked Slack channel —
Slack only ever shows the original question. This happens because the
widget submits the answer as an UPDATE to the original message (writing
`content_attributes.submitted_values` or `submitted_email`), but the
Slack hook only listened to `message.created`, so updates were ignored.
Closes https://linear.app/chatwoot/issue/PLA-147
### Preview
<img width="1290" height="1106" alt="CleanShot 2026-04-21 at 13 19
19@2x"
src="https://github.com/user-attachments/assets/cd2a9d3f-89d3-4e81-9230-5b078e1b7b44"
/>
### How to test
1. Connect a web widget inbox to a Slack channel.
2. Trigger each bot message type (input_select, form, input_csat,
input_email) in a conversation.
3. Submit responses from the widget.
4. Verify each response now appears in the Slack thread, appended to the
original bot question.
---------
Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
## Description
This PR resolves an issue that was causing Zimbabwe country being
selected in the Contacts list.
The root cause is objects in `countries.json` file are missing the
`code` property which was causing the countries map to always have the
last country in the list also map to an `undefined` key. In turn, this
was causing an error when contact's `country` was undefined.
Additional thing to keep in mind: I am not sure if there is a case when
contact's `country` property is used or if it can be removed as well,
but in my Chatwoot installation contacts only have a `countryCode`
property, and there is no way to set `country` from the widget sdk.
Fixes#7822
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
Simply running the vue application locally targeting my live chatwoot
API.
## 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
- [ ] 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
## Linear Ticket
-
https://linear.app/chatwoot/issue/CW-6883/allow-disabling-2fa-using-a-backup-code
## Description
When a user loses access to their authenticator app, they can now
disable 2FA using one of their saved backup codes (in addition to their
password), so they can re-enroll a new authenticator. The disable dialog
includes a toggle to switch between entering a verification code and a
backup code.
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Via UI flows
<img width="495" height="423" alt="Screenshot 2026-04-20 at 2 17 21 PM"
src="https://github.com/user-attachments/assets/cc6b3dc5-39e6-4104-b5b9-cdabdc46947e"
/>
<img width="475" height="409" alt="Screenshot 2026-04-20 at 2 17 36 PM"
src="https://github.com/user-attachments/assets/97c7304d-4adb-42ed-b7b4-50f5b38585a3"
/>
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] 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
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
Updating portal settings (name, header text, page title, homepage link)
on a portal that already has a logo attached returns 500. The error is
\`NoMethodError: undefined method 'valid_encoding?' for an instance of
Integer\`. The fix is a two-character change in
\`process_attached_logo\`.
Closes#13300
## Root cause
\`ActiveStorage::Blob.find_signed\` expects a signed ID string (e.g.
\`"eyJfcmFpbH..."\`). Internally it calls \`valid_encoding?\` on the
argument to validate the signature payload — a method that exists on
\`String\` but not \`Integer\`.
When a portal already has a logo, the frontend includes the blob's raw
database integer ID (e.g. \`blob_id: 170\`) in the update request
payload. The controller passes this integer directly to \`find_signed\`,
which immediately raises \`NoMethodError\` before any database query is
made.
\`\`\`ruby
# before, crashes when blob_id is an Integer
blob_id = params[:blob_id]
blob = ActiveStorage::Blob.find_signed(blob_id) # NoMethodError here
@portal.logo.attach(blob)
\`\`\`
## What changed
\`\`\`ruby
# after, safe for any input type
blob = ActiveStorage::Blob.find_signed(params[:blob_id].to_s)
@portal.logo.attach(blob) if blob
\`\`\`
\`.to_s\` on an Integer produces a plain decimal string (\`"170"\`),
which is not a valid signed ID. \`find_signed\` returns \`nil\` for any
invalid signature rather than raising, so the nil guard prevents a
broken \`attach\` call. The existing logo remains attached and the
settings update succeeds.
## Trade-offs considered
| Option | Decision |
|---|---|
| \`find(blob_id)\` when input is an Integer | Bypasses signature
verification — any authenticated user knowing a blob ID could attach
arbitrary files to a portal. Security risk. Rejected. |
| Raise a 422 for non-string blob_id | Overly strict — the frontend
sending an integer is pre-existing behaviour this PR shouldn't break. |
| Silently no-op for invalid blob_id (chosen) | Correct product
behaviour: if no valid signed upload is provided, leave the logo
unchanged. The settings update still succeeds. |
## Known limitation
The correct long-term fix is also on the frontend: it should only send
\`blob_id\` when attaching a **new** upload (using the signed ID from
the direct-upload flow), not when re-submitting the existing logo's raw
database integer ID. This PR makes the server robust against the current
frontend behaviour without requiring a coordinated frontend change.
## How to reproduce
1. Create a Help Center portal and upload a logo
2. Update any text field via \`PUT /api/v1/accounts/:id/portals/:slug\`
while including \`blob_id: <integer>\` in the payload
3. Observe 500 with \`NoMethodError: undefined method 'valid_encoding?'
for an instance of Integer\`
After this fix, the request returns 200, settings are updated, and the
existing logo is preserved.
Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za>
This routes external downloads used by webhook fetch used by macros and
acutomations through SafeFetch. It closes the SSRF exposure from raw
Down.download paths, preserves provider-specific auth and header flows,
and adds regression coverage for blocked internal URLs plus
authenticated downloads.
Fixes # (issue):
[CW-6940](https://linear.app/chatwoot/issue/CW-6940/ssrf-via-webhooksautomationmacros-non-upload-non-avatar)
\`GET /platform/api/v1/agent_bots\` returns 500 when any \`AgentBot\`
that was previously registered with a Platform App has since been
deleted. The bug was introduced by a missing \`dependent: :destroy\` on
the \`AgentBot\` model — deleting a bot left orphaned rows in
\`platform_app_permissibles\`, which the index action later iterated
over and crashed rendering with a \`NoMethodError\` on \`nil\`.
Closes#13407
## Root cause
The index action loads all \`platform_app_permissibles\` for the
platform app and passes each \`resource.permissible\` (the associated
\`AgentBot\`) to a Jbuilder partial. When the \`AgentBot\` no longer
exists, \`resource.permissible\` returns \`nil\` and the partial crashes
calling \`.id\`, \`.name\`, etc. on it.
Every other \`AgentBot\` association (\`agent_bot_inboxes\`,
\`messages\`, \`assigned_conversations\`) had a \`dependent:\` option —
\`platform_app_permissibles\` was the only one missing it. There was
also an N+1 query: the index fired a separate SQL query per permissible
to load each bot.
## What changed
**1. Model — prevent orphans at deletion time**
\`\`\`ruby
has_many :platform_app_permissibles, as: :permissible, dependent:
:destroy
\`\`\`
**2. Controller — eager-load to eliminate N+1**
\`\`\`ruby
@resources = @platform_app.platform_app_permissibles
.where(permissible_type: 'AgentBot')
.includes(:permissible)
\`\`\`
**3. Jbuilder — defensive nil guard for pre-existing orphans**
\`\`\`ruby
bot = resource.permissible
next if bot.nil?
json.partial! '...', resource: bot
\`\`\`
## Trade-offs considered
| Option | Decision |
|---|---|
| Rescue \`NoMethodError\` in jbuilder | Hides the failure rather than
fixing it. Rejected. |
| Only add the nil guard, skip the model fix | Leaves the data integrity
gap open — future deletions continue creating orphans. Rejected. |
| Both layers (chosen) | Model fix prevents new orphans; nil guard is
defence-in-depth for any orphans that survived before deployment. |
| \`dependent: :nullify\` | Doesn't apply — a nullified permissible
would still cause the same nil dereference. Rejected. |
## How to reproduce
1. Create an AgentBot via the Platform API
2. Delete the AgentBot via any path (admin UI, API, or direct model
call)
3. Call \`GET /platform/api/v1/agent_bots\` with a Platform App token
4. Observe 500
After this fix, the endpoint returns 200 with an empty array.
Co-authored-by: Ramalau Debeila <rdebeila@datacentrix.co.za>
Standardizes the contact company import/filter/automation contract on
`company_name`.
Closes#14096
Revives #9907
## Why
Contact company is read across the current CRM/contact UI from
`additional_attributes['company_name']`, but CSV import and a few
backend filter/automation paths still used the older `company` key. That
meant imported company values could be saved in a place the dashboard,
sorting, filters, and automation conditions did not consistently read
from.
Based on the production data check, the legacy `company` automation
configuration is effectively dead: the affected account did not have
contacts populated with `additional_attributes['company']`. So this PR
intentionally avoids adding long-term fallback behavior and uses
`company_name` as the single key going forward.
## What changed
- Contact CSV import now writes only `company_name` into
`additional_attributes['company_name']`.
- The example contact import CSV now uses the `company_name` header.
- Contact company sorting/filter config now uses `company_name`.
- Automation condition config now uses `company_name`.
- Existing standard automation conditions with `attribute_key:
'company'` are migrated to `company_name`.
- Existing saved contact filters with standard `attribute_key:
'company'` are migrated to `company_name`.
- Custom attributes named `company` are preserved and are not rewritten
by the migration.
## How to test
- Import a contact CSV with a `company_name` column and confirm the
Contact Company field is populated.
- Sort contacts by Company and confirm imported contacts are ordered
correctly.
- Create/edit an automation with Company as a condition and confirm it
saves with `company_name`.
- Verify existing saved contact filters and automation rules using the
old standard `company` key are migrated to `company_name`.
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Sojan Jose <sojan@pepalo.com>
# Pull Request Template
## Description
Captain currently cannot discern today, tomorrow etc. This PR adds
datetime awareness to the system prompt
Fixes:
https://linear.app/chatwoot/issue/AI-148/captain-should-be-aware-of-datetime
## 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.
Locally
<img width="696" height="247" alt="CleanShot 2026-04-27 at 14 47 47"
src="https://github.com/user-attachments/assets/6a73a8d9-f48e-46bb-a306-7b9a28a5fa9c"
/>
## 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
With this change, the form will start displaying a required field. While
validation is already enforced in APIs and other areas, the super_admin
console—being autogenerated—will throw an error since this requirement
isn’t explicitly defined in the model.
<img width="670" alt="Screenshot 2025-01-30 at 2 12 43 PM"
src="https://github.com/user-attachments/assets/e0ab3ace-3649-4ef2-bc94-8d4d80453dd1"
/>
Fixes https://github.com/chatwoot/chatwoot/issues/10754
---------
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Sony Mathew <ynos1234@gmail.com>
Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
Co-authored-by: Sony Mathew <sony@chatwoot.com>
Loads Rails locale-specific pluralization rules so languages with an
`other`-only plural model can safely use Crowdin exports without
maintaining duplicate `one` keys.
## Closes
None
## Why
Crowdin exports Rails YAML pluralized strings using each target
language's plural categories. These categories come from Unicode CLDR
and represent grammatical forms, not a literal "number is 1" bucket.
Some languages need separate forms such as `one` and `other`, but
languages like Japanese, Korean, Indonesian, Thai, Vietnamese, and
Chinese use the same form for `1`, `2`, `5`, and larger counts in these
strings. For those locales, CLDR correctly models the plural category as
`other` only.
Before this change, Chatwoot still relied on Rails' default
English-style plural behavior for these locales. That meant a valid
Crowdin export containing only `other` could fail at runtime when Rails
received `count: 1` and looked for a missing `one` branch.
Keeping duplicate `one` keys would only fight Crowdin on every
translation sync. The runtime should instead follow the locale's plural
rules.
## What changed
- Added `rails-i18n` and enabled only its pluralization module.
- Added explicit `other`-only plural rules for Chatwoot's underscore
Chinese locale aliases, `zh_CN` and `zh_TW`.
- Removed redundant `one` keys from the affected Devise and `time_units`
translations.
## Validation
- Ran a Rails runner check across `id`, `ja`, `ko`, `ms`, `th`, `vi`,
`zh_CN`, and `zh_TW` to verify `errors.messages.not_saved` and
`time_units.days` resolve with only `other` for `count: 1`.
- Ran YAML parse validation for all edited locale files.
- Ran `bundle exec rubocop Gemfile config/application.rb
config/initializers/i18n_pluralization.rb`.
# Pull Request Template
## Description
This PR fixes an issue where signature images (with
`?cw_image_height=...`) render at their original large size in the email
bubble.
### Cause
Renderer output:
```html
<img src="..." height="24px" width="auto" />
```
Email UI and clients (Gmail, Outlook) apply CSS like:
`img { max-width: 100%; height: auto; }`
This overrides `height="24px"`.
Other channels work because they use inline styles (`style="height:
24px;"`).
### Solution
Use inline style instead:
```html
<img src="..." style="height: 24px;" />
```
### Why backend fix
* Fixes root cause and aligns Ruby + JS renderers
* Works in both Chatwoot UI and recipient inboxes
* Covers all email-rendered content
* Minimal change
Fixes
https://linear.app/chatwoot/issue/CW-6948/email-signature-image-renders-oversized-in-chatwoot-ui
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
#### Screenshots
**Before**
<img width="1637" height="377" alt="image"
src="https://github.com/user-attachments/assets/0477f6fb-3b95-4fc3-9ea8-f59b71e27f47"
/>
**After**
<img width="1637" height="289" alt="image"
src="https://github.com/user-attachments/assets/de5ea4c1-8452-4c5f-aeb1-e1e11e0fe7d5"
/>
## 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
- [ ] Any dependent changes have been merged and published in downstream
modules
# Pull Request Template
## Description
When creating a help center article, typing a title and navigating into
the content auto-creates the article and switches the route
(`/articles/new` → `/articles/.../edit/:slug`). During this transition,
focus was jumping back to the title, interrupting editing.
This happened because `ArticleEditor` always autofocuses the title. On
route change, the component remounts and re-triggers focus. Now, after
auto-create, focus stays in the body as expected.
Fixes
https://linear.app/chatwoot/issue/CW-6951/issue-with-the-cursor-position-on-the-help-center-article-when
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Screencast**
https://github.com/user-attachments/assets/dac3f7c6-08c4-4df2-afb0-7731ee76424b
## 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
# Pull Request Template
## Description
- Fetch main content only from Firecrawl, exclude some tags to remove
boilerplate
- Prompt changes for FAQ generation
## Type of change
Please delete options that are not relevant.
- [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.
tested locally
## 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
Fixes
https://linear.app/chatwoot/issue/CW-6950/support-bulk-actions-for-publish-archive-move-to-draft-delete-in
How to test
1. Go to Help Center → Articles
2. Select articles using checkboxes → bulk bar appears
3. Click Publish/Draft/Archive → articles update, list refreshes
4. Click Delete → confirmation dialog → articles removed
5. Click Translate (requires Captain enabled) → select locale + category
→ translation starts
6. Try translating to a locale that already has translations → warning
with links to existing articles → "Overwrite and translate" proceeds
8. Single article: click three-dot menu → Translate → same dialog opens
for that article
https://github.com/user-attachments/assets/7c76495e-f89e-4456-92bd-a6639a9992f4
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the ability to translate help center articles to other languages using Captain's LLM infrastructure. Translated articles are created as drafts linked to the source article.
Fixes
https://linear.app/chatwoot/issue/CW-6901/translate-article-to-another-language
**How to test**
1. Navigate to Help Center → Articles for a portal with multiple locales
2. Click the three-dot menu on any article → "Translate"
3. Select a target language and category → click Translate
4. Switch to the target locale — the translated article appears as a
draft
5. Try translating the same article again — a warning shows the existing
translation with a link to open it in a new tab
6. Click "Overwrite and translate" to replace the existing translation
https://github.com/user-attachments/assets/1d2e991b-f0ac-403a-bcc1-2181b5731ea4
This routes external downloads used by avatar sync through SafeFetch. It closes the SSRF exposure from raw Down.download paths, preserves provider-specific auth and header flows, and adds regression coverage
for blocked internal URLs plus authenticated downloads.
Fixes # (issue): [CW-6931](https://linear.app/chatwoot/issue/CW-6931/avatarwidget-url-ssrf-downdownload-unprotected-unauth)
## Description
Spreadsheet applications such as Microsoft Excel do not auto-detect
UTF-8 encoding when opening CSV files. This causes non-ASCII characters
(Arabic, Japanese, Chinese, Korean, etc.) to appear garbled in the
exported contacts CSV.
This PR prepends the UTF-8 Byte Order Mark (`EF BB BF`) to the CSV
output in `Account::ContactsExportJob`, which signals to spreadsheet
applications that the file is UTF-8 encoded.
Fixes: #13998
## Description
`DataImportJob#csv_reader` reads CSV data with `force_encoding('UTF-8')`
but does not strip the UTF-8 Byte Order Mark (`EF BB BF`). If a CSV file
containing a BOM is imported, the first header key is prefixed with
`\uFEFF`, which causes key mismatches in `DataImport::ContactManager`
when the first column is one of the recognized keys (`:email`,
`:identifier`, `:phone_number`, `:name`).
This was identified during review of #14123 (see #14124 for the tracking
issue).
Fixes#14124
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Added a new fixture (`spec/fixtures/data_import/with_bom.csv`)
containing a UTF-8 BOM followed by valid contact data.
- Added a new spec (`will strip UTF-8 BOM and import contacts
correctly`) that imports the BOM fixture and verifies that `name`,
`email`, and `phone_number` are all correctly parsed.
- All existing examples in `spec/jobs/data_import_job_spec.rb` continue
to pass.
## 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
- [x] 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
# Pull Request Template
## Description
Document auto-sync job pipeline without wiring to controllers or FE
Fixes # (issue)
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## 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.
specs and locally
## 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
## Summary
Outbound email messages never populate
`content_attributes.email.subject`. The subject lives only on the parent
conversation's `additional_attributes.mail_subject`. The search index
doc built by `Messages::SearchDataPresenter` pulls from the
message-level field only, so outbound email subjects are unsearchable
for accounts on the advanced_search (Elasticsearch) path.
This change makes the presenter fall back to
`conversation.additional_attributes.mail_subject` when the message-level
subject is blank. Inbound email messages keep their existing behavior
(message-level subject takes precedence).
Closes https://linear.app/chatwoot/issue/CW-6877
## What changes
- `Messages::SearchDataPresenter#content_attributes_data` now falls back
to the conversation's `mail_subject` when the message-level subject is
blank.
- Added specs covering the fallback, precedence, and the neither-set
case.
## What does not change
- No schema changes, no migrations, no backfill of existing OpenSearch
documents.
- Searchkick's `after_commit :reindex_for_search` on `Message` will pick
up the new field for all newly created or updated messages via the
existing indexing path.
- Postgres search path (free accounts, fallback) is untouched. Broader
subject search for those users is a separate follow-up.