LeadMail server now supports skip_queue=true to send synchronously and
return the actual outbound Message-ID (which Zeptomail rewrites for VERP
bounce tracking). This is the critical piece for Chatwoot's reply
threading: Email::SendOnEmailService reads Mail::Message#message_id after
deliver and stores it as the message's source_id. When the recipient
replies with In-Reply-To: <that-id>, Chatwoot's threading lookup finds the
matching source_id and threads into the same conversation.
Changes:
- Send skip_queue: true on every request. Verification still runs (now
inline) but is cache-hit fast for any recipient we've previously sent to
— Chatwoot replies are nearly always to such addresses (came in via
IMAP, contact created on first inbound). User UX unaffected because all
send paths run via Sidekiq.
- After a successful response, override message.message_id with
data.message_id from the response. This is what Email::SendOnEmailService
then writes to source_id.
- Parse LeadMail's structured error response (422/502 with error.code +
error.message) so failures surface readable diagnostics in Sentry rather
than raw HTML/JSON bodies.
Previous extract_from used message[:from].display_names.first. That
method exists on Mail::Field but its behavior across Mail gem versions
is inconsistent — it sometimes returns a string, sometimes blank. The
canonical Mail gem API for getting the display name of the first
address in a multi-address field is addrs.first.display_name, which
returns the parsed display name reliably.
Without a working display name extraction, our LeadMail payload only
included {email: ...} without the name, so emails arrived showing only
the bare 'dev@leadmagnet.fi' instead of 'Niklas Pull from Lead Magnet
Dev <dev@leadmagnet.fi>'.
Also include the from payload (email + name) in the success log line so
future from-header issues are immediately visible without needing to
check LeadMail's dashboard.
ConversationReplyMailer#email_reply guards delivery with:
return unless smtp_config_set_or_development? || email_smtp_enabled?
|| (email_imap_enabled? && email_oauth_enabled?)
email_oauth_enabled? in the upstream helper only recognises microsoft? and
google? providers. For our new microsoft_shared provider it returns false,
so the guard fails:
- smtp_config_set_or_development? false (production, no SMTP_ADDRESS)
- email_smtp_enabled? false (we never set channel.smtp_enabled)
- email_imap_enabled? true (we set imap_enabled in callback)
- email_oauth_enabled? false (channel.microsoft? is false)
email_reply early-returns without calling mail(). ActionMailer ends up
with no Mail::Message, deliver_now returns nil, and the next line in
Email::SendOnEmailService raises 'undefined method message_id for nil'.
Override email_oauth_enabled? in the overlay to return true for
microsoft_shared channels.
Production diagnostic logged:
scopes='email IMAP.AccessAsUser.All Mail.ReadWrite.Shared Mail.Send.Shared
openid profile User.Read'
aud='00000003-0000-0000-c000-000000000000'
The aud is Microsoft Graph's well-known app ID. Outlook's IMAP server only
accepts tokens with aud=https://outlook.office.com — Graph-aud tokens get
rejected with NoResponseError 'AUTHENTICATE failed.' even when the granted
scope list looks right.
Cause: a single Microsoft v2.0 OAuth response can only have one audience.
Mixing Graph-resource scopes (User.Read) with Outlook-resource scopes
(https://outlook.office.com/...) in one request makes Microsoft pick a
single audience for the token. With User.Read present, it picks Graph.
Fix: imap_smtp and imap_leadmail now request only:
- OIDC scopes (offline_access, openid, profile, email) — universal, don't
pin audience and still produce an id_token for oauth_user_email
- Outlook-resource scopes (https://outlook.office.com/IMAP.AccessAsUser.All
and SMTP.Send for imap_smtp)
graph_full keeps its Graph scope set unchanged.
User-side caveat: previously-consented Mail.*.Shared scopes from earlier
graph_full testing are still in Niklas's consent cache and Microsoft will
reflect them back in scp on any new OAuth, potentially re-tainting the
audience choice. Niklas needs to revoke the LeadChat app from
https://myaccount.microsoft.com -> Privacy -> Apps and services to fully
clear those before retrying.
Net::IMAP raises NoResponseError ('AUTHENTICATE failed.') without exposing
the underlying Microsoft challenge response. Adding the JWT payload's scp,
aud, and user-email claims to the failure log lets us distinguish the
common causes:
- granted_scopes missing IMAP.AccessAsUser.All -> Microsoft silently
downgraded the scope at consent time (Azure app config / consent screen
issue)
- aud not matching outlook.office.com -> token issued for wrong resource
(rare; would mean OAuth flow misconfigured)
- scopes correct -> tenant-level policy (OAuth2ClientProfileEnabled,
Conditional Access blocking IMAP, etc.)
Read-only inspection of our own token; no change to the auth flow itself.
Faraday treats a leading slash on the request path as absolute from the
host root, replacing the path component of the connection URL. With
LEADMAIL_API_URL=https://mail.leadmagnet.dev/api/v1 and post('/emails/send'),
the resulting URL was https://mail.leadmagnet.dev/emails/send — missing
/api/v1, getting an HTML 404 from the LeadMail dispatcher.
Drop the leading slash so the path is appended relative to the prefix:
https://mail.leadmagnet.dev/api/v1/emails/send
Also add Accept: application/json to mirror the documented curl example
and avoid any content-negotiation surprises (HTML error pages vs JSON
error responses).
Faraday's default request pipeline doesn't auto-serialize a Hash body
when Content-Type is application/json — the :url_encoded middleware only
kicks in for application/x-www-form-urlencoded. With our json content-
type, the Hash was passed through unchanged to Net::HTTP, which then
called body.bytesize for Content-Length and crashed with:
NoMethodError (undefined method 'bytesize' for an instance of Hash)
Bug was dormant until the previous commit (a00bcb204) wired
ActionMailer::Base.delivery_method = :leadmail correctly. Before that
fix, delivery_method was silently :smtp and this code never ran.
Serialize the payload explicitly with .to_json. Content-Type header is
already set on the Faraday client.
Both LeadMail-related touchpoints now logged in section B (Direct edits
to OSS files). Notes the LeadChat-owned vs upstream distinction so
future merges know which need defense and which are ours to evolve.
The ActionMailer railtie's on_load hook applies config.action_mailer.*
settings to ActionMailer::Base when ActionMailer::Base is first
referenced. In our initializer load order, devise.rb runs before
mailer.rb (alphabetical) and triggers ActionMailer::Base loading via
Devise::Mailer. By the time mailer.rb runs, the on_load hook has
already fired with the default :smtp delivery method, and subsequent
writes to config.action_mailer.delivery_method are silently ignored.
Result: production transactional emails (Devise confirmation, password
reset, member invitations) were being sent via Mail's default :smtp
delivery method to localhost:25, raising Errno::ECONNREFUSED on every
attempt — silently in deliver_later. LeadMail was never actually used
despite LEADMAIL_API_TOKEN being set.
Fix is to set ActionMailer::Base.delivery_method (and the matching
*_settings) directly in addition to config.action_mailer.*. Same pattern
applied to the SMTP and sendmail fallback branches for consistency.
Verified locally: rails runner now reports delivery_method=:leadmail
when LEADMAIL_API_TOKEN is set.
Captures the test sequence to run on staging when resuming this work,
including the critical regression check that the LeadmailDelivery payload
patch hasn't broken existing transactional emails.
Microsoft 365 Graph API requires the shared mailbox itself to be licensed
(€5/mo each), which doesn't scale across clients. Phase 1's graph_full
transport stays for licensed mailboxes; this commit adds two free-of-extra-
licensing transports alongside it. Admin picks per channel.
Three transports now:
- graph_full -> Graph send + (Phase 2) Graph webhook receive
- imap_smtp -> M365 xoauth2 SMTP send + xoauth2 IMAP receive
- imap_leadmail -> LeadMail API send + xoauth2 IMAP receive
Backend:
- MicrosoftSharedConcern: SCOPES_BY_TRANSPORT map (Graph and Outlook scopes
are different audiences — separate scope set per transport).
- AuthorizationsController accepts and validates a transport param,
uses transport-specific scopes, encodes transport into signed state.
- CallbacksController reads transport from state, dispatches to the right
access verifier (Graph for graph_full, new ImapAccessVerifier for imap_*),
stores transport in provider_config, sets imap_login=upn for IMAP transports.
- New Microsoft::Shared::ImapAccessVerifier opens a Net::IMAP connection,
authenticates XOAUTH2 with the SHARED mailbox UPN as username and the
delegate's access token, runs CAPABILITY, closes. Confirms the SASL pattern
works for this delegate before creating the channel.
Send routing (overlay on ConversationReplyMailerHelper):
- graph_full -> :microsoft_shared delivery (existing Graph send service)
- imap_smtp -> :smtp with xoauth2, delegate UPN as SASL user, channel.email
as From: header (Microsoft routes via Send As permission)
- imap_leadmail -> :leadmail delivery (existing LeadmailDelivery + the small
extension below to forward threading headers)
Receive routing:
- New Custom::Leadchat::Inboxes::FetchImapEmailsJobExtension overlay routes
microsoft_shared channels with imap_* transport to the existing
Imap::MicrosoftFetchEmailService. That service already does xoauth2 IMAP
with channel.imap_login as the SASL user; we set imap_login=upn at channel
creation so the existing fetch logic reads the shared mailbox's INBOX with
no service-level changes.
LeadmailDelivery: build_payload now forwards in_reply_to, references, and
message_id so conversation reply threading works on the imap_leadmail path.
LeadMail server should preserve these as RFC822 headers on the outbound MIME.
Frontend:
- MicrosoftShared.vue gets a transport radio with 3 options + per-option help.
- microsoftSharedClient already passes payload through (no change).
- en inboxMgmt.json: TRANSPORT.{IMAP_SMTP,IMAP_LEADMAIL,GRAPH}.{TITLE,HELP}.
Token management: existing Microsoft::RefreshOauthTokenService works for
all three transports — refresh tokens come back with the same scopes the
original grant had.
Phase 2 (Graph webhook receive for graph_full) remains pending; only needed
if you ship licensed-mailbox channels.
Without prompt=consent Microsoft may silently reuse a prior user consent
that doesn't include our new shared-mailbox scopes (Mail.ReadWrite.Shared,
Mail.Send.Shared). The result is a successful OAuth round-trip but an
access token missing the scopes, leading to 403 on the Graph access check
in the callback. Mirror the existing microsoft provider which sets the
same flag.
Two changes to debug the 'You don't have access' rejection in the
OAuth callback flow:
- Use literal '@' in the /users/{upn} path. Microsoft Graph accepts both
forms in most cases but the URL-encoded %40 form has been seen to fail
with 404 on some endpoints. Literal @ is more reliable.
- Log the actual HTTP status, response body (first 500 chars), and the
granted scopes from the access token's 'scp' claim. Makes future
debugging of permission/scope issues immediate from production.log
instead of a binary 'access denied' message.
Token decoding uses JWT.decode with verify=false — this is our own
token so verifying signature isn't useful here, we just inspect the scp
claim to confirm what scopes Microsoft actually granted.
Vue-i18n treats @ as the marker for linked messages (@:other.key).
A bare @ in a translation triggers 'Invalid linked format' SyntaxError
when the message compiler runs. Escape with {'@'} so vue-i18n outputs
the literal character at render time.
Affected strings: MICROSOFT_SHARED.DESCRIPTION and UPN_PLACEHOLDER, both
contained the example address sales@yourcompany.com which broke when
the user clicked the new Microsoft Shared Inbox tile.
Initializers run during Rails boot before Zeitwerk finalises autoload, so
referencing MicrosoftSharedDelivery during config raises NameError on
'rake assets:precompile' and other early-boot rake tasks. Mirror the
existing LeadmailDelivery fix (commit 2d54224d0) by requiring the file
explicitly.
Also drop the unused 'config.action_mailer.delivery_methods ||= {}' line —
delivery methods are registered via add_delivery_method, not that hash.
Adds a new "microsoft_shared" Email Channel provider that uses Microsoft
Graph API end-to-end for sending. Bypasses xoauth2 SMTP, so it works on
tenants with Security Defaults enabled and survives the April 2026 SMTP
Basic Auth retirement.
Flow:
1. Admin picks "Microsoft Shared Inbox" in the Add Inbox wizard
2. Enters the shared mailbox UPN (e.g. sales@company.com)
3. Single OAuth round-trip as a delegate user; UPN is carried in a signed
state parameter so the callback knows which mailbox to bind
4. Callback verifies the OAuth user has access to the UPN via
GET /users/{upn}/mailFolders/inbox before creating the channel
5. Replies route through a custom :microsoft_shared ActionMailer delivery
method that POSTs base64-encoded MIME to /users/{upn}/sendMail
Channel modifications use the custom/ overlay (Custom::Leadchat::*).
Direct OSS edits are wrapped in '# === LeadChat: microsoft_shared ===' markers.
Phase 2 (Graph webhook receive) and Phase 3 (re-auth, subscription
failure surfacing, channel-destroy cleanup) follow.
Azure app prerequisites (one-time, manual): add delegated permissions
Mail.Send.Shared, Mail.ReadWrite.Shared, User.Read, offline_access; grant
admin consent. No new app registration required.
See LEADCHAT.md for the full customization index.
Foundation for LeadChat customizations using Chatwoot's built-in custom/
extension mechanism (ChatwootApp.extensions). Adds:
- LEADCHAT.md: index of every customization touchpoint, updated as work lands
- custom/: overlay tree where Custom::Leadchat::* modules live, autoloaded
alongside enterprise/ via a parallel block in config/application.rb
- Channel::Email#microsoft_shared? predicate as the first overlay (used by
the upcoming Microsoft 365 Shared Inbox provider)
OSS-file additions wrapped in '# === LeadChat: ===' markers so future
upstream-merge conflicts are mechanical.
Avoid port conflicts with Gitea (or other services) when both run on same server.
Container port remains 6379, host binding changed to 6380.
Updated REDIS_URL in .env.example. Update your local .env and server .env accordingly.
- Switch from official chatwoot image to custom build (includes LeadMail, translations, branding)
- Change Rails port from 3001 to avoid conflict with Gitea on 3000
- Use environment variable for POSTGRES_PASSWORD instead of empty default
Replace SMTP with LeadMail API service for sending system transactional emails (password resets, invitations, notifications). LeadMail provides built-in email verification via Verifalia and async queue-based sending.
Configuration:
- Set LEADMAIL_API_TOKEN and LEADMAIL_API_URL in .env
- Falls back to SMTP if LeadMail token not present
- Works via custom ActionMailer delivery method (stable across upstream merges)
Includes:
- LeadmailDelivery class with full spec coverage
- Support for multipart messages, attachments, CC/BCC
- Error handling and logging with message tracking
- Documentation in docs/LEADMAIL_INTEGRATION.md
Store all 44 Finnish translation files in custom-logo/translations/ and update bin/rebrand to restore them after upstream merges. Ensures Finnish translations persist across future git pulls from Chatwoot upstream.
Introduce a `Last Responding Agent` options to assign_agents action in
automations to cover the following use cases.
- Assign conversations to first responding agent : ( automation message
created at , if assignee is nil, assign last responding agent )
- Ensure conversations are not resolved with out an assignee : (
automation conversation resolved at : if assignee is nil, assign last
responding agent )
and potential other cases.
fixes: #1592
## Summary
The `assignee_name` and `user_name` variables are swapped in the Chinese
(zh/zh_CN) locale files for conversation assignment activity messages,
causing the rendered text to show the wrong person as the assignee.
### Before (incorrect)
| Template | English (correct) | Chinese (incorrect) |
|---|---|---|
| `assignee.assigned` | Assigned to **AgentA** by **Admin** | 由
**AgentA** 分配给 **Admin** |
| `team.assigned` | Assigned to **TeamX** by **Admin** | 由 **TeamX** 分配给
**Admin** |
| `team.assigned_with_assignee` | Assigned to **AgentA** via **TeamX**
by **Admin** | 由 **AgentA** 分配给 **TeamX** 团队的 **Admin** |
The Chinese text reads as if the conversation was assigned **to Admin**
(the API caller), when it was actually assigned **to AgentA**.
### After (correct)
| Template | Chinese (fixed) |
|---|---|
| `assignee.assigned` | 由 **Admin** 分配给 **AgentA** |
| `team.assigned` | 由 **Admin** 分配给 **TeamX** |
| `team.assigned_with_assignee` | 由 **Admin** 通过 **TeamX** 团队分配给
**AgentA** |
Now correctly matches the English template semantics.
## Files Changed
- `config/locales/zh_CN.yml` — 3 lines
- `config/locales/zh.yml` — 3 lines
## How to Verify
1. Set locale to `zh_CN`
2. Have an admin assign a conversation to an agent
3. Check the activity message in the conversation — the assignee name
should appear after "分配给", not before it
Co-authored-by: Sojan Jose <sojan@pepalo.com>
This updates macros and automations so agents can explicitly remove
assigned agents or teams, while keeping the existing `Assign -> None`
flow working for backward compatibility.
Fixes: #7551Closes: #7551
## Why
The original macro change exposed unassignment only through `Assign ->
None`, which made macros behave differently from automations and left
the explicit remove actions inconsistent across the product. This keeps
the lower-risk compatibility path and adds the explicit remove actions
requested in review.
## What this change does
- Adds `Remove Assigned Agent` and `Remove Assigned Team` as explicit
actions in macros.
- Adds the same explicit remove actions in automations.
- Keeps `Assign Agent -> None` and `Assign Team -> None` working for
existing behavior and stored payloads.
- Preserves backward compatibility for existing macro and automation
execution payloads.
- Downmerges the latest `develop` and resolves the conflicts while
keeping both the new remove actions and current `develop` behavior.
## Validation
- Verified both remove actions are available and selectable in the macro
editor.
- Verified both remove actions are available and selectable in the
automation builder.
- Applied a disposable macro with `Remove Assigned Agent` and `Remove
Assigned Team` on a real conversation and confirmed both fields were
cleared.
- Applied a disposable macro with `Assign Agent -> None` and `Assign
Team -> None` on a real conversation and confirmed both fields were
still cleared.
# Pull Request Template
## Description
This PR adds support for resizing the reply editor up to nearly half the
screen height. It also deprecates the old modal-based pop-out reply box,
clicking the same button now expands the editor inline. Users can adjust
the height using the slider or the expand button.
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
### Loom video
https://www.loom.com/share/be27e1c06d19475ab404289710b3b0da
## 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
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Pranav <pranav@chatwoot.com>
Update BulkSelectBar to compute selection state (indeterminate/all) from
visible item IDs and only toggle selection for visible items. Preserve
existing selection for off-screen items when toggling, and guard against
empty visibility. Add detection/rendering for an optional
secondary-actions slot and adjust layout/divider. Also fix
ContactsBulkActionBar selection logic to determine "all selected" by
verifying every visible ID is in the selection. These changes ensure
correct select-all behavior with filtered/visible lists and support
additional UI actions.
https://github.com/user-attachments/assets/d06b78d1-a64a-4c0c-a82a-f870140236c7
# Pull Request Template
## Description
Please include a summary of the change and issue(s) fixed. Also, mention
relevant motivation, context, and any dependencies that this change
requires.
Fixes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] 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?
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.
## 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
---------
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
RubyLLM bundles a static models.json that doesn't know about models
released after the gem was published. Self-hosted users configuring
newer models hit ModelNotFoundError.
Added a rake task that refreshes the registry from models.dev and saves
to disk. ~~Called during Docker image build so every deploy gets fresh
model data. Falls back silently to the bundled registry if models.dev is
unreachable.~~
Commit the models.json file to code so it is available across
deployments.
---------
Co-authored-by: Sojan Jose <sojan@pepalo.com>
# Pull Request Template
## Description
Add migrations for document auto-sync
Fixes # (issue)
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been 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
## Summary
This PR enables the **Participating** conversation view in the main
sidebar and keeps the behavior aligned with existing conversation views.
## What changed
- Added **Participating** under Conversations in the new sidebar.
- Added a guard in conversation realtime `addConversation` flow so
generic `conversation.created` events are not injected while the user is
on Participating view.
- Added participating route mapping in conversation-list redirect helper
so list redirects resolve correctly to `/participating/conversations`.
## Scope notes
- Kept changes minimal and consistent with current `develop` behavior.
- No additional update-event filtering was added beyond what existing
views already do.
---------
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
## Linear ticket
https://linear.app/chatwoot/issue/CW-6839/blocked-contact-can-still-send-messages-to-whatsapp-inbox
## Description
Drop WhatsApp incoming messages from blocked contacts
## Type of change
- [ ] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
- Incoming messages for blocked contacts
## 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
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
# Pull Request Template
## Description
This PR adds inline editing support for contact name, phone number,
email, and company fields in the conversation contact sidebar
## Type of change
- [x] New feature (non-breaking change which adds functionality)
## How Has This Been Tested?
**Screencast**
https://github.com/user-attachments/assets/e9f8e37d-145b-4736-b27a-eb9ea66847bd
## 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
---------
Co-authored-by: Pranav <pranav@chatwoot.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
# Pull Request Template
## Description
### Description
This PR fixes an issue where the editor would reset content and move the
cursor while typing. The issue was caused by a dual debounce setup
(400ms + 2500ms) that saved content and then overwrote local state with
stale API responses while the user was still typing.
### What changed
* Editor now uses local state (`localTitle`, `localContent`) as the
source of truth while editing
* Vuex store is only used on initial load or navigation
* Replaced dual debounce with a single 500ms debounce (fewer API calls)
* `UPDATE_ARTICLE` now merges updates instead of replacing the article
* Prevents status changes from wiping unsaved content
* Removed `updateAsync` for a simpler update flow
### How it works
User types
→ local ref updates immediately (editor reads from this)
→ 500ms debounce triggers
→ dispatches `articles/update`
→ API persists the change
→ on success: store merges the response (used by other components)
→ editor remains unaffected (continues using local state)
Fixes
https://linear.app/chatwoot/issue/CW-6727/better-syncing-of-content-the-editor-randomly-updates-the-content
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
1. Open any Help Center article for editing
2. Type continuously for a few seconds — content should not reset or
jump
3. Change article status (publish/archive/draft) while editing — content
should remain intact
4. Test on a slow network (use DevTools throttling) — typing should
remain smooth
## 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
- [ ] Any dependent changes have been merged and published in downstream
modules
---------
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
AI-generated summaries now respect the account's language setting.
Previously, summaries were always returned in English regardless of the
user's configured language, making section headings like "Customer
Intent" and "Action Items" appear in English even for non-English
accounts.
Previous behavior:
<img width="1336" height="790" alt="image"
src="https://github.com/user-attachments/assets/5df8b78b-1218-438d-9578-a806b5cb94ac"
/>
Current Behavior:
<img width="1253" height="372" alt="image"
src="https://github.com/user-attachments/assets/ae932c97-06da-4baf-9f77-9719bc9162e8"
/>
## What changed
- Added explicit account locale to the AI system prompt in
`Captain::SummaryService`
- Updated the summary prompt template to instruct the model to translate
section headings
## How to test
1. Configure an account with a non-English language (e.g., Portuguese)
2. Open a conversation with messages
3. Use the Copilot "Summarize" feature
4. Verify that section headings ("Customer Intent", "Conversation
Summary", etc.) appear in the account's language
---------
Co-authored-by: Aakash Bakhle <48802744+aakashb95@users.noreply.github.com>