Compare commits

..
Author SHA1 Message Date
Sojan Jose 906b14d825 Ensure conversation access honors permissions 2025-10-03 14:27:22 +05:30
66cfef9298 feat: Add WhatsApp health monitoring and self-service registration completion (#12556)
Fixes
https://linear.app/chatwoot/issue/CW-5692/whatsapp-es-numbers-stuck-in-pending-due-to-premature-registration


###  Problem  
Multiple customers reported that their WhatsApp numbers remain stuck in
**Pending** in WhatsApp Manager even after successful onboarding.

- Our system triggers a **registration call**
(`/<PHONE_NUMBER_ID>/register`) as soon as the number is OTP verified.
- In many cases, Meta hasn’t finished **display name
review/provisioning**, so the call fails with:

  ```
  code: 100, error_subcode: 2388001
  error_user_title: "Cannot Create Certificate"
error_user_msg: "Your display name could not be processed. Please edit
your display name and try again."
  ```

- This leaves the number stuck in Pending, no messaging can start until
we manually retry registration.
- Some customers have reported being stuck in this state for **7+
days**.

###  Root cause  
- We only check `code_verification_status = VERIFIED` before attempting
registration.
- We **don’t wait** for display name provisioning (`name_status` /
`platform_type`) to be complete.
- As a result, registration fails prematurely and the number never
transitions out of Pending.

### Solution  

#### 1. Health Status Monitoring  
- Build a backend service to fetch **real-time health data** from Graph
API:
  - `code_verification_status`  
  - `name_status` / `display_name_status`  
  - `platform_type`  
  - `throughput.level`  
  - `messaging_limit_tier`  
  - `quality_rating`  
- Expose health data via API
(`/api/v1/accounts/:account_id/inboxes/:id/health`).
- Display this in the UI as an **Account Health tab** with clear badges
and direct links to WhatsApp Manager.

#### 2. Smarter Registration Logic  
- Update `WebhookSetupService` to include a **dual-condition check**:  
  - Register if:  
    1. Phone is **not verified**, OR  
2. Phone is **verified but provisioning incomplete** (`platform_type =
NOT_APPLICABLE`, `throughput.level = NOT_APPLICABLE`).
- Skip registration if number is already provisioned.  
- Retry registration automatically when stuck.  
- Provide a UI banner with complete registration button so customers can
retry without manual support.

### Screenshot
<img width="2292" height="1344" alt="CleanShot 2025-09-30 at 16 01
03@2x"
src="https://github.com/user-attachments/assets/1c417d2a-b11c-475e-b092-3c5671ee59a7"
/>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2025-10-02 11:25:48 +05:30
Sivin VargheseandGitHub 109aaa2341 fix: Display proper conversation ID with click-to-open in SLA reports (#12570) 2025-10-02 08:18:54 +05:30
Shivam MishraandGitHub ecff66146a fix: Rendering on email without html content (#12561)
<img width="983" height="579" alt="image"
src="https://github.com/user-attachments/assets/2972e8d9-5145-4958-8f66-9e84bd9c8c4b"
/>
2025-10-01 13:43:05 +05:30
Shivam MishraandGitHub 21366e1c3b feat: allow quoted email thread in reply (#12545)
This PR adds the ability to include the thread history as a quoted text

## Preview


https://github.com/user-attachments/assets/c96a85e5-8ac8-4021-86ca-57509b4eea9f
2025-09-30 17:47:09 +05:30
406a470c81 feat: Log push notification error (#12543)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-29 15:19:45 +05:30
Muhsin KelothandGitHub 6c6aaf573c fix: Optimize Slack channel fetching to avoid rate limiting issues (#12542)
### Problem
The Slack integration fails when fetching channels from workspaces with
many channels due to rate limiting errors. The current implementation
makes a single API call requesting both public and private channels
simultaneously with `types: 'public_channel,private_channel'`, which
causes Slack's API to apply complex filtering and hit rate limits more
frequently.

  When testing with a csutomer workspace containing 157 channels:
- Combined request: Hit rate limits after a few pages, required 22+ API
calls with long delays
- Separate requests: Private channels (1 channel) load instantly, public
channels (185 channels) load quickly
  
 
  ### Solution

  Split the channel fetching into two sequential steps:
1. **Fetch private channels first** with `limit: 1000` (expects very
few)
  2. **Fetch public channels second** with pagination as needed

This approach leverages the fact that Slack's API handles single-type
requests much more efficiently than mixed-type requests, avoiding the
rate limiting issues entirely while maintaining the same functionality.
2025-09-29 14:41:48 +05:30
Chatwoot BotandGitHub 487209574c chore: Update translations (#12535) 2025-09-29 11:16:22 +05:30
7f1671c083 feat: Form validation message for password input (#11705)
Fixes https://github.com/chatwoot/chatwoot/issues/10914

# 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.

- [x] Bug fix (non-breaking change which fixes an issue)
- [x] 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:

- [x ] 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>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
2025-09-29 11:12:04 +05:30
b00261d7c2 feat: Add password visibility toggle to form input (#12524)
# Pull Request Template

## Description

This PR adds a password visibility toggle to the auth form input
component.

## Type of change

- [x] New feature (non-breaking change which adds functionality)

## How Has This Been Tested?

### Screencast


https://github.com/user-attachments/assets/17652e86-e823-46e6-a3ba-80af37c78906




## 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: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-25 20:14:36 +05:30
fcb91ab88a fix: Auto resolution flaky spec (#11964)
The test was failing because Current.contact was not being cleared when
testing system auto-resolution. Added Current.contact = nil to ensure
the system auto-resolution message is triggered instead of contact
resolution.

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

# 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: Claude <noreply@anthropic.com>
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-25 19:36:38 +05:30
59f7c8aa55 perf: Contact optimisation fixes (#12016)
- Avoids the duplicate count queries for contact end point

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-25 18:59:55 +05:30
Muhsin KelothandGitHub cd2c58726f fix: Ensure message is always present in conversation_created webhook for WhatsApp attachment messages (#12507)
Fixes https://github.com/chatwoot/chatwoot/issues/11753 and
https://github.com/chatwoot/chatwoot/issues/12442

**Problem**
When a WhatsApp conversation started with a media message, the
conversation created webhook would sometimes fire before the message and
its relationships were fully committed to the database. This resulted in
the message being missing
from the webhook payload, breaking external automations that rely on
this field.

**Solution**

Added `ActiveRecord::Base.transaction` wrapper around the core message
processing operations in `Whatsapp::IncomingMessageBaseService` to
ensure atomic execution:

- `set_conversation` (creates conversation)
- `create_messages` (creates message with account_id)
- `clear_message_source_id_from_redis` (cleanup)

Now the webhook only triggers after all related data is fully persisted,
guaranteeing message availability.
2025-09-25 18:19:09 +05:30
03d0688cc2 chore: Update translations (#12519)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2025-09-25 15:59:03 +05:30
Shivam MishraandGitHub b75ea7a762 feat: Use resolved contacts as base relation for filtering (#12520)
This PR has two changes to speed up contact filtering

### Updated Base Relation

Update the `base_relation` to use resolved contacts scope to improve
perf when filtering conversations. This narrows the search space
drastically, and what is usually a sequential scan becomes a index scan
for that `account_id`

ref: https://github.com/chatwoot/chatwoot/pull/9347
ref: https://github.com/chatwoot/chatwoot/pull/7175/

Result: https://explain.dalibo.com/plan/c8a8gb17f0275fgf#plan


## Selective filtering in Compose New Conversation

We also cost of filtering in compose new conversation dialog by reducing
the search space based on the search candidate. For instance, a search
term that obviously can’t be a phone, we exclude that from the filter.
Similarly we skip name lookups for email-shaped queries.

Removing the phone number took the query times from 50 seconds to under
1 seconds

### Comparison

1. Only Email: https://explain.dalibo.com/plan/h91a6844a4438a6a 
2. Email + Name: https://explain.dalibo.com/plan/beg3aah05ch9ade0
3. Email + Name + Phone:
https://explain.dalibo.com/plan/c8a8gb17f0275fgf
2025-09-25 15:26:44 +05:30
222 changed files with 3180 additions and 552 deletions
+60
View File
@@ -7,6 +7,7 @@ class Messages::MessageBuilder
@private = params[:private] || false
@conversation = conversation
@user = user
@account = conversation.account
@message_type = params[:message_type] || 'outgoing'
@attachments = params[:attachments]
@automation_rule = content_attributes&.dig(:automation_rule_id)
@@ -20,6 +21,9 @@ class Messages::MessageBuilder
@message = @conversation.messages.build(message_params)
process_attachments
process_emails
# When the message has no quoted content, it will just be rendered as a regular message
# The frontend is equipped to handle this case
process_email_content if @account.feature_enabled?(:quoted_email_reply)
@message.save!
@message
end
@@ -92,6 +96,14 @@ class Messages::MessageBuilder
@message.content_attributes[:to_emails] = to_emails
end
def process_email_content
return unless should_process_email_content?
@message.content_attributes ||= {}
email_attributes = build_email_attributes
@message.content_attributes[:email] = email_attributes
end
def process_email_string(email_string)
return [] if email_string.blank?
@@ -153,4 +165,52 @@ class Messages::MessageBuilder
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
def email_inbox?
@conversation.inbox&.inbox_type == 'Email'
end
def should_process_email_content?
email_inbox? && !@private && @message.content.present?
end
def build_email_attributes
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
email_attributes[:html_content] = build_html_content(normalized_content)
email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes
end
def build_html_content(normalized_content)
html_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :html_content) || {})
rendered_html = render_email_html(normalized_content)
html_content[:full] = rendered_html
html_content[:reply] = rendered_html
html_content
end
def build_text_content(normalized_content)
text_content = ensure_indifferent_access(@message.content_attributes.dig(:email, :text_content) || {})
text_content[:full] = normalized_content
text_content[:reply] = normalized_content
text_content
end
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
end
@@ -17,8 +17,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
before_action :set_include_contact_inboxes, only: [:index, :active, :search, :filter, :show, :update]
def index
@contacts_count = resolved_contacts.count
@contacts = fetch_contacts(resolved_contacts)
@contacts_count = @contacts.total_count
end
def search
@@ -29,8 +29,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
search: "%#{params[:q].strip}%"
)
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def import
@@ -55,8 +55,8 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
def active
contacts = Current.account.contacts.where(id: ::OnlineStatusTracker
.get_available_contact_ids(Current.account.id))
@contacts_count = contacts.count
@contacts = fetch_contacts(contacts)
@contacts_count = @contacts.total_count
end
def show; end
@@ -133,13 +133,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contacts(contacts)
contacts_with_avatar = filtrate(contacts)
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
# Build includes hash to avoid separate query when contact_inboxes are needed
includes_hash = { avatar_attachment: [:blob] }
includes_hash[:contact_inboxes] = { inbox: :channel } if @include_contact_inboxes
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
contacts_with_avatar
filtrate(contacts)
.includes(includes_hash)
.page(@current_page)
.per(RESULTS_PER_PAGE)
end
def build_contact_inbox
@@ -160,6 +160,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def conversation
@conversation ||= Current.account.conversations.find_by!(display_id: params[:id])
authorize @conversation
authorize @conversation.inbox, :show?
end
@@ -4,7 +4,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
before_action :fetch_agent_bot, only: [:set_agent_bot]
before_action :validate_limit, only: [:create]
# we are already handling the authorization in fetch inbox
before_action :check_authorization, except: [:show]
before_action :check_authorization, except: [:show, :health]
before_action :validate_whatsapp_cloud_channel, only: [:health]
def index
@inboxes = policy_scope(Current.account.inboxes.order_by_name.includes(:channel, { avatar_attachment: [:blob] }))
@@ -78,6 +79,14 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
render status: :internal_server_error, json: { error: e.message }
end
def health
health_data = Whatsapp::HealthService.new(@inbox.channel).fetch_health_status
render json: health_data
rescue StandardError => e
Rails.logger.error "[INBOX HEALTH] Error fetching health data: #{e.message}"
render json: { error: e.message }, status: :unprocessable_entity
end
private
def fetch_inbox
@@ -89,6 +98,12 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@agent_bot = AgentBot.find(params[:agent_bot]) if params[:agent_bot]
end
def validate_whatsapp_cloud_channel
return if @inbox.channel.is_a?(Channel::Whatsapp) && @inbox.channel.provider == 'whatsapp_cloud'
render json: { error: 'Health data only available for WhatsApp Cloud API channels' }, status: :bad_request
end
def create_channel
return unless allowed_channel_types.include?(permitted_params[:channel][:type])
@@ -23,29 +23,24 @@ module RequestExceptionHandler
Current.reset
end
def render_error(message, status, error = nil)
log_handled_error(error) if error
render json: { error: message }, status: status
end
def render_unauthorized(message)
render_error(message, :unauthorized)
render json: { error: message }, status: :unauthorized
end
def render_not_found_error(message)
render_error(message, :not_found)
render json: { error: message }, status: :not_found
end
def render_could_not_create_error(message)
render_error(message, :unprocessable_entity)
render json: { error: message }, status: :unprocessable_entity
end
def render_payment_required(message)
render_error(message, :payment_required)
render json: { error: message }, status: :payment_required
end
def render_internal_server_error(message)
render_error(message, :internal_server_error)
render json: { error: message }, status: :internal_server_error
end
def render_record_invalid(exception)
@@ -62,23 +57,6 @@ module RequestExceptionHandler
end
def log_handled_error(exception)
return unless exception
logger.info("Handled error: #{exception.inspect}")
report_to_apms(exception)
end
def report_to_apms(exception)
apm_reporters = {
'NewRelic::Agent' => -> { ::NewRelic::Agent.notice_error(exception) },
'Datadog::Tracing' => -> { ::Datadog::Tracing.active_trace&.set_error(exception) },
'ElasticAPM' => -> { ::ElasticAPM.report(exception) },
'ScoutApm::Error' => -> { ::ScoutApm::Error.capture(exception) },
'Sentry' => -> { ::Sentry.capture_exception(exception) }
}
apm_reporters.each do |module_name, reporter|
reporter.call if Object.const_defined?(module_name)
end
end
end
@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from './ApiClient';
class InboxHealthAPI extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
getHealthStatus(inboxId) {
return axios.get(`${this.url}/${inboxId}/health`);
}
}
export default new InboxHealthAPI();
@@ -148,10 +148,21 @@ const isAnyDropdownActive = computed(() => {
const handleContactSearch = value => {
showContactsDropdown.value = true;
emit('searchContacts', {
keys: ['email', 'phone_number', 'name'],
query: value,
const query = typeof value === 'string' ? value.trim() : '';
const hasAlphabet = Array.from(query).some(char => {
const lower = char.toLowerCase();
const upper = char.toUpperCase();
return lower !== upper;
});
const isEmailLike = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(query);
const keys = ['email', 'phone_number', 'name'].filter(key => {
if (key === 'phone_number' && hasAlphabet) return false;
if (key === 'name' && isEmailLike) return false;
return true;
});
emit('searchContacts', { keys, query: value });
};
const handleDropdownUpdate = (type, value) => {
@@ -5,9 +5,9 @@ import { sanitizeTextForRender } from '@chatwoot/utils';
import { allowedCssProperties } from 'lettersanitizer';
import Icon from 'next/icon/Icon.vue';
import { EmailQuoteExtractor } from './removeReply.js';
import BaseBubble from 'next/message/bubbles/Base.vue';
import { EmailQuoteExtractor } from 'dashboard/helper/emailQuoteExtractor.js';
import FormattedContent from 'next/message/bubbles/Text/FormattedContent.vue';
import BaseBubble from 'next/message/bubbles/Base.vue';
import AttachmentChips from 'next/message/chips/AttachmentChips.vue';
import EmailMeta from './EmailMeta.vue';
import TranslationToggle from 'dashboard/components-next/message/TranslationToggle.vue';
@@ -47,6 +47,13 @@ const originalEmailHtml = computed(
originalEmailText.value
);
const hasEmailContent = computed(() => {
return (
contentAttributes?.value?.email?.textContent?.full ||
contentAttributes?.value?.email?.htmlContent?.full
);
});
const messageContent = computed(() => {
// If translations exist and we're showing translations (not original)
if (hasTranslations.value && !renderOriginal.value) {
@@ -137,7 +144,7 @@ const handleSeeOriginal = () => {
</button>
</div>
<FormattedContent
v-if="isOutgoing && content"
v-if="isOutgoing && content && !hasEmailContent"
class="text-n-slate-12"
:content="messageContent"
/>
@@ -118,6 +118,14 @@ export default {
type: String,
default: '',
},
showQuotedReplyToggle: {
type: Boolean,
default: false,
},
quotedReplyEnabled: {
type: Boolean,
default: false,
},
},
emits: [
'replaceText',
@@ -125,6 +133,7 @@ export default {
'toggleEditor',
'selectWhatsappTemplate',
'selectContentTemplate',
'toggleQuotedReply',
],
setup() {
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
@@ -249,6 +258,11 @@ export default {
isFetchingAppIntegrations() {
return this.uiFlags.isFetching;
},
quotedReplyToggleTooltip() {
return this.quotedReplyEnabled
? this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.DISABLE_TOOLTIP')
: this.$t('CONVERSATION.REPLYBOX.QUOTED_REPLY.ENABLE_TOOLTIP');
},
},
mounted() {
ActiveStorage.start();
@@ -339,6 +353,16 @@ export default {
sm
@click="toggleMessageSignature"
/>
<NextButton
v-if="showQuotedReplyToggle"
v-tooltip.top-end="quotedReplyToggleTooltip"
icon="i-ph-quotes"
:variant="quotedReplyEnabled ? 'solid' : 'faded'"
color="slate"
sm
:aria-pressed="quotedReplyEnabled"
@click="$emit('toggleQuotedReply')"
/>
<NextButton
v-if="enableWhatsAppTemplates"
v-tooltip.top-end="$t('CONVERSATION.FOOTER.WHATSAPP_TEMPLATES')"
@@ -0,0 +1,76 @@
<script setup>
import { computed, ref } from 'vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { useI18n } from 'vue-i18n';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
quotedEmailText: {
type: String,
required: true,
},
previewText: {
type: String,
required: true,
},
});
const emit = defineEmits(['toggle']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const isExpanded = ref(false);
const formattedQuotedEmailText = computed(() => {
if (!props.quotedEmailText) {
return '';
}
return formatMessage(props.quotedEmailText, false, false, true);
});
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
</script>
<template>
<div class="mt-2">
<div
class="relative rounded-md px-3 py-2 text-xs text-n-slate-12 bg-n-slate-3 dark:bg-n-solid-3"
>
<div class="absolute top-2 right-2 z-10 flex items-center gap-1">
<NextButton
v-tooltip="
isExpanded
? t('CONVERSATION.REPLYBOX.QUOTED_REPLY.COLLAPSE')
: t('CONVERSATION.REPLYBOX.QUOTED_REPLY.EXPAND')
"
ghost
slate
xs
:icon="isExpanded ? 'i-lucide-minimize' : 'i-lucide-maximize'"
@click="toggleExpand"
/>
<NextButton
v-tooltip="t('CONVERSATION.REPLYBOX.QUOTED_REPLY.REMOVE_PREVIEW')"
ghost
slate
xs
icon="i-lucide-x"
@click="emit('toggle')"
/>
</div>
<div
v-dompurify-html="formattedQuotedEmailText"
class="w-full max-w-none break-words prose prose-sm dark:prose-invert cursor-pointer ltr:pr-8 rtl:pl-8"
:class="{
'line-clamp-1': !isExpanded,
'max-h-60 overflow-y-auto': isExpanded,
}"
:title="previewText"
@click="toggleExpand"
/>
</div>
</div>
</template>
@@ -5,6 +5,7 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useTrack } from 'dashboard/composables';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import CannedResponse from './CannedResponse.vue';
import ReplyToMessage from './ReplyToMessage.vue';
@@ -16,6 +17,7 @@ import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBotto
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
import ReplyBoxBanner from './ReplyBoxBanner.vue';
import QuotedEmailPreview from './QuotedEmailPreview.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
@@ -32,6 +34,12 @@ import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import {
extractQuotedEmailText,
buildQuotedEmailHeader,
truncatePreviewText,
appendQuotedTextToMessage,
} from 'dashboard/helper/quotedEmailHelper';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
import {
@@ -65,6 +73,7 @@ export default {
ContentTemplates,
WhatsappTemplates,
WootMessageEditor,
QuotedEmailPreview,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
@@ -80,6 +89,8 @@ export default {
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
} = useUISettings();
const replyEditor = useTemplateRef('replyEditor');
@@ -89,6 +100,8 @@ export default {
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
setQuotedReplyFlagForInbox,
fetchQuotedReplyFlagFromUISettings,
replyEditor,
};
},
@@ -130,6 +143,8 @@ export default {
currentUser: 'getCurrentUser',
lastEmail: 'getLastEmailInSelectedChat',
globalConfig: 'globalConfig/get',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
}),
currentContact() {
return this.$store.getters['contacts/getContact'](
@@ -367,6 +382,51 @@ export default {
const { slug = '' } = portal;
return slug;
},
isQuotedEmailReplyEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.QUOTED_EMAIL_REPLY
);
},
quotedReplyPreference() {
if (!this.isAnEmailChannel || !this.isQuotedEmailReplyEnabled) {
return false;
}
return !!this.fetchQuotedReplyFlagFromUISettings(this.channelType);
},
lastEmailWithQuotedContent() {
if (!this.isAnEmailChannel) {
return null;
}
const lastEmail = this.lastEmail;
if (!lastEmail || lastEmail.private) {
return null;
}
return lastEmail;
},
quotedEmailText() {
return extractQuotedEmailText(this.lastEmailWithQuotedContent);
},
quotedEmailPreviewText() {
return truncatePreviewText(this.quotedEmailText, 80);
},
shouldShowQuotedReplyToggle() {
return (
this.isAnEmailChannel &&
!this.isOnPrivateNote &&
this.isQuotedEmailReplyEnabled
);
},
shouldShowQuotedPreview() {
return (
this.shouldShowQuotedReplyToggle &&
this.quotedReplyPreference &&
!!this.quotedEmailText
);
},
},
watch: {
currentChat(conversation, oldConversation) {
@@ -516,6 +576,36 @@ export default {
);
}
},
toggleQuotedReply() {
if (!this.isAnEmailChannel) {
return;
}
const nextValue = !this.quotedReplyPreference;
this.setQuotedReplyFlagForInbox(this.channelType, nextValue);
},
shouldIncludeQuotedEmail() {
return (
this.isQuotedEmailReplyEnabled &&
this.quotedReplyPreference &&
this.shouldShowQuotedReplyToggle &&
!!this.quotedEmailText
);
},
getMessageWithQuotedEmailText(message) {
if (!this.shouldIncludeQuotedEmail()) {
return message;
}
const quotedText = this.quotedEmailText || '';
const header = buildQuotedEmailHeader(
this.lastEmailWithQuotedContent,
this.currentContact,
this.inbox
);
return appendQuotedTextToMessage(message, quotedText, header);
},
resetRecorderAndClearAttachments() {
// Reset audio recorder UI state
this.resetAudioRecorderInput();
@@ -965,9 +1055,11 @@ export default {
return multipleMessagePayload;
},
getMessagePayload(message) {
const messageWithQuote = this.getMessageWithQuotedEmailText(message);
let messagePayload = {
conversationId: this.currentChat.id,
message,
message: messageWithQuote,
private: this.isPrivate,
sender: this.sender,
};
@@ -995,7 +1087,6 @@ export default {
if (this.toEmails && !this.isOnPrivateNote) {
messagePayload.toEmails = this.toEmails;
}
return messagePayload;
},
setCcEmails(value) {
@@ -1160,6 +1251,12 @@ export default {
@toggle-variables-menu="toggleVariablesMenu"
@clear-selection="clearEditorSelection"
/>
<QuotedEmailPreview
v-if="shouldShowQuotedPreview"
:quoted-email-text="quotedEmailText"
:preview-text="quotedEmailPreviewText"
@toggle="toggleQuotedReply"
/>
</div>
<div
v-if="hasAttachments && !showAudioRecorderEditor"
@@ -1195,6 +1292,8 @@ export default {
:show-editor-toggle="isAPIInbox && !isOnPrivateNote"
:show-emoji-picker="showEmojiPicker"
:show-file-upload="showFileUpload"
:show-quoted-reply-toggle="shouldShowQuotedReplyToggle"
:quoted-reply-enabled="quotedReplyPreference"
:toggle-audio-recorder-play-pause="toggleAudioRecorderPlayPause"
:toggle-audio-recorder="toggleAudioRecorder"
:toggle-emoji-picker="toggleEmojiPicker"
@@ -1206,6 +1305,7 @@ export default {
@toggle-editor="toggleRichContentEditor"
@replace-text="replaceText"
@toggle-insert-article="toggleInsertArticle"
@toggle-quoted-reply="toggleQuotedReply"
/>
<WhatsappTemplates
:inbox-id="inbox.id"
@@ -13,6 +13,7 @@ const getUISettingsMock = ref({
conversation_sidebar_items_order: DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
vi.mock('dashboard/composables/store', () => ({
@@ -37,6 +38,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
});
@@ -51,6 +53,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -65,6 +68,7 @@ describe('useUISettings', () => {
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -100,6 +104,7 @@ describe('useUISettings', () => {
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
email_signature_enabled: true,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
@@ -109,6 +114,26 @@ describe('useUISettings', () => {
expect(fetchSignatureFlagFromUISettings('email')).toBe(undefined);
});
it('sets quoted reply flag for inbox correctly', () => {
const { setQuotedReplyFlagForInbox } = useUISettings();
setQuotedReplyFlagForInbox('Channel::Email', false);
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: false,
},
});
});
it('fetches quoted reply flag from UI settings correctly', () => {
const { fetchQuotedReplyFlagFromUISettings } = useUISettings();
expect(fetchQuotedReplyFlagFromUISettings('Channel::Email')).toBe(true);
});
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is configured', () => {
getUISettingsMock.value.enter_to_send_enabled = false;
const { isEditorHotKeyEnabled } = useUISettings();
@@ -87,6 +87,13 @@ const setSignatureFlagForInbox = (channelType, value, updateUISettings) => {
updateUISettings({ [`${slugifiedChannel}_signature_enabled`]: value });
};
const setQuotedReplyFlagForInbox = (channelType, value, updateUISettings) => {
if (!channelType) return;
const slugifiedChannel = slugifyChannel(channelType);
updateUISettings({ [`${slugifiedChannel}_quoted_reply_enabled`]: value });
};
/**
* Fetches the signature flag for a specific channel type from UI settings.
* @param {string} channelType - The type of the channel.
@@ -100,6 +107,13 @@ const fetchSignatureFlagFromUISettings = (channelType, uiSettings) => {
return uiSettings.value[`${slugifiedChannel}_signature_enabled`];
};
const fetchQuotedReplyFlagFromUISettings = (channelType, uiSettings) => {
if (!channelType) return false;
const slugifiedChannel = slugifyChannel(channelType);
return uiSettings.value[`${slugifiedChannel}_quoted_reply_enabled`];
};
/**
* Checks if a specific editor hotkey is enabled.
* @param {string} key - The key to check.
@@ -147,6 +161,10 @@ export function useUISettings() {
setSignatureFlagForInbox(channelType, value, updateUISettings),
fetchSignatureFlagFromUISettings: channelType =>
fetchSignatureFlagFromUISettings(channelType, uiSettings),
setQuotedReplyFlagForInbox: (channelType, value) =>
setQuotedReplyFlagForInbox(channelType, value, updateUISettings),
fetchQuotedReplyFlagFromUISettings: channelType =>
fetchQuotedReplyFlagFromUISettings(channelType, uiSettings),
isEditorHotKeyEnabled: key => isEditorHotKeyEnabled(key, uiSettings),
};
}
+1
View File
@@ -40,6 +40,7 @@ export const FEATURE_FLAGS = {
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
CAPTAIN_V2: 'captain_integration_v2',
SAML: 'saml',
QUOTED_EMAIL_REPLY: 'quoted_email_reply',
};
export const PREMIUM_FEATURES = [
@@ -10,6 +10,8 @@ const QUOTE_INDICATORS = [
'[class*="Quote"]',
];
const BLOCKQUOTE_FALLBACK_SELECTOR = 'blockquote';
// Regex patterns for quote identification
const QUOTE_PATTERNS = [
/On .* wrote:/i,
@@ -36,6 +38,8 @@ export class EmailQuoteExtractor {
});
});
this.removeTrailingBlockquote(tempDiv);
// Remove text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
textNodeQuotes.forEach(el => {
@@ -62,6 +66,10 @@ export class EmailQuoteExtractor {
}
}
if (this.findTrailingBlockquote(tempDiv)) {
return true;
}
// Check for text-based quotes
const textNodeQuotes = this.findTextNodeQuotes(tempDiv);
return textNodeQuotes.length > 0;
@@ -123,4 +131,26 @@ export class EmailQuoteExtractor {
return null;
}
/**
* Remove fallback blockquote if it is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
*/
static removeTrailingBlockquote(rootElement) {
const trailingBlockquote = this.findTrailingBlockquote(rootElement);
trailingBlockquote?.remove();
}
/**
* Locate a fallback blockquote that is the last top-level element.
* @param {Element} rootElement - Root element containing the HTML
* @returns {Element|null} The trailing blockquote element if present
*/
static findTrailingBlockquote(rootElement) {
const lastElement = rootElement.lastElementChild;
if (lastElement?.matches?.(BLOCKQUOTE_FALLBACK_SELECTOR)) {
return lastElement;
}
return null;
}
}
@@ -68,13 +68,17 @@ export const registerSubscription = (onSuccess = () => {}) => {
.then(() => {
onSuccess();
})
.catch(() => {
.catch(error => {
// eslint-disable-next-line no-console
console.error('Push subscription registration failed:', error);
useAlert('This browser does not support desktop notification');
});
};
export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) {
// eslint-disable-next-line no-console
console.warn('Notification is not supported');
useAlert('This browser does not support desktop notification');
} else if (Notification.permission === 'granted') {
registerSubscription(onSuccess);
@@ -0,0 +1,332 @@
import { format, parseISO, isValid as isValidDate } from 'date-fns';
/**
* Extracts plain text from HTML content
* @param {string} html - HTML content to convert
* @returns {string} Plain text content
*/
export const extractPlainTextFromHtml = html => {
if (!html) {
return '';
}
if (typeof document === 'undefined') {
return html.replace(/<[^>]*>/g, ' ');
}
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
return tempDiv.textContent || tempDiv.innerText || '';
};
/**
* Extracts sender name from email message
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Sender name
*/
export const getEmailSenderName = (lastEmail, contact) => {
const senderName = lastEmail?.sender?.name;
if (senderName && senderName.trim()) {
return senderName.trim();
}
const contactName = contact?.name;
return contactName && contactName.trim() ? contactName.trim() : '';
};
/**
* Extracts sender email from email message
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Sender email address
*/
export const getEmailSenderEmail = (lastEmail, contact) => {
const senderEmail = lastEmail?.sender?.email;
if (senderEmail && senderEmail.trim()) {
return senderEmail.trim();
}
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (Array.isArray(emailMeta.from) && emailMeta.from.length > 0) {
const fromAddress = emailMeta.from[0];
if (fromAddress && fromAddress.trim()) {
return fromAddress.trim();
}
}
const contactEmail = contact?.email;
return contactEmail && contactEmail.trim() ? contactEmail.trim() : '';
};
/**
* Extracts date from email message
* @param {Object} lastEmail - Last email message object
* @returns {Date|null} Email date
*/
export const getEmailDate = lastEmail => {
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (emailMeta.date) {
const parsedDate = parseISO(emailMeta.date);
if (isValidDate(parsedDate)) {
return parsedDate;
}
}
const createdAt = lastEmail?.created_at;
if (createdAt) {
const timestamp = Number(createdAt);
if (!Number.isNaN(timestamp)) {
const milliseconds = timestamp > 1e12 ? timestamp : timestamp * 1000;
const derivedDate = new Date(milliseconds);
if (!Number.isNaN(derivedDate.getTime())) {
return derivedDate;
}
}
}
return null;
};
/**
* Formats date for quoted email header
* @param {Date} date - Date to format
* @returns {string} Formatted date string
*/
export const formatQuotedEmailDate = date => {
try {
return format(date, "EEE, MMM d, yyyy 'at' p");
} catch (error) {
const fallbackDate = new Date(date);
if (!Number.isNaN(fallbackDate.getTime())) {
return format(fallbackDate, "EEE, MMM d, yyyy 'at' p");
}
}
return '';
};
/**
* Extracts inbox email address from last email message
* @param {Object} lastEmail - Last email message object
* @param {Object} inbox - Inbox object
* @returns {string} Inbox email address
*/
export const getInboxEmail = (lastEmail, inbox) => {
const contentAttributes =
lastEmail?.contentAttributes || lastEmail?.content_attributes || {};
const emailMeta = contentAttributes.email || {};
if (Array.isArray(emailMeta.to) && emailMeta.to.length > 0) {
const toAddress = emailMeta.to[0];
if (toAddress && toAddress.trim()) {
return toAddress.trim();
}
}
const inboxEmail = inbox?.email;
return inboxEmail && inboxEmail.trim() ? inboxEmail.trim() : '';
};
/**
* Builds quoted email header from contact (for incoming messages)
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeaderFromContact = (lastEmail, contact) => {
if (!lastEmail) {
return '';
}
const quotedDate = getEmailDate(lastEmail);
const senderEmail = getEmailSenderEmail(lastEmail, contact);
if (!quotedDate || !senderEmail) {
return '';
}
const formattedDate = formatQuotedEmailDate(quotedDate);
if (!formattedDate) {
return '';
}
const senderName = getEmailSenderName(lastEmail, contact);
const hasName = !!senderName;
const contactLabel = hasName
? `${senderName} <${senderEmail}>`
: `<${senderEmail}>`;
return `On ${formattedDate} ${contactLabel} wrote:`;
};
/**
* Builds quoted email header from inbox (for outgoing messages)
* @param {Object} lastEmail - Last email message object
* @param {Object} inbox - Inbox object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeaderFromInbox = (lastEmail, inbox) => {
if (!lastEmail) {
return '';
}
const quotedDate = getEmailDate(lastEmail);
const inboxEmail = getInboxEmail(lastEmail, inbox);
if (!quotedDate || !inboxEmail) {
return '';
}
const formattedDate = formatQuotedEmailDate(quotedDate);
if (!formattedDate) {
return '';
}
const inboxName = inbox?.name;
const hasName = !!inboxName;
const inboxLabel = hasName
? `${inboxName} <${inboxEmail}>`
: `<${inboxEmail}>`;
return `On ${formattedDate} ${inboxLabel} wrote:`;
};
/**
* Builds quoted email header based on message type
* @param {Object} lastEmail - Last email message object
* @param {Object} contact - Contact object
* @param {Object} inbox - Inbox object
* @returns {string} Formatted header string
*/
export const buildQuotedEmailHeader = (lastEmail, contact, inbox) => {
if (!lastEmail) {
return '';
}
// MESSAGE_TYPE.OUTGOING = 1, MESSAGE_TYPE.INCOMING = 0
const isOutgoing = lastEmail.message_type === 1;
if (isOutgoing) {
return buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
}
return buildQuotedEmailHeaderFromContact(lastEmail, contact);
};
/**
* Formats text as markdown blockquote
* @param {string} text - Text to format
* @param {string} header - Optional header to prepend
* @returns {string} Formatted blockquote
*/
export const formatQuotedTextAsBlockquote = (text, header = '') => {
const normalizedLines = text
? String(text).replace(/\r\n/g, '\n').split('\n')
: [];
if (!header && !normalizedLines.length) {
return '';
}
const quotedLines = [];
if (header) {
quotedLines.push(`> ${header}`);
quotedLines.push('>');
}
normalizedLines.forEach(line => {
const trimmedLine = line.trimEnd();
quotedLines.push(trimmedLine ? `> ${trimmedLine}` : '>');
});
return quotedLines.join('\n');
};
/**
* Extracts quoted email text from last email message
* @param {Object} lastEmail - Last email message object
* @returns {string} Quoted email text
*/
export const extractQuotedEmailText = lastEmail => {
if (!lastEmail) {
return '';
}
const contentAttributes =
lastEmail.contentAttributes || lastEmail.content_attributes || {};
const emailContent = contentAttributes.email || {};
const textContent = emailContent.textContent || emailContent.text_content;
if (textContent?.reply) {
return textContent.reply;
}
if (textContent?.full) {
return textContent.full;
}
const htmlContent = emailContent.htmlContent || emailContent.html_content;
if (htmlContent?.reply) {
return extractPlainTextFromHtml(htmlContent.reply);
}
if (htmlContent?.full) {
return extractPlainTextFromHtml(htmlContent.full);
}
const fallbackContent =
lastEmail.content || lastEmail.processed_message_content || '';
return fallbackContent;
};
/**
* Truncates text for preview display
* @param {string} text - Text to truncate
* @param {number} maxLength - Maximum length (default: 80)
* @returns {string} Truncated text
*/
export const truncatePreviewText = (text, maxLength = 80) => {
const preview = text.trim().replace(/\s+/g, ' ');
if (!preview) {
return '';
}
if (preview.length <= maxLength) {
return preview;
}
return `${preview.slice(0, maxLength - 3)}...`;
};
/**
* Appends quoted text to message
* @param {string} message - Original message
* @param {string} quotedText - Text to quote
* @param {string} header - Quote header
* @returns {string} Message with quoted text appended
*/
export const appendQuotedTextToMessage = (message, quotedText, header) => {
const baseMessage = message ? String(message) : '';
const quotedBlock = formatQuotedTextAsBlockquote(quotedText, header);
if (!quotedBlock) {
return baseMessage;
}
if (!baseMessage) {
return quotedBlock;
}
let separator = '\n\n';
if (baseMessage.endsWith('\n\n')) {
separator = '';
} else if (baseMessage.endsWith('\n')) {
separator = '\n';
}
return `${baseMessage}${separator}${quotedBlock}`;
};
@@ -0,0 +1,99 @@
import { describe, it, expect } from 'vitest';
import { EmailQuoteExtractor } from '../emailQuoteExtractor.js';
const SAMPLE_EMAIL_HTML = `
<p>method</p>
<blockquote>
<p>On Mon, Sep 29, 2025 at 5:18 PM John <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
<p>Hi</p>
<blockquote>
<p>On Mon, Sep 29, 2025 at 5:17 PM Shivam Mishra <a href="mailto:shivam@chatwoot.com">shivam@chatwoot.com</a> wrote:</p>
<p>Yes, it is.</p>
<p>On Mon, Sep 29, 2025 at 5:16 PM John from Shaneforwoot &lt; shaneforwoot@gmail.com&gt; wrote:</p>
<blockquote>
<p>Hey</p>
<p>On Mon, Sep 29, 2025 at 4:59 PM John shivam@chatwoot.com wrote:</p>
<p>This is another quoted quoted text reply</p>
<p>This is nice</p>
<p>On Mon, Sep 29, 2025 at 4:21 PM John from Shaneforwoot &lt; &gt; shaneforwoot@gmail.com&gt; wrote:</p>
<p>Hey there, this is a reply from Chatwoot, notice the quoted text</p>
<p>Hey there</p>
<p>This is an email text, enjoy reading this</p>
<p>-- Shivam Mishra, Chatwoot</p>
</blockquote>
</blockquote>
</blockquote>
`;
const EMAIL_WITH_SIGNATURE = `
<p>Latest reply here.</p>
<p>Thanks,</p>
<p>Jane Doe</p>
<blockquote>
<p>On Mon, Sep 22, Someone wrote:</p>
<p>Previous reply content</p>
</blockquote>
`;
const EMAIL_WITH_FOLLOW_UP_CONTENT = `
<blockquote>
<p>Inline quote that should stay</p>
</blockquote>
<p>Internal note follows</p>
<p>Regards,</p>
`;
describe('EmailQuoteExtractor', () => {
it('removes blockquote-based quotes from the email body', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(SAMPLE_EMAIL_HTML);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelectorAll('blockquote').length).toBe(0);
expect(container.textContent?.trim()).toBe('method');
expect(container.textContent).not.toContain(
'On Mon, Sep 29, 2025 at 5:18 PM'
);
});
it('keeps blockquote fallback when it is not the last top-level element', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(
EMAIL_WITH_FOLLOW_UP_CONTENT
);
const container = document.createElement('div');
container.innerHTML = cleanedHtml;
expect(container.querySelector('blockquote')).not.toBeNull();
expect(container.lastElementChild?.tagName).toBe('P');
});
it('detects quote indicators in nested blockquotes', () => {
const result = EmailQuoteExtractor.hasQuotes(SAMPLE_EMAIL_HTML);
expect(result).toBe(true);
});
it('does not flag blockquotes that are followed by other elements', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_FOLLOW_UP_CONTENT)).toBe(
false
);
});
it('returns false when no quote indicators are present', () => {
const html = '<p>Plain content</p>';
expect(EmailQuoteExtractor.hasQuotes(html)).toBe(false);
});
it('removes trailing blockquotes while preserving trailing signatures', () => {
const cleanedHtml = EmailQuoteExtractor.extractQuotes(EMAIL_WITH_SIGNATURE);
expect(cleanedHtml).toContain('<p>Thanks,</p>');
expect(cleanedHtml).toContain('<p>Jane Doe</p>');
expect(cleanedHtml).not.toContain('<blockquote');
});
it('detects quotes for trailing blockquotes even when signatures follow text', () => {
expect(EmailQuoteExtractor.hasQuotes(EMAIL_WITH_SIGNATURE)).toBe(true);
});
});
@@ -0,0 +1,441 @@
import {
extractPlainTextFromHtml,
getEmailSenderName,
getEmailSenderEmail,
getEmailDate,
formatQuotedEmailDate,
getInboxEmail,
buildQuotedEmailHeader,
buildQuotedEmailHeaderFromContact,
buildQuotedEmailHeaderFromInbox,
formatQuotedTextAsBlockquote,
extractQuotedEmailText,
truncatePreviewText,
appendQuotedTextToMessage,
} from '../quotedEmailHelper';
describe('quotedEmailHelper', () => {
describe('extractPlainTextFromHtml', () => {
it('returns empty string for null or undefined', () => {
expect(extractPlainTextFromHtml(null)).toBe('');
expect(extractPlainTextFromHtml(undefined)).toBe('');
});
it('strips HTML tags and returns plain text', () => {
const html = '<p>Hello <strong>world</strong></p>';
const result = extractPlainTextFromHtml(html);
expect(result).toBe('Hello world');
});
it('handles complex HTML structure', () => {
const html = '<div><p>Line 1</p><p>Line 2</p></div>';
const result = extractPlainTextFromHtml(html);
expect(result).toContain('Line 1');
expect(result).toContain('Line 2');
});
});
describe('getEmailSenderName', () => {
it('returns sender name from lastEmail', () => {
const lastEmail = { sender: { name: 'John Doe' } };
const result = getEmailSenderName(lastEmail, {});
expect(result).toBe('John Doe');
});
it('returns contact name if sender name not available', () => {
const lastEmail = { sender: {} };
const contact = { name: 'Jane Smith' };
const result = getEmailSenderName(lastEmail, contact);
expect(result).toBe('Jane Smith');
});
it('returns empty string if neither available', () => {
const result = getEmailSenderName({}, {});
expect(result).toBe('');
});
it('trims whitespace from names', () => {
const lastEmail = { sender: { name: ' John Doe ' } };
const result = getEmailSenderName(lastEmail, {});
expect(result).toBe('John Doe');
});
});
describe('getEmailSenderEmail', () => {
it('returns sender email from lastEmail', () => {
const lastEmail = { sender: { email: 'john@example.com' } };
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('john@example.com');
});
it('returns email from contentAttributes if sender email not available', () => {
const lastEmail = {
contentAttributes: {
email: { from: ['jane@example.com'] },
},
};
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('jane@example.com');
});
it('returns contact email as fallback', () => {
const lastEmail = {};
const contact = { email: 'contact@example.com' };
const result = getEmailSenderEmail(lastEmail, contact);
expect(result).toBe('contact@example.com');
});
it('trims whitespace from emails', () => {
const lastEmail = { sender: { email: ' john@example.com ' } };
const result = getEmailSenderEmail(lastEmail, {});
expect(result).toBe('john@example.com');
});
});
describe('getEmailDate', () => {
it('returns parsed date from email metadata', () => {
const lastEmail = {
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('returns date from created_at timestamp', () => {
const lastEmail = { created_at: 1705318200 };
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('handles millisecond timestamps', () => {
const lastEmail = { created_at: 1705318200000 };
const result = getEmailDate(lastEmail);
expect(result).toBeInstanceOf(Date);
});
it('returns null if no valid date found', () => {
const result = getEmailDate({});
expect(result).toBeNull();
});
});
describe('formatQuotedEmailDate', () => {
it('formats date correctly', () => {
const date = new Date('2024-01-15T10:30:00Z');
const result = formatQuotedEmailDate(date);
expect(result).toMatch(/Mon, Jan 15, 2024 at/);
});
it('returns empty string for invalid date', () => {
const result = formatQuotedEmailDate('invalid');
expect(result).toBe('');
});
});
describe('getInboxEmail', () => {
it('returns email from contentAttributes.email.to', () => {
const lastEmail = {
contentAttributes: {
email: { to: ['inbox@example.com'] },
},
};
const result = getInboxEmail(lastEmail, {});
expect(result).toBe('inbox@example.com');
});
it('returns inbox email as fallback', () => {
const lastEmail = {};
const inbox = { email: 'support@example.com' };
const result = getInboxEmail(lastEmail, inbox);
expect(result).toBe('support@example.com');
});
it('returns empty string if no email found', () => {
expect(getInboxEmail({}, {})).toBe('');
});
it('trims whitespace from emails', () => {
const lastEmail = {
contentAttributes: {
email: { to: [' inbox@example.com '] },
},
};
const result = getInboxEmail(lastEmail, {});
expect(result).toBe('inbox@example.com');
});
});
describe('buildQuotedEmailHeaderFromContact', () => {
it('builds complete header with name and email', () => {
const lastEmail = {
sender: { name: 'John Doe', email: 'john@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
expect(result).toContain('John Doe');
expect(result).toContain('john@example.com');
expect(result).toContain('wrote:');
});
it('builds header without name if not available', () => {
const lastEmail = {
sender: { email: 'john@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const result = buildQuotedEmailHeaderFromContact(lastEmail, {});
expect(result).toContain('<john@example.com>');
expect(result).not.toContain('undefined');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeaderFromContact(null, {})).toBe('');
expect(buildQuotedEmailHeaderFromContact({}, {})).toBe('');
});
});
describe('buildQuotedEmailHeaderFromInbox', () => {
it('builds complete header with inbox name and email', () => {
const lastEmail = {
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['support@example.com'],
},
},
};
const inbox = { name: 'Support Team', email: 'support@example.com' };
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
expect(result).toContain('Support Team');
expect(result).toContain('support@example.com');
expect(result).toContain('wrote:');
});
it('builds header without name if not available', () => {
const lastEmail = {
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['inbox@example.com'],
},
},
};
const inbox = { email: 'inbox@example.com' };
const result = buildQuotedEmailHeaderFromInbox(lastEmail, inbox);
expect(result).toContain('<inbox@example.com>');
expect(result).not.toContain('undefined');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeaderFromInbox(null, {})).toBe('');
expect(buildQuotedEmailHeaderFromInbox({}, {})).toBe('');
});
});
describe('buildQuotedEmailHeader', () => {
it('uses inbox email for outgoing messages (message_type: 1)', () => {
const lastEmail = {
message_type: 1,
contentAttributes: {
email: {
date: '2024-01-15T10:30:00Z',
to: ['support@example.com'],
},
},
};
const inbox = { name: 'Support', email: 'support@example.com' };
const contact = { name: 'John Doe', email: 'john@example.com' };
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
expect(result).toContain('Support');
expect(result).toContain('support@example.com');
expect(result).not.toContain('John Doe');
});
it('uses contact email for incoming messages (message_type: 0)', () => {
const lastEmail = {
message_type: 0,
sender: { name: 'Jane Smith', email: 'jane@example.com' },
contentAttributes: {
email: { date: '2024-01-15T10:30:00Z' },
},
};
const inbox = { name: 'Support', email: 'support@example.com' };
const contact = { name: 'Jane Smith', email: 'jane@example.com' };
const result = buildQuotedEmailHeader(lastEmail, contact, inbox);
expect(result).toContain('Jane Smith');
expect(result).toContain('jane@example.com');
expect(result).not.toContain('Support');
});
it('returns empty string if missing required data', () => {
expect(buildQuotedEmailHeader(null, {}, {})).toBe('');
expect(buildQuotedEmailHeader({}, {}, {})).toBe('');
});
});
describe('formatQuotedTextAsBlockquote', () => {
it('formats single line text', () => {
const result = formatQuotedTextAsBlockquote('Hello world');
expect(result).toBe('> Hello world');
});
it('formats multi-line text', () => {
const text = 'Line 1\nLine 2\nLine 3';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n> Line 2\n> Line 3');
});
it('includes header if provided', () => {
const result = formatQuotedTextAsBlockquote('Hello', 'Header text');
expect(result).toContain('> Header text');
expect(result).toContain('>\n> Hello');
});
it('handles empty lines correctly', () => {
const text = 'Line 1\n\nLine 3';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n>\n> Line 3');
});
it('returns empty string for empty input', () => {
expect(formatQuotedTextAsBlockquote('')).toBe('');
expect(formatQuotedTextAsBlockquote('', '')).toBe('');
});
it('handles Windows line endings', () => {
const text = 'Line 1\r\nLine 2';
const result = formatQuotedTextAsBlockquote(text);
expect(result).toBe('> Line 1\n> Line 2');
});
});
describe('extractQuotedEmailText', () => {
it('extracts text from textContent.reply', () => {
const lastEmail = {
contentAttributes: {
email: { textContent: { reply: 'Reply text' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Reply text');
});
it('falls back to textContent.full', () => {
const lastEmail = {
contentAttributes: {
email: { textContent: { full: 'Full text' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Full text');
});
it('extracts from htmlContent and converts to plain text', () => {
const lastEmail = {
contentAttributes: {
email: { htmlContent: { reply: '<p>HTML reply</p>' } },
},
};
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('HTML reply');
});
it('uses fallback content if structured content not available', () => {
const lastEmail = { content: 'Fallback content' };
const result = extractQuotedEmailText(lastEmail);
expect(result).toBe('Fallback content');
});
it('returns empty string for null or missing email', () => {
expect(extractQuotedEmailText(null)).toBe('');
expect(extractQuotedEmailText({})).toBe('');
});
});
describe('truncatePreviewText', () => {
it('returns full text if under max length', () => {
const text = 'Short text';
const result = truncatePreviewText(text, 80);
expect(result).toBe('Short text');
});
it('truncates text exceeding max length', () => {
const text = 'A'.repeat(100);
const result = truncatePreviewText(text, 80);
expect(result).toHaveLength(80);
expect(result).toContain('...');
});
it('collapses multiple spaces', () => {
const text = 'Text with spaces';
const result = truncatePreviewText(text);
expect(result).toBe('Text with spaces');
});
it('trims whitespace', () => {
const text = ' Text with spaces ';
const result = truncatePreviewText(text);
expect(result).toBe('Text with spaces');
});
it('returns empty string for empty input', () => {
expect(truncatePreviewText('')).toBe('');
expect(truncatePreviewText(' ')).toBe('');
});
it('uses default max length of 80', () => {
const text = 'A'.repeat(100);
const result = truncatePreviewText(text);
expect(result).toHaveLength(80);
});
});
describe('appendQuotedTextToMessage', () => {
it('appends quoted text to message', () => {
const message = 'My reply';
const quotedText = 'Original message';
const header = 'On date sender wrote:';
const result = appendQuotedTextToMessage(message, quotedText, header);
expect(result).toContain('My reply');
expect(result).toContain('> On date sender wrote:');
expect(result).toContain('> Original message');
});
it('returns only quoted text if message is empty', () => {
const result = appendQuotedTextToMessage('', 'Quoted', 'Header');
expect(result).toContain('> Header');
expect(result).toContain('> Quoted');
expect(result).not.toContain('\n\n\n');
});
it('returns message if no quoted text', () => {
const result = appendQuotedTextToMessage('Message', '', '');
expect(result).toBe('Message');
});
it('handles proper spacing with double newline', () => {
const result = appendQuotedTextToMessage('Message', 'Quoted', 'Header');
expect(result).toContain('Message\n\n>');
});
it('does not add extra newlines if message already ends with newlines', () => {
const result = appendQuotedTextToMessage(
'Message\n\n',
'Quoted',
'Header'
);
expect(result).not.toContain('\n\n\n');
});
it('adds single newline if message ends with one newline', () => {
const result = appendQuotedTextToMessage('Message\n', 'Quoted', 'Header');
expect(result).toContain('Message\n\n>');
});
});
});
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "بحث",
"EMPTY_STATE": "لم يتم العثور على النتائج"
},
"CLOSE": "أغلق"
"CLOSE": "أغلق",
"BETA": "تجريبي",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "كود \"الماسنجر\"",
"MESSENGER_SUB_HEAD": "ضع هذا الكود داخل وسم الـ body في موقعك",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "وكيل الدعم",
"INBOX_AGENTS_SUB_TEXT": "إضافة أو إزالة وكلاء من صندوق الوارد هذا",
"AGENT_ASSIGNMENT": "تعيين المحادثة",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Търсене",
"EMPTY_STATE": "Няма намерени резултати"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Агенти",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Cercar",
"EMPTY_STATE": "No s'ha trobat agents"
},
"CLOSE": "Tanca"
"CLOSE": "Tanca",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Script del missatger",
"MESSENGER_SUB_HEAD": "Col·loca aquest botó dins de l'etiqueta body",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Afegir o eliminar agents d'aquesta safata d'entrada",
"AGENT_ASSIGNMENT": "Conversació Assignada",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Hledat",
"EMPTY_STATE": "Žádné výsledky"
},
"CLOSE": "Zavřít"
"CLOSE": "Zavřít",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger skript",
"MESSENGER_SUB_HEAD": "Umístěte toto tlačítko dovnitř vašeho tělesného štítku",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agenti",
"INBOX_AGENTS_SUB_TEXT": "Přidat nebo odebrat agenty z této složky doručené pošty",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Søg",
"EMPTY_STATE": "Ingen resultater fundet"
},
"CLOSE": "Luk"
"CLOSE": "Luk",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger- Script",
"MESSENGER_SUB_HEAD": "Placer denne knap inde i din body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agenter",
"INBOX_AGENTS_SUB_TEXT": "Tilføj eller fjern agenter fra denne indbakke",
"AGENT_ASSIGNMENT": "Samtale Tildeling",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Suchen",
"EMPTY_STATE": "Keine Ergebnisse gefunden"
},
"CLOSE": "Schließen"
"CLOSE": "Schließen",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger-Skript",
"MESSENGER_SUB_HEAD": "Platzieren Sie diese Schaltfläche in Ihrem Body-Tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agenten",
"INBOX_AGENTS_SUB_TEXT": "Hinzufügen oder Entfernen von Agenten zu diesem Posteingang",
"AGENT_ASSIGNMENT": "Konversationssauftrag",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Αναζήτηση",
"EMPTY_STATE": "Δεν βρέθηκαν αποτελέσματα"
},
"CLOSE": "Κλείσιμο"
"CLOSE": "Κλείσιμο",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Κώδικας (Script)",
"MESSENGER_SUB_HEAD": "Τοποθετήσετε αυτόν τον κώδικα μέσα στο body tag της ιστοσελίδας σας",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Πράκτορες",
"INBOX_AGENTS_SUB_TEXT": "Προσθέστε ή αφαιρέστε πράκτορες σε αυτό το κιβώτιο",
"AGENT_ASSIGNMENT": "Ανάθεση Συνομιλίας",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -227,6 +227,13 @@
"YES": "Send",
"CANCEL": "Cancel"
}
},
"QUOTED_REPLY": {
"ENABLE_TOOLTIP": "Include quoted email thread",
"DISABLE_TOOLTIP": "Don't include quoted email thread",
"REMOVE_PREVIEW": "Remove quoted email thread",
"COLLAPSE": "Collapse preview",
"EXPAND": "Expand preview"
}
},
"VISIBLE_TO_AGENTS": "Private Note: Only visible to you and your team",
@@ -5,6 +5,8 @@
"LEARN_MORE": "Learn more about inboxes",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"WHATSAPP_REGISTRATION_INCOMPLETE": "Your WhatsApp Business registration isn’t complete. Please check your display name status in Meta Business Manager before reconnecting.",
"COMPLETE_REGISTRATION": "Complete Registration",
"LIST": {
"404": "There are no inboxes attached to this account."
},
@@ -605,8 +607,62 @@
"BUSINESS_HOURS": "Business Hours",
"WIDGET_BUILDER": "Widget Builder",
"BOT_CONFIGURATION": "Bot Configuration",
"ACCOUNT_HEALTH": "Account Health",
"CSAT": "CSAT"
},
"ACCOUNT_HEALTH": {
"TITLE": "Manage your WhatsApp account",
"DESCRIPTION": "Review your WhatsApp account status, messaging limits, and quality. Update settings or resolve issues if needed",
"GO_TO_SETTINGS": "Go to Meta Business Manager",
"NO_DATA": "Health data is not available",
"FIELDS": {
"DISPLAY_PHONE_NUMBER": {
"LABEL": "Display phone number",
"TOOLTIP": "Phone number displayed to customers"
},
"VERIFIED_NAME": {
"LABEL": "Business name",
"TOOLTIP": "Business name verified by WhatsApp"
},
"DISPLAY_NAME_STATUS": {
"LABEL": "Display name status",
"TOOLTIP": "Status of your business name verification"
},
"QUALITY_RATING": {
"LABEL": "Quality rating",
"TOOLTIP": "WhatsApp quality rating for your account"
},
"MESSAGING_LIMIT_TIER": {
"LABEL": "Messaging limit tier",
"TOOLTIP": "Daily messaging limit for your account"
},
"ACCOUNT_MODE": {
"LABEL": "Account mode",
"TOOLTIP": "Current operating mode of your WhatsApp account"
}
},
"VALUES": {
"TIERS": {
"TIER_250": "250 customers per 24h",
"TIER_1000": "1K customers per 24h",
"TIER_1K": "1K customers per 24h",
"TIER_10K": "10K customers per 24h",
"TIER_100K": "100K customers per 24h",
"TIER_UNLIMITED": "Unlimited customers per 24h"
},
"STATUSES": {
"APPROVED": "Approved",
"PENDING_REVIEW": "Pending Review",
"AVAILABLE_WITHOUT_REVIEW": "Available Without Review",
"REJECTED": "Rejected",
"DECLINED": "Declined"
},
"MODES": {
"SANDBOX": "Sandbox",
"LIVE": "Live"
}
}
},
"SETTINGS": "Settings",
"FEATURES": {
"LABEL": "Features",
@@ -24,7 +24,7 @@
"CREATE_NEW_ACCOUNT": "Create a new account",
"SUBMIT": "Login",
"SAML": {
"LABEL": "Log in via SSO",
"LABEL": "Login via SSO",
"TITLE": "Initiate Single Sign-on (SSO)",
"SUBTITLE": "Enter your work email to access your organization",
"BACK_TO_LOGIN": "Login via Password",
@@ -27,15 +27,20 @@
"LABEL": "Password",
"PLACEHOLDER": "Password",
"ERROR": "Password is too short.",
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character."
"IS_INVALID_PASSWORD": "Password should contain atleast 1 uppercase letter, 1 lowercase letter, 1 number and 1 special character.",
"REQUIREMENTS_LENGTH": "At least 6 characters long",
"REQUIREMENTS_UPPERCASE": "At least one uppercase letter",
"REQUIREMENTS_LOWERCASE": "At least one lowercase letter",
"REQUIREMENTS_NUMBER": "At least one number",
"REQUIREMENTS_SPECIAL": "At least one special character"
},
"CONFIRM_PASSWORD": {
"LABEL": "Confirm password",
"PLACEHOLDER": "Confirm password",
"ERROR": "Password doesnot match."
"ERROR": "Passwords do not match."
},
"API": {
"SUCCESS_MESSAGE": "Registration Successfull",
"SUCCESS_MESSAGE": "Registration Successful",
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
},
"SUBMIT": "Create account",
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Buscar",
"EMPTY_STATE": "No se encontraron resultados"
},
"CLOSE": "Cerrar"
"CLOSE": "Cerrar",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Script de Messenger",
"MESSENGER_SUB_HEAD": "Coloca este botón dentro de tu etiqueta cuerpo",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agentes",
"INBOX_AGENTS_SUB_TEXT": "Añadir o quitar agentes de esta bandeja de entrada",
"AGENT_ASSIGNMENT": "Asignación de conversación",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "جستجو",
"EMPTY_STATE": "نتیجه‌ای یافت نشد"
},
"CLOSE": "بستن"
"CLOSE": "بستن",
"BETA": "آزمایشی",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "اسکریپت ویجت",
"MESSENGER_SUB_HEAD": "این دکمه را در تگ body قرار دهید",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "ایجنت ها",
"INBOX_AGENTS_SUB_TEXT": "اضافه کردن یا حذف کردن دسترسی ایجنت به صندوق ورودی",
"AGENT_ASSIGNMENT": "اختصاص گفتگو",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Etsi",
"EMPTY_STATE": "Tuloksia ei löytynyt"
},
"CLOSE": "Sulje"
"CLOSE": "Sulje",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger-skripti",
"MESSENGER_SUB_HEAD": "Aseta tämä painike body-tagiisi",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Edustajat",
"INBOX_AGENTS_SUB_TEXT": "Lisää tai poista edustajia tästä saapuneet-kansiosta",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Rechercher",
"EMPTY_STATE": "Aucun résultat trouvé"
},
"CLOSE": "Fermer"
"CLOSE": "Fermer",
"BETA": "Bêta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Script du Widget Web",
"MESSENGER_SUB_HEAD": "Placez ce code avant la fermeture de votre balise body",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Ajouter ou supprimer des agents de cette boîte de réception",
"AGENT_ASSIGNMENT": "Konversationsauftrag",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "חפש",
"EMPTY_STATE": "לא נמצאו תוצאות"
},
"CLOSE": "סגור"
"CLOSE": "סגור",
"BETA": "בטא",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "סקריפט מסנג'ר",
"MESSENGER_SUB_HEAD": "מקם את הכפתור הזה בתוך תג הגוף שלך",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "סוכנים",
"INBOX_AGENTS_SUB_TEXT": "הוסף או הסר נציגים מתיבת הדואר הנכנס הזו",
"AGENT_ASSIGNMENT": "שיוך שיחה",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "Nisu pronađeni rezultati"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agenti",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Keresés",
"EMPTY_STATE": "Nincs találat"
},
"CLOSE": "Bezárás"
"CLOSE": "Bezárás",
"BETA": "Béta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger szkript",
"MESSENGER_SUB_HEAD": "Ezt a gombot a body tag-en belül helyezd el",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Ügynökök",
"INBOX_AGENTS_SUB_TEXT": "Ügynökök hosszáadása vagy eltávolítása az inboxból",
"AGENT_ASSIGNMENT": "Beszélgetés hozzárendelés",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Cari",
"EMPTY_STATE": "Tidak ada hasil ditemukan"
},
"CLOSE": "Tutup"
"CLOSE": "Tutup",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Tempatkan tombol ini di dalam tag <body> Anda",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agen",
"INBOX_AGENTS_SUB_TEXT": "Tambahkan atau hapus agen dari kotak masuk ini",
"AGENT_ASSIGNMENT": "Tugas Percakapan",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Leit",
"EMPTY_STATE": "Engar niðurstöður fundust"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Þjónustufulltrúar",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Cerca",
"EMPTY_STATE": "Nessun risultato trovato"
},
"CLOSE": "Chiudi"
"CLOSE": "Chiudi",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Script Messenger",
"MESSENGER_SUB_HEAD": "Posiziona questo pulsante all'interno del tuo tag body",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agenti",
"INBOX_AGENTS_SUB_TEXT": "Aggiungi o rimuovi agenti da questa casella",
"AGENT_ASSIGNMENT": "Assegnazione conversazione",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "検索",
"EMPTY_STATE": "結果が見つかりませんでした。"
},
"CLOSE": "閉じる"
"CLOSE": "閉じる",
"BETA": "ベータ版",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messengerスクリプト",
"MESSENGER_SUB_HEAD": "このボタンをbodyタグの中に配置してください。",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "担当者",
"INBOX_AGENTS_SUB_TEXT": "この受信トレイから担当者を追加または削除する",
"AGENT_ASSIGNMENT": "会話の割り当て",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Search",
"EMPTY_STATE": "No results found"
},
"CLOSE": "Close"
"CLOSE": "Close",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "검색",
"EMPTY_STATE": "검색 결과가 없습니다"
},
"CLOSE": "닫기"
"CLOSE": "닫기",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "메신저 스크립트",
"MESSENGER_SUB_HEAD": "이 버튼을 당신의 body 태그 안에 넣으세요.",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "에이전트",
"INBOX_AGENTS_SUB_TEXT": "받은 메시지함에서 에이전트 추가 또는 제거",
"AGENT_ASSIGNMENT": "Conversation Assignment",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Ieškoti",
"EMPTY_STATE": "Nieko nerasta"
},
"CLOSE": "Uždaryti"
"CLOSE": "Uždaryti",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger scenarijus",
"MESSENGER_SUB_HEAD": "Įdėkite šį mygtuką savo <body> žymos viduje",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Agentai",
"INBOX_AGENTS_SUB_TEXT": "Pridėti ar pašalinti agentus iš gautų laiškų aplanko",
"AGENT_ASSIGNMENT": "Pokalbio paskirstymas",
@@ -34,7 +34,7 @@
},
"SUBMIT": "Continue with SSO",
"API": {
"ERROR_MESSAGE": "SSO authentication failed"
"ERROR_MESSAGE": "SSO authentication failed. Please check your credentials and try again."
}
}
}
@@ -5,6 +5,8 @@
"PLACEHOLDER": "Meklēt",
"EMPTY_STATE": "Nav atrasts"
},
"CLOSE": "Aizvērt"
"CLOSE": "Aizvērt",
"BETA": "Beta",
"BETA_DESCRIPTION": "This feature is in beta and may change as we improve it."
}
}
@@ -618,6 +618,11 @@
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Skripts",
"MESSENGER_SUB_HEAD": "Ievietojiet šo pogu savā body tagā",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
},
"INBOX_AGENTS": "Aģenti",
"INBOX_AGENTS_SUB_TEXT": "Pievienot vai noņemt aģentus no šīs iesūtnes",
"AGENT_ASSIGNMENT": "Sarunas Piešķiršana",

Some files were not shown because too many files have changed in this diff Show More