From 1afcd36deefe9a928281905e46c5dfffcd15f10d Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Mon, 1 Jun 2026 13:58:57 +0530 Subject: [PATCH 1/9] fix(contacts): align contact export permissions (#14601) Allows contact managers to export and import contacts from the Contacts page while keeping plain agents blocked. The contacts action menu now mirrors backend permissions for both export and import. ## Closes - https://linear.app/chatwoot/issue/CW-4438/contact-export-is-broken ## What changed - Allows Enterprise custom roles with `contact_manage` to pass `ContactPolicy#export?` and `ContactPolicy#import?`. - Shows Export and Import to admins and contact managers only. - Adds Enterprise policy coverage for contact export and import. ## Screenshots Admin: Export and Import are available. Admin contact actions with Export
and Import visible Contact manager: Export and Import are available. Contact manager contact actions
with Export and Import visible Regular agent: Export and Import are hidden. Regular agent contact actions with
Export and Import hidden ## How to test - Sign in as an administrator and open Contacts; the action menu shows Export and Import. - Sign in as a custom-role user with `contact_manage`; the action menu shows Export and Import. - Sign in as a plain agent; Export and Import are not available and both APIs remain unauthorized. --- .../components/ContactMoreActions.vue | 44 ++++++++++++------- app/policies/contact_policy.rb | 2 + .../app/policies/enterprise/contact_policy.rb | 9 ++++ .../policies/contact_policy_spec.rb | 26 +++++++++++ 4 files changed, 66 insertions(+), 15 deletions(-) create mode 100644 enterprise/app/policies/enterprise/contact_policy.rb create mode 100644 spec/enterprise/policies/contact_policy_spec.rb diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue index d5932535c..9deaa84f2 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsHeader/components/ContactMoreActions.vue @@ -1,34 +1,48 @@ + + diff --git a/spec/builders/messages/facebook/message_builder_spec.rb b/spec/builders/messages/facebook/message_builder_spec.rb index f3244bc21..afa9d5f34 100644 --- a/spec/builders/messages/facebook/message_builder_spec.rb +++ b/spec/builders/messages/facebook/message_builder_spec.rb @@ -140,6 +140,45 @@ describe Messages::Facebook::MessageBuilder do end end + [ + { + source_id: 'm_fallback_test', + attachment: { type: 'fallback', title: 'Shared link', url: 'https://www.example.com/shared-link' }, + title: 'Shared link', + url: 'https://www.example.com/shared-link' + }, + { + source_id: 'm_share_test', + attachment: { type: 'share', title: 'Shared Facebook post', payload: { url: 'https://www.facebook.com/example/posts/123' } }, + title: 'Shared Facebook post', + url: 'https://www.facebook.com/example/posts/123' + } + ].each do |message_data| + it "stores #{message_data[:attachment][:type]} attachments as fallback links" do + allow(Koala::Facebook::API).to receive(:new).and_return(fb_object) + allow(fb_object).to receive(:get_object).and_return( + { first_name: 'Jane', last_name: 'Dae', profile_pic: 'https://chatwoot-assets.local/sample.png' }.with_indifferent_access + ) + expect(Down).not_to receive(:download) + + message_object = { + messaging: { + sender: { id: '3383290475046708' }, + recipient: { id: facebook_channel.page_id }, + message: { mid: message_data[:source_id], attachments: [message_data[:attachment]] } + } + }.to_json + message = Integrations::Facebook::MessageParser.new(message_object) + + described_class.new(message, facebook_channel.inbox).perform + + attachment = facebook_channel.inbox.messages.find_by(source_id: message_data[:source_id]).attachments.first + expect(attachment.file_type).to eq('fallback') + expect(attachment.fallback_title).to eq(message_data[:title]) + expect(attachment.external_url).to eq(message_data[:url]) + end + end + context 'when lock to single conversation' do subject(:mocked_message_builder) do described_class.new(mocked_incoming_fb_text_message, facebook_channel.inbox).perform From 1f6203d5584304daecd92152b689b06103df80c0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 2 Jun 2026 13:24:46 +0530 Subject: [PATCH 6/9] feat(onboarding): honor return_to hint in TikTok OAuth callback (#14569) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When connecting a TikTok inbox during onboarding, the OAuth flow used to drop users in inbox settings, breaking onboarding. The OAuth start endpoint now accepts an optional `return_to=onboarding` hint, carried tamper-proof inside the signed `state` (a claim on TikTok's signed JWT), and the callback uses it to return the user to the onboarding inbox-setup screen. Without the hint, behavior is unchanged. This is the backend half only; the frontend that sends `return_to=onboarding` ships separately. ## What changed - `Tiktok::IntegrationHelper`: the signed JWT carries an optional `return_to` claim, added only when present (a request without it is byte-identical to before); added `tiktok_token_return_to` to read it; `decode_token` now returns the full payload and `verify_tiktok_token` derives the account id from it. - `Tiktok::AuthorizationsController#create` passes `params[:return_to]` into the token. - `Tiktok::CallbacksController` redirects to the onboarding inbox-setup screen when `return_to == 'onboarding'`, before the normal settings/agents redirect. - Added the `app_onboarding_inbox_setup` route (shared with the sibling Gmail/Outlook and Instagram PRs — keep a single copy on merge to avoid a duplicate route name). Co-authored-by: Muhsin Keloth --- .../tiktok/authorizations_controller.rb | 2 +- .../tiktok/callbacks_controller.rb | 6 +++++ app/helpers/tiktok/integration_helper.rb | 25 ++++++++++++------- config/routes.rb | 1 + 4 files changed, 24 insertions(+), 10 deletions(-) diff --git a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb index 7c7320393..64bf38775 100644 --- a/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/tiktok/authorizations_controller.rb @@ -3,7 +3,7 @@ class Api::V1::Accounts::Tiktok::AuthorizationsController < Api::V1::Accounts::O def create redirect_url = Tiktok::AuthClient.authorize_url( - state: generate_tiktok_token(Current.account.id) + state: generate_tiktok_token(Current.account.id, params[:return_to]) ) if redirect_url diff --git a/app/controllers/tiktok/callbacks_controller.rb b/app/controllers/tiktok/callbacks_controller.rb index e484905c3..20c0ee9c0 100644 --- a/app/controllers/tiktok/callbacks_controller.rb +++ b/app/controllers/tiktok/callbacks_controller.rb @@ -20,6 +20,8 @@ class Tiktok::CallbacksController < ApplicationController def process_successful_authorization inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account_id) if return_to == 'onboarding' + if already_exists redirect_to app_tiktok_inbox_settings_url(account_id: account_id, inbox_id: inbox.id) else @@ -127,6 +129,10 @@ class Tiktok::CallbacksController < ApplicationController @account_id ||= verify_tiktok_token(params[:state]) end + def return_to + tiktok_token_return_to(params[:state]) + end + def account @account ||= Account.find(account_id) end diff --git a/app/helpers/tiktok/integration_helper.rb b/app/helpers/tiktok/integration_helper.rb index b2de4a092..7bc8bc4ab 100644 --- a/app/helpers/tiktok/integration_helper.rb +++ b/app/helpers/tiktok/integration_helper.rb @@ -2,11 +2,12 @@ module Tiktok::IntegrationHelper # Generates a signed JWT token for Tiktok integration # # @param account_id [Integer] The account ID to encode in the token + # @param return_to [String, nil] Optional onboarding return hint # @return [String, nil] The encoded JWT token or nil if client secret is missing - def generate_tiktok_token(account_id) + def generate_tiktok_token(account_id, return_to = nil) return if client_secret.blank? - JWT.encode(token_payload(account_id), client_secret, 'HS256') + JWT.encode(token_payload(account_id, return_to), client_secret, 'HS256') rescue StandardError => e Rails.logger.error("Failed to generate TikTok token: #{e.message}") nil @@ -19,7 +20,14 @@ module Tiktok::IntegrationHelper def verify_tiktok_token(token) return if token.blank? || client_secret.blank? - decode_token(token, client_secret) + decode_token(token, client_secret)&.dig('sub') + end + + # Reads the onboarding return hint from a Tiktok JWT token, if present. + def tiktok_token_return_to(token) + return if token.blank? || client_secret.blank? + + decode_token(token, client_secret)&.dig('return_to') end private @@ -28,18 +36,17 @@ module Tiktok::IntegrationHelper @client_secret ||= GlobalConfigService.load('TIKTOK_APP_SECRET', nil) end - def token_payload(account_id) - { - sub: account_id, - iat: Time.current.to_i - } + def token_payload(account_id, return_to = nil) + payload = { sub: account_id, iat: Time.current.to_i } + payload[:return_to] = return_to if return_to.present? + payload end def decode_token(token, secret) JWT.decode(token, secret, true, { algorithm: 'HS256', verify_expiration: true - }).first['sub'] + }).first rescue StandardError => e Rails.logger.error("Unexpected error verifying Tiktok token: #{e.message}") nil diff --git a/config/routes.rb b/config/routes.rb index aeb7fe525..15c471891 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -29,6 +29,7 @@ Rails.application.routes.draw do get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_instagram_inbox_settings' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_tiktok_inbox_settings' get '/app/accounts/:account_id/settings/inboxes/:inbox_id', to: 'dashboard#index', as: 'app_email_inbox_settings' + get '/app/accounts/:account_id/onboarding/inbox-setup', to: 'dashboard#index', as: 'app_onboarding_inbox_setup' resource :widget, only: [:show] namespace :survey do From 04ac9d378054eaa8ab0610f379b4983151544bad Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 2 Jun 2026 13:26:30 +0530 Subject: [PATCH 7/9] fix: use UPN for `imap_login` on Microsoft OAuth callback (#14522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outgoing email on a Microsoft email inbox was failing with `535 5.7.3 Authentication unsuccessful` immediately after the user re-authenticated, while incoming (IMAP) continued to work. The root cause is in our OAuth callback: we persist the id_token's `email` claim into `channel.imap_login`, but Microsoft's SMTP AUTH (XOAUTH2) validates the username against the access token's **UPN**, not the mailbox's primary SMTP address or aliases. When those diverge — common in tenants that use one domain for sign-in identities and another for mailbox addresses — SMTP rejects every send. This PR makes `OauthCallbackController#update_channel` prefer `preferred_username` (v2.0) / `upn` (v1.0) from the id_token over `email`, with `email` as the fallback so Google flows are unchanged. ## What changed - `app/controllers/oauth_callback_controller.rb` — extract `imap_login_identity` (default: `users_data['email']`, same as before). `update_channel` now calls it instead of inlining the email claim. No behavioural change in the base controller. - `app/controllers/microsoft/callbacks_controller.rb` — override `imap_login_identity` to return `preferred_username || upn || super`. Provider-specific knowledge stays in the provider subclass; Google's flow is literally untouched. - `spec/controllers/microsoft/callbacks_controller_spec.rb` — adds one example that reproduces the divergent shape (`email: `, `preferred_username: `) and asserts `imap_login` lands on the UPN while `channel.email` stays on the mailbox alias. `channel.email` and `find_channel_by_email` still key on the id_token's `email` claim everywhere, so customer-facing From identity and reconnect-by-email matching are unchanged. Behavioural matrix: | Provider / shape | `imap_login` before | `imap_login` after | |-----------------------------------------------------------------|---------------------|--------------------| | Microsoft, `upn == email` (common case) | email | UPN (= email, same string) | | Microsoft, `upn != email` (e.g. UPN on one domain, mailbox alias on another) | alias (broken) | UPN (works) | | Google | email | email (no change, base impl used) | ## Why this happens (Microsoft side, for context) Two facts that together produce the bug — one is documented, the other we verified empirically because Microsoft's docs don't address it: 1. **`email` and `upn` are different claims and can legitimately diverge.** In Entra, the UPN is the sign-in identity; the id_token's `email` claim is the user's mailbox property (which can be a proxy address). For v2.0 tokens (which is what we use — `/common/oauth2/v2.0/token` in `MicrosoftConcern`), the documented "username to sign in as" claim is `preferred_username`. See [ID token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference). So `users_data['email']` was the wrong source for `imap_login`; tenants where UPN == primary SMTP happened to mask the bug. 2. **Exchange's XOAUTH2 SMTP rejects aliases in the `user=` field, even though IMAP accepts them.** This asymmetry is **not** in [Microsoft's canonical XOAUTH2 doc](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth) — the doc treats the SASL `user=` field uniformly across IMAP, POP, and SMTP, with only a shared-mailbox carve-out spelled out. We confirmed the SMTP-strict behaviour empirically by running an XOAUTH2 AUTH probe with the same access token: `user=` returned `535 5.7.3`, `user=` returned `235`. Same token, only the `user=` field differed. That's what tied the symptom to claim-shape. Hence the patch: read the documented "sign-in name" claim and persist that, not the customer-facing mailbox address. ## How to reproduce (before the fix) 1. Set up a Microsoft email inbox in a tenant where the user's UPN domain differs from the user's primary SMTP / mailbox domain (e.g. UPN `user@tenant-a.example`, mailbox `User@tenant-b.example` where `tenant-b.example` is a proxy address on the same mailbox). 2. Connect or re-authenticate the inbox through the standard flow. 3. Inspect the channel: ```ruby ch.imap_login # => "User@tenant-b.example" (alias — wrong) token = ch.provider_config['access_token'] JSON.parse(Base64.urlsafe_decode64(token.split('.')[1].then { |s| s + '=' * (-s.length % 4) }))['upn'] # => "user@tenant-a.example" (UPN — what SMTP actually needs) ``` 4. Send a reply. SMTP returns `535 5.7.3 Authentication unsuccessful`. Incoming IMAP continues to work fine. After the fix, `imap_login` is set to the UPN at callback time and SMTP succeeds. ## Notes for review - The id_token decoding path (`users_data`) already exists and is trusted by the rest of the callback — no new attack surface. - Scoped to Microsoft on purpose. A provider-agnostic fallback chain in the base controller would also have worked (Google id_tokens don't carry `preferred_username` / `upn`, so it'd land on email anyway), but keeping Google's code path identical to today removes any risk of an unintended interaction with whatever a future Google update might add to its id_token. Pattern matches the existing `find_channel_by_email` override Google already has. - The [id_token claims reference](https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference) warns that `preferred_username` is mutable and "can't be used to make authorization decisions." That warning targets apps using the claim as a stable cross-session identifier for app-level authz — we're not. We use it as the SASL `user=` string for SMTP AUTH, validated by Microsoft against the same token it just issued. The claim's mutability matters only if a tenant admin renames a UPN between re-auths, in which case the stored `imap_login` goes stale and SMTP 535s — but the pre-patch code has the identical mutability characteristic on the `email` claim (rename a mailbox's primary SMTP and you get the same stale-then-535). The patch doesn't enlarge that failure surface; it shrinks it, because UPN-vs-alias divergence is now handled rather than always-broken. - Related but out of scope: SMTP failures don't trigger `channel.authorization_error!` today. `ExceptionList::SMTP_EXCEPTIONS` (`lib/exception_list.rb`) only contains `Net::SMTPSyntaxError`; `Net::SMTPAuthenticationError` bubbles unhandled, and `ApplicationMailer#handle_smtp_exceptions` only logs. So a 535 — whether from this bug or any other identity drift — never prompts re-auth in the UI, unlike the IMAP path (`fetch_imap_emails_job` catches `OAuth2::Error` and calls `authorization_error!`). Worth a separate PR; flagging here so we don't pretend stale-identity SMTP errors are self-healing. - The alternative I considered — decoding the access token's `upn` directly — is more correct in principle but relies on Microsoft access tokens being JWTs, which is undocumented behaviour for the `outlook.office.com` audience and contradicts the [docs guidance](https://learn.microsoft.com/en-us/entra/identity-platform/access-tokens) that access tokens are opaque to clients. - **Not addressed here, flagging for follow-up:** existing channels that were re-authenticated before this fix still hold the alias in `imap_login` and will keep 535ing until the user re-auths again. A one-shot rake task could decode the stored access tokens and reconcile, but fixing forward via natural re-auth is lower-risk; let me know if you want the backfill. - Also out of scope: `app/mailers/conversation_reply_mailer_helper.rb` reads `provider_config['access_token']` raw without going through `Microsoft::RefreshOauthTokenService`, while the IMAP path refreshes. That's a real asymmetry but unrelated to this bug (the token here was minutes-old) and worth its own PR. --- .../microsoft/callbacks_controller.rb | 7 +++++++ app/controllers/oauth_callback_controller.rb | 9 ++++++++- .../microsoft/callbacks_controller_spec.rb | 19 +++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb index 2f07505fc..045789f75 100644 --- a/app/controllers/microsoft/callbacks_controller.rb +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -14,4 +14,11 @@ class Microsoft::CallbacksController < OauthCallbackController def imap_address 'outlook.office365.com' end + + # Exchange Online's SMTP AUTH (XOAUTH2) rejects proxy addresses in the SASL `user=` field; + # it must match the token's UPN. `preferred_username` is the documented v2.0 claim; + # `upn` is the v1.0 fallback. + def imap_login_identity + users_data['preferred_username'] || users_data['upn'] || super + end end diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index be0fa5008..8929db987 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -44,7 +44,7 @@ class OauthCallbackController < ApplicationController def update_channel(channel_email) channel_email.update!({ - imap_login: users_data['email'], imap_address: imap_address, + imap_login: imap_login_identity, imap_address: imap_address, imap_port: '993', imap_enabled: true, provider: provider_name, provider_config: { @@ -55,6 +55,13 @@ class OauthCallbackController < ApplicationController }) end + # Identity used as the IMAP/SMTP login (SASL XOAUTH2 `user=` field). Defaults to the + # id_token's email claim; providers override when their server requires a different + # claim (e.g. Microsoft SMTP requires UPN). + def imap_login_identity + users_data['email'] + end + def provider_name raise NotImplementedError end diff --git a/spec/controllers/microsoft/callbacks_controller_spec.rb b/spec/controllers/microsoft/callbacks_controller_spec.rb index 129cfa383..be13d5c84 100644 --- a/spec/controllers/microsoft/callbacks_controller_spec.rb +++ b/spec/controllers/microsoft/callbacks_controller_spec.rb @@ -34,6 +34,25 @@ RSpec.describe 'Microsoft::CallbacksController', type: :request do expect(inbox.channel.imap_address).to eq 'outlook.office365.com' end + it 'sets imap_login from preferred_username when the id_token carries a UPN that differs from email' do + upn = 'testaccount@primary-domain.example' + mailbox = 'TestAccount@mailbox-domain.example' + response_body = { + id_token: JWT.encode({ email: mailbox, preferred_username: upn, name: 'test' }, nil, 'none'), + access_token: SecureRandom.hex(10), token_type: 'Bearer', refresh_token: SecureRandom.hex(10) + } + stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token') + .with(body: { 'code' => code, 'grant_type' => 'authorization_code', + 'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" }) + .to_return(status: 200, body: response_body.to_json, headers: { 'Content-Type' => 'application/json' }) + + get microsoft_callback_url, params: { code: code, state: state } + + channel = account.inboxes.last.channel + expect(channel.imap_login).to eq upn + expect(channel.email).to eq mailbox + end + it 'creates updates inbox channel config if inbox exists and authentication is successful' do inbox = create(:channel_email, account: account, email: email)&.inbox expect(inbox.channel.provider_config).to eq({}) From 4c6a345d604888920e45e0ba13c937705135f8d0 Mon Sep 17 00:00:00 2001 From: Shivam Mishra Date: Tue, 2 Jun 2026 14:21:11 +0530 Subject: [PATCH 8/9] feat(onboarding): honor return hint in email OAuth callback (#14567) When connecting a Gmail or Outlook inbox during onboarding, the OAuth flow used to drop users in inbox settings, breaking onboarding. The OAuth start endpoint now accepts an optional `return_to=onboarding` hint, carried tamper-proof inside the signed `state`, and the callback uses it to return the user to the onboarding inbox-setup screen. Without the hint, behavior is unchanged. This is the backend half only; the frontend that sends `return_to=onboarding` ships separately. Co-authored-by: Muhsin Keloth --- .../accounts/oauth_authorization_controller.rb | 10 +++++++++- app/controllers/oauth_callback_controller.rb | 18 +++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb index feb218b59..7fbca86da 100644 --- a/app/controllers/api/v1/accounts/oauth_authorization_controller.rb +++ b/app/controllers/api/v1/accounts/oauth_authorization_controller.rb @@ -8,7 +8,15 @@ class Api::V1::Accounts::OauthAuthorizationController < Api::V1::Accounts::BaseC end def state - Current.account.to_sgid(expires_in: 15.minutes).to_s + # The sgid purpose doubles as a return hint: onboarding tags it so the callback + # can route the user back to inbox setup. The purpose is part of the signed + # payload (tamper-proof), and a non-onboarding request keeps the default + # purpose, leaving callers like Notion byte-identical. + Current.account.to_sgid(expires_in: 15.minutes, for: state_purpose).to_s + end + + def state_purpose + params[:return_to] == 'onboarding' ? 'onboarding' : 'default' end def base_url diff --git a/app/controllers/oauth_callback_controller.rb b/app/controllers/oauth_callback_controller.rb index 8929db987..4a3d049a6 100644 --- a/app/controllers/oauth_callback_controller.rb +++ b/app/controllers/oauth_callback_controller.rb @@ -16,6 +16,8 @@ class OauthCallbackController < ApplicationController def handle_response inbox, already_exists = find_or_create_inbox + return redirect_to app_onboarding_inbox_setup_url(account_id: account.id) if return_to == 'onboarding' + if already_exists redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id) else @@ -88,10 +90,19 @@ class OauthCallbackController < ApplicationController decoded_token[0] end + # The sgid purpose carries the onboarding return hint (see + # OauthAuthorizationController#state). Try the onboarding purpose first — a match + # both resolves the account and records the return target — then fall back to the + # default purpose used by every other caller. def account_from_signed_id raise ActionController::BadRequest, 'Missing state variable' if params[:state].blank? - account = GlobalID::Locator.locate_signed(params[:state]) + if (account = GlobalID::Locator.locate_signed(params[:state], for: 'onboarding')) + @return_to = 'onboarding' + else + account = GlobalID::Locator.locate_signed(params[:state]) + end + raise 'Invalid or expired state' if account.nil? account @@ -101,6 +112,11 @@ class OauthCallbackController < ApplicationController @account ||= account_from_signed_id end + def return_to + account # resolving the sgid records which purpose matched + @return_to + end + # Fallback name, for when name field is missing from users_data def fallback_name users_data['email'].split('@').first.parameterize.titleize From 33dea837168b145c357cace9f554531990c4664b Mon Sep 17 00:00:00 2001 From: Sojan Jose Date: Tue, 2 Jun 2026 14:33:02 +0530 Subject: [PATCH 9/9] docs: document message attachment uploads (#14600) Updates the Create New Message API documentation to explain how to send file attachments with multipart form data. Closes #10472 Closes #12672 ## Why The endpoint already accepts attachment uploads, but the published API docs only described the JSON request body. That made it unclear that clients need to use multipart form data and send files through the `attachments[]` field. ## What changed - Adds `multipart/form-data` as a documented request body option for Create New Message. - Documents the `attachments` binary array and form encoding used by `attachments[]`. - Adds a multipart cURL request example for a message with an attachment. - Regenerates the Swagger JSON artifacts. ## Screenshots Create New Message API docs with the multipart cURL sample and `multipart/form-data` request sample selected: create-message-attachment-docs-multipart ## How to test 1. Open `/swagger`. 2. Navigate to Application -> Messages -> Create New Message. 3. Confirm the endpoint documents both `application/json` and `multipart/form-data`, including the attachment payload schema. --- .../conversation/messages/create.yml | 69 ++++++++++++++++ swagger/swagger.json | 82 ++++++++++++++++++- swagger/tag_groups/application_swagger.json | 82 ++++++++++++++++++- 3 files changed, 231 insertions(+), 2 deletions(-) diff --git a/swagger/paths/application/conversation/messages/create.yml b/swagger/paths/application/conversation/messages/create.yml index 1b8272585..09dece97f 100644 --- a/swagger/paths/application/conversation/messages/create.yml +++ b/swagger/paths/application/conversation/messages/create.yml @@ -4,6 +4,23 @@ operationId: create-a-new-message-in-a-conversation summary: Create New Message description: | Create a new message in the conversation. + + Use `application/json` for text messages and `multipart/form-data` when the + message includes file attachments. + + ### Multipart attachment request + + Send files with the `attachments[]` form field. `curl -F` sets the + `multipart/form-data` content type and boundary automatically. + + ```bash + curl -X POST "https://app.chatwoot.com/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages" \ + -H "api_access_token: " \ + -F "content=Here is the screenshot" \ + -F "message_type=outgoing" \ + -F "private=false" \ + -F "attachments[]=@/path/to/screenshot.png" + ``` ## WhatsApp Template Messages @@ -62,6 +79,58 @@ requestBody: application/json: schema: $ref: '#/components/schemas/conversation_message_create_payload' + multipart/form-data: + schema: + type: object + description: Form data payload for creating a message with file attachments. + example: + content: Here is the screenshot + message_type: outgoing + private: false + 'attachments[]': + - screenshot.png + properties: + content: + type: string + description: The content of the message + example: Here is the screenshot + message_type: + type: string + enum: ['outgoing', 'incoming'] + description: The type of the message + example: outgoing + private: + type: boolean + description: Flag to identify if it is a private note + example: false + content_type: + type: string + enum: ['text', 'input_email', 'cards', 'input_select', 'form', 'article'] + description: Content type of the message + example: text + content_attributes: + type: object + description: Attributes based on the content type + example: {} + 'attachments[]': + type: array + description: Files to attach to the message + items: + type: string + format: binary + encoding: + 'attachments[]': + style: form + explode: true + examples: + attachment_message: + summary: Message with an attachment + value: + content: Here is the screenshot + message_type: outgoing + private: false + 'attachments[]': + - screenshot.png responses: '200': description: Success diff --git a/swagger/swagger.json b/swagger/swagger.json index a73b3ba95..b21742b4e 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -6737,7 +6737,7 @@ ], "operationId": "create-a-new-message-in-a-conversation", "summary": "Create New Message", - "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", + "description": "Create a new message in the conversation.\n\nUse `application/json` for text messages and `multipart/form-data` when the\nmessage includes file attachments.\n\n### Multipart attachment request\n\nSend files with the `attachments[]` form field. `curl -F` sets the\n`multipart/form-data` content type and boundary automatically.\n\n```bash\ncurl -X POST \"https://app.chatwoot.com/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages\" \\\n -H \"api_access_token: \" \\\n -F \"content=Here is the screenshot\" \\\n -F \"message_type=outgoing\" \\\n -F \"private=false\" \\\n -F \"attachments[]=@/path/to/screenshot.png\"\n```\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", "security": [ { "userApiKey": [] @@ -6753,6 +6753,86 @@ "schema": { "$ref": "#/components/schemas/conversation_message_create_payload" } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "description": "Form data payload for creating a message with file attachments.", + "example": { + "content": "Here is the screenshot", + "message_type": "outgoing", + "private": false, + "attachments[]": [ + "screenshot.png" + ] + }, + "properties": { + "content": { + "type": "string", + "description": "The content of the message", + "example": "Here is the screenshot" + }, + "message_type": { + "type": "string", + "enum": [ + "outgoing", + "incoming" + ], + "description": "The type of the message", + "example": "outgoing" + }, + "private": { + "type": "boolean", + "description": "Flag to identify if it is a private note", + "example": false + }, + "content_type": { + "type": "string", + "enum": [ + "text", + "input_email", + "cards", + "input_select", + "form", + "article" + ], + "description": "Content type of the message", + "example": "text" + }, + "content_attributes": { + "type": "object", + "description": "Attributes based on the content type", + "example": {} + }, + "attachments[]": { + "type": "array", + "description": "Files to attach to the message", + "items": { + "type": "string", + "format": "binary" + } + } + } + }, + "encoding": { + "attachments[]": { + "style": "form", + "explode": true + } + }, + "examples": { + "attachment_message": { + "summary": "Message with an attachment", + "value": { + "content": "Here is the screenshot", + "message_type": "outgoing", + "private": false, + "attachments[]": [ + "screenshot.png" + ] + } + } + } } } }, diff --git a/swagger/tag_groups/application_swagger.json b/swagger/tag_groups/application_swagger.json index 4b1977577..f9ff31a7e 100644 --- a/swagger/tag_groups/application_swagger.json +++ b/swagger/tag_groups/application_swagger.json @@ -5280,7 +5280,7 @@ ], "operationId": "create-a-new-message-in-a-conversation", "summary": "Create New Message", - "description": "Create a new message in the conversation.\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", + "description": "Create a new message in the conversation.\n\nUse `application/json` for text messages and `multipart/form-data` when the\nmessage includes file attachments.\n\n### Multipart attachment request\n\nSend files with the `attachments[]` form field. `curl -F` sets the\n`multipart/form-data` content type and boundary automatically.\n\n```bash\ncurl -X POST \"https://app.chatwoot.com/api/v1/accounts/{account_id}/conversations/{conversation_id}/messages\" \\\n -H \"api_access_token: \" \\\n -F \"content=Here is the screenshot\" \\\n -F \"message_type=outgoing\" \\\n -F \"private=false\" \\\n -F \"attachments[]=@/path/to/screenshot.png\"\n```\n\n## WhatsApp Template Messages\n\nFor WhatsApp channels, you can send structured template messages using the `template_params` field. \nTemplates must be pre-approved in WhatsApp Business Manager.\n\n### Example Templates\n\n**Text with Image Header:**\n```json\n{\n \"content\": \"Hi your order 121212 is confirmed. Please wait for further updates\",\n \"template_params\": {\n \"name\": \"order_confirmation\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"1\": \"121212\"\n },\n \"header\": {\n \"media_url\": \"https://picsum.photos/200/300\",\n \"media_type\": \"image\"\n }\n }\n }\n}\n```\n\n**Text with Copy Code Button:**\n```json\n{\n \"content\": \"Special offer! Get 30% off your next purchase. Use the code below\",\n \"template_params\": {\n \"name\": \"discount_coupon\",\n \"category\": \"MARKETING\",\n \"language\": \"en\",\n \"processed_params\": {\n \"body\": {\n \"discount_percentage\": \"30\"\n },\n \"buttons\": [{\n \"type\": \"copy_code\",\n \"parameter\": \"SAVE20\"\n }]\n }\n }\n}\n```\n", "security": [ { "userApiKey": [] @@ -5296,6 +5296,86 @@ "schema": { "$ref": "#/components/schemas/conversation_message_create_payload" } + }, + "multipart/form-data": { + "schema": { + "type": "object", + "description": "Form data payload for creating a message with file attachments.", + "example": { + "content": "Here is the screenshot", + "message_type": "outgoing", + "private": false, + "attachments[]": [ + "screenshot.png" + ] + }, + "properties": { + "content": { + "type": "string", + "description": "The content of the message", + "example": "Here is the screenshot" + }, + "message_type": { + "type": "string", + "enum": [ + "outgoing", + "incoming" + ], + "description": "The type of the message", + "example": "outgoing" + }, + "private": { + "type": "boolean", + "description": "Flag to identify if it is a private note", + "example": false + }, + "content_type": { + "type": "string", + "enum": [ + "text", + "input_email", + "cards", + "input_select", + "form", + "article" + ], + "description": "Content type of the message", + "example": "text" + }, + "content_attributes": { + "type": "object", + "description": "Attributes based on the content type", + "example": {} + }, + "attachments[]": { + "type": "array", + "description": "Files to attach to the message", + "items": { + "type": "string", + "format": "binary" + } + } + } + }, + "encoding": { + "attachments[]": { + "style": "form", + "explode": true + } + }, + "examples": { + "attachment_message": { + "summary": "Message with an attachment", + "value": { + "content": "Here is the screenshot", + "message_type": "outgoing", + "private": false, + "attachments[]": [ + "screenshot.png" + ] + } + } + } } } },