Merge branch 'feat/scenario-agents' of github.com:chatwoot/chatwoot into feat/scenario-agents

This commit is contained in:
Shivam Mishra
2025-08-05 12:17:47 +05:30
25 changed files with 565 additions and 251 deletions
+1 -1
View File
@@ -1 +1 @@
3.4.1
3.4.2
@@ -63,11 +63,12 @@ const lastActivityAt = computed(() => {
});
const menuItems = computed(() => [
{ key: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
{
key: isUnread.value ? 'mark_as_read' : 'mark_as_unread',
icon: isUnread.value ? 'mail' : 'mail-unread',
label: t(`INBOX.MENU_ITEM.MARK_AS_${isUnread.value ? 'READ' : 'UNREAD'}`),
},
{ key: 'delete', icon: 'delete', label: t('INBOX.MENU_ITEM.DELETE') },
]);
const messageClasses = computed(() => ({
@@ -1,94 +0,0 @@
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import { useAccount } from 'dashboard/composables/useAccount';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
data() {
return { conversationMeta: {} };
},
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
getAccount: 'accounts/getAccount',
}),
bannerMessage() {
return this.$t('GENERAL_SETTINGS.LIMITS_UPGRADE');
},
actionButtonMessage() {
return this.$t('GENERAL_SETTINGS.OPEN_BILLING');
},
shouldShowBanner() {
if (!this.isOnChatwootCloud) {
return false;
}
if (this.isTrialAccount()) {
return false;
}
return this.isLimitExceeded();
},
},
mounted() {
if (this.isOnChatwootCloud) {
this.fetchLimits();
}
},
methods: {
fetchLimits() {
this.$store.dispatch('accounts/limits');
},
routeToBilling() {
this.$router.push({
name: 'billing_settings_index',
params: { accountId: this.accountId },
});
},
isTrialAccount() {
// check if account is less than 15 days old
const account = this.getAccount(this.accountId);
if (!account) return false;
const createdAt = new Date(account.created_at);
const diffDays = differenceInDays(new Date(), createdAt);
return diffDays <= 15;
},
isLimitExceeded() {
const account = this.getAccount(this.accountId);
if (!account) return false;
const { limits } = account;
if (!limits) return false;
const { conversation, non_web_inboxes: nonWebInboxes } = limits;
return this.testLimit(conversation) || this.testLimit(nonWebInboxes);
},
testLimit({ allowed, consumed }) {
return consumed > allowed;
},
},
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@primary-action="routeToBilling"
/>
</template>
@@ -4,6 +4,7 @@ import { useStore } from 'dashboard/composables/store';
import Copilot from 'dashboard/components-next/copilot/Copilot.vue';
import { useMapGetter } from 'dashboard/composables/store';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useConfig } from 'dashboard/composables/useConfig';
import { useWindowSize } from '@vueuse/core';
import { vOnClickOutside } from '@vueuse/components';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -18,6 +19,7 @@ defineProps({
const store = useStore();
const { uiSettings, updateUISettings } = useUISettings();
const { isEnterprise } = useConfig();
const { width: windowWidth } = useWindowSize();
const currentUser = useMapGetter('getCurrentUser');
@@ -82,6 +84,9 @@ const setAssistant = async assistant => {
};
const shouldShowCopilotPanel = computed(() => {
if (!isEnterprise) {
return false;
}
const isCaptainEnabled = isFeatureEnabledonAccount.value(
currentAccountId.value,
FEATURE_FLAGS.CAPTAIN
@@ -113,7 +118,9 @@ const sendMessage = async message => {
};
onMounted(() => {
store.dispatch('captainAssistants/get');
if (isEnterprise) {
store.dispatch('captainAssistants/get');
}
});
</script>
@@ -68,6 +68,10 @@ export default {
type: Boolean,
default: false,
},
allowedContextMenuOptions: {
type: Array,
default: () => [],
},
},
emits: [
'contextMenuToggle',
@@ -151,11 +155,9 @@ export default {
hasSlaPolicyId() {
return this.chat?.sla_policy_id;
},
},
methods: {
onCardClick(e) {
conversationPath() {
const { activeInbox, chat } = this;
const path = frontendURL(
return frontendURL(
conversationUrl({
accountId: this.accountId,
activeInbox,
@@ -166,18 +168,26 @@ export default {
conversationType: this.conversationType,
})
);
},
},
methods: {
onCardClick(e) {
const path = this.conversationPath;
if (!path) return;
// Handle Ctrl/Cmd + Click for new tab
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
window.chatwootConfig.hostURL + path,
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener noreferrer nofollow'
'noopener,noreferrer'
);
return;
}
if (this.isActiveChat) {
return;
}
// Skip if already active
if (this.isActiveChat) return;
router.push({ path });
},
@@ -359,6 +369,8 @@ export default {
:priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread"
:conversation-url="conversationPath"
:allowed-options="allowedContextMenuOptions"
@update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent"
@assign-label="onAssignLabel"
@@ -367,6 +379,7 @@ export default {
@mark-as-read="markAsRead"
@assign-priority="assignPriority"
@delete-conversation="deleteConversation"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
@@ -1,5 +1,8 @@
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useAlert } from 'dashboard/composables';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import {
getSortedAgentsByAvailability,
getAgentsByUpdatedPresence,
@@ -8,7 +11,20 @@ import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants/globals';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
const MENU = {
MARK_AS_READ: 'mark-as-read',
MARK_AS_UNREAD: 'mark-as-unread',
PRIORITY: 'priority',
STATUS: 'status',
SNOOZE: 'snooze',
AGENT: 'agent',
TEAM: 'team',
LABEL: 'label',
DELETE: 'delete',
OPEN_NEW_TAB: 'open-new-tab',
COPY_LINK: 'copy-link',
};
export default {
components: {
@@ -37,6 +53,14 @@ export default {
type: String,
default: null,
},
conversationUrl: {
type: String,
default: '',
},
allowedOptions: {
type: Array,
default: () => [],
},
},
emits: [
'updateConversation',
@@ -47,6 +71,7 @@ export default {
'assignTeam',
'assignLabel',
'deleteConversation',
'close',
],
setup() {
const { isAdmin } = useAdmin();
@@ -56,6 +81,7 @@ export default {
},
data() {
return {
MENU,
STATUS_TYPE: wootConstants.STATUS_TYPE,
readOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_READ'),
@@ -63,7 +89,7 @@ export default {
},
unreadOption: {
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.MARK_AS_UNREAD'),
icon: 'mail',
icon: 'mail-unread',
},
statusMenuConfig: [
{
@@ -88,7 +114,7 @@ export default {
icon: 'snooze',
},
priorityConfig: {
key: 'priority',
key: MENU.PRIORITY,
label: this.$t('CONVERSATION.PRIORITY.TITLE'),
icon: 'warning',
options: [
@@ -115,25 +141,35 @@ export default {
].filter(item => item.key !== this.priority),
},
labelMenuConfig: {
key: 'label',
key: MENU.LABEL,
icon: 'tag',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_LABEL'),
},
agentMenuConfig: {
key: 'agent',
key: MENU.AGENT,
icon: 'person-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_AGENT'),
},
teamMenuConfig: {
key: 'team',
key: MENU.TEAM,
icon: 'people-team-add',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.ASSIGN_TEAM'),
},
deleteOption: {
key: 'delete',
key: MENU.DELETE,
icon: 'delete',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.DELETE'),
},
openInNewTabOption: {
key: MENU.OPEN_NEW_TAB,
icon: 'open',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.OPEN_IN_NEW_TAB'),
},
copyLinkOption: {
key: MENU.COPY_LINK,
icon: 'copy',
label: this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK'),
},
};
},
computed: {
@@ -180,6 +216,10 @@ export default {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
},
methods: {
isAllowed(keys) {
if (!this.allowedOptions.length) return true;
return keys.some(key => this.allowedOptions.includes(key));
},
toggleStatus(status, snoozedUntil) {
this.$emit('updateConversation', status, snoozedUntil);
},
@@ -194,6 +234,24 @@ export default {
deleteConversation() {
this.$emit('deleteConversation', this.chatId);
},
openInNewTab() {
if (!this.conversationUrl) return;
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
window.open(url, '_blank', 'noopener,noreferrer');
this.$emit('close');
},
async copyConversationLink() {
if (!this.conversationUrl) return;
try {
const url = `${window.chatwootConfig.hostURL}${this.conversationUrl}`;
await copyTextToClipboard(url);
useAlert(this.$t('CONVERSATION.CARD_CONTEXT_MENU.COPY_LINK_SUCCESS'));
this.$emit('close');
} catch (error) {
// error
}
},
show(key) {
// If the conversation status is same as the action, then don't display the option
// i.e.: Don't show an option to resolve if the conversation is already resolved.
@@ -217,83 +275,114 @@ export default {
</script>
<template>
<div class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px]">
<MenuItem
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<template v-for="option in statusMenuConfig">
<div
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<template v-if="isAllowed([MENU.MARK_AS_READ, MENU.MARK_AS_UNREAD])">
<MenuItem
v-if="show(option.key)"
:key="option.key"
:option="option"
v-if="!hasUnreadMessages"
:option="unreadOption"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
@click.stop="$emit('markAsUnread')"
/>
<MenuItem
v-else
:option="readOption"
variant="icon"
@click.stop="$emit('markAsRead')"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<MenuItem
v-if="showSnooze"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItemWithSubmenu :option="priorityConfig">
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<template v-if="isAllowed([MENU.STATUS, MENU.SNOOZE])">
<template v-for="option in statusMenuConfig">
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
v-if="show(option.key) && isAllowed([MENU.STATUS])"
:key="option.key"
:option="option"
variant="icon"
@click.stop="toggleStatus(option.key, null)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
v-if="showSnooze && isAllowed([MENU.SNOOZE])"
:option="snoozeOption"
variant="icon"
@click.stop="snoozeConversation()"
/>
</MenuItemWithSubmenu>
<template v-if="isAdmin">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template
v-if="isAllowed([MENU.PRIORITY, MENU.LABEL, MENU.AGENT, MENU.TEAM])"
>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.PRIORITY])"
:option="priorityConfig"
>
<MenuItem
v-for="(option, i) in priorityConfig.options"
:key="i"
:option="option"
@click.stop="assignPriority(option.key)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.LABEL])"
:option="labelMenuConfig"
:sub-menu-available="!!labels.length"
>
<MenuItem
v-for="label in labels"
:key="label.id"
:option="generateMenuLabelConfig(label, 'label')"
variant="label"
@click.stop="$emit('assignLabel', label)"
/>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.AGENT])"
:option="agentMenuConfig"
:sub-menu-available="!!assignableAgents.length"
>
<AgentLoadingPlaceholder v-if="assignableAgentsUiFlags.isFetching" />
<template v-else>
<MenuItem
v-for="agent in assignableAgents"
:key="agent.id"
:option="generateMenuLabelConfig(agent, 'agent')"
variant="agent"
@click.stop="$emit('assignAgent', agent)"
/>
</template>
</MenuItemWithSubmenu>
<MenuItemWithSubmenu
v-if="isAllowed([MENU.TEAM])"
:option="teamMenuConfig"
:sub-menu-available="!!teams.length"
>
<MenuItem
v-for="team in teams"
:key="team.id"
:option="generateMenuLabelConfig(team, 'team')"
@click.stop="$emit('assignTeam', team)"
/>
</MenuItemWithSubmenu>
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
</template>
<template v-if="isAllowed([MENU.OPEN_NEW_TAB, MENU.COPY_LINK])">
<MenuItem
v-if="isAllowed([MENU.OPEN_NEW_TAB])"
:option="openInNewTabOption"
variant="icon"
@click.stop="openInNewTab"
/>
<MenuItem
v-if="isAllowed([MENU.COPY_LINK])"
:option="copyLinkOption"
variant="icon"
@click.stop="copyConversationLink"
/>
</template>
<template v-if="isAdmin && isAllowed([MENU.DELETE])">
<hr class="m-1 rounded border-b border-n-weak dark:border-n-weak" />
<MenuItem
:option="deleteOption"
@@ -1,20 +1,16 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
export default {
components: {
Thumbnail,
<script setup>
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
defineProps({
option: {
type: Object,
default: () => {},
},
props: {
option: {
type: Object,
default: () => {},
},
variant: {
type: String,
default: 'default',
},
variant: {
type: String,
default: 'default',
},
};
});
</script>
<template>
@@ -30,12 +26,12 @@ export default {
class="label-pill flex-shrink-0"
:style="{ backgroundColor: option.color }"
/>
<Thumbnail
<Avatar
v-if="variant === 'agent'"
:username="option.label"
:name="option.label"
:src="option.thumbnail"
:status="option.status"
size="20px"
:status="option.status === 'online' ? option.status : null"
:size="20"
class="flex-shrink-0"
/>
<p class="menu-label truncate min-w-0 flex-1">
@@ -1,12 +1,14 @@
import { computed } from 'vue';
import { useStore } from 'dashboard/composables/store.js';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useCamelCase } from 'dashboard/composables/useTransformKeys';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
export function useCaptain() {
const store = useStore();
const { isCloudFeatureEnabled, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const captainEnabled = computed(() => {
return isCloudFeatureEnabled(FEATURE_FLAGS.CAPTAIN);
@@ -33,7 +35,9 @@ export function useCaptain() {
});
const fetchLimits = () => {
store.dispatch('accounts/limits');
if (isEnterprise) {
store.dispatch('accounts/limits');
}
};
return {
@@ -144,6 +144,9 @@
"AGENTS_LOADING": "Loading agents...",
"ASSIGN_TEAM": "Assign team",
"DELETE": "Delete conversation",
"OPEN_IN_NEW_TAB": "Open in new tab",
"COPY_LINK": "Copy conversation link",
"COPY_LINK_SUCCESS": "Conversation link copied to clipboard",
"API": {
"AGENT_ASSIGNMENT": {
"SUCCESFUL": "Conversation id {conversationId} assigned to \"{agentName}\"",
@@ -60,6 +60,8 @@ export default {
:chat="conversation"
:hide-inbox-name="false"
hide-thumbnail
enable-context-menu
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
class="compact"
/>
</div>
@@ -246,7 +246,7 @@ onMounted(() => {
:key="notificationItem.id"
:inbox-item="notificationItem"
:state-inbox="stateInbox(notificationItem.primaryActor?.inboxId)"
class="inbox-card rounded-lg hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
class="inbox-card rounded-none hover:rounded-lg hover:bg-n-alpha-1 dark:hover:bg-n-alpha-3"
:class="
currentConversationId === notificationItem.primaryActor?.id
? 'bg-n-alpha-1 dark:bg-n-alpha-3 rounded-lg active'
@@ -1,32 +1,27 @@
<script>
<script setup>
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import MenuItem from './MenuItem.vue';
import MenuItem from 'dashboard/components/widgets/conversation/contextMenu/menuItem.vue';
export default {
components: {
MenuItem,
ContextMenu,
defineProps({
contextMenuPosition: {
type: Object,
default: () => ({}),
},
props: {
contextMenuPosition: {
type: Object,
default: () => ({}),
},
menuItems: {
type: Array,
default: () => [],
},
},
emits: ['close', 'selectAction'],
methods: {
handleClose() {
this.$emit('close');
},
onMenuItemClick(key) {
this.$emit('selectAction', key);
this.handleClose();
},
menuItems: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['close', 'selectAction']);
const handleClose = () => {
emit('close');
};
const onMenuItemClick = key => {
emit('selectAction', key);
handleClose();
};
</script>
@@ -37,12 +32,14 @@ export default {
@close="handleClose"
>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] w-40 py-2 px-2 outline outline-1 outline-n-container shadow-lg rounded-xl"
class="p-1 rounded-md shadow-xl bg-n-alpha-3/50 backdrop-blur-[100px] outline-1 outline outline-n-weak/50"
>
<MenuItem
v-for="item in menuItems"
:key="item.key"
:label="item.label"
:option="item"
variant="icon"
class="!w-48"
@click.stop="onMenuItemClick(item.key)"
/>
</div>
@@ -4,6 +4,7 @@ import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useRouter } from 'vue-router';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { differenceInDays } from 'date-fns';
import { useAdmin } from 'dashboard/composables/useAdmin';
import { useI18n } from 'vue-i18n';
@@ -22,6 +23,7 @@ const router = useRouter();
const store = useStore();
const { t } = useI18n();
const { accountId, currentAccount } = useAccount();
const { isEnterprise } = useConfig();
const { isAdmin } = useAdmin();
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
@@ -100,7 +102,11 @@ const routeToBilling = () => {
});
};
onMounted(() => fetchLimits());
onMounted(() => {
if (isEnterprise) {
fetchLimits();
}
});
defineExpose({ shouldShowUpgradePage });
</script>
@@ -1,5 +1,6 @@
class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
queue_as :scheduled_jobs
include BillingHelper
def perform
email_inboxes = Inbox.where(channel_type: 'Channel::Email')
@@ -11,6 +12,13 @@ class Inboxes::FetchImapEmailInboxesJob < ApplicationJob
private
def should_fetch_emails?(inbox)
inbox.channel.imap_enabled && !inbox.account.suspended?
return false if inbox.account.suspended?
return false unless inbox.channel.imap_enabled
return false if inbox.channel.reauthorization_required?
return true unless ChatwootApp.chatwoot_cloud?
return false if default_plan?(inbox.account)
true
end
end
+19 -1
View File
@@ -37,10 +37,11 @@ class HookListener < BaseListener
private
def execute_hooks(event, message)
message.account.hooks.each do |hook|
message.account.hooks.find_each do |hook|
# In case of dialogflow, we would have a hook for each inbox.
# Which means we will execute the same hook multiple times if the below filter isn't there
next if hook.inbox.present? && hook.inbox != message.inbox
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, message: message)
end
@@ -48,7 +49,24 @@ class HookListener < BaseListener
def execute_account_hooks(event, account, event_data = {})
account.hooks.account_hooks.find_each do |hook|
next unless supported_hook_event?(hook, event.name)
HookJob.perform_later(hook, event.name, event_data)
end
end
def supported_hook_event?(hook, event_name)
return false if hook.disabled?
supported_events_map = {
'slack' => ['message.created'],
'dialogflow' => ['message.created', 'message.updated'],
'google_translate' => ['message.created'],
'leadsquared' => ['contact.updated', 'conversation.created', 'conversation.resolved']
}
return false unless supported_events_map.key?(hook.app_id)
supported_events_map[hook.app_id].include?(event_name)
end
end
@@ -4,16 +4,17 @@ module IncomingEmailValidityHelper
def incoming_email_from_valid_email?
return false unless valid_external_email_for_active_account?
# Return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
# Process bounced emails, as regular emails
return true if @processed_mail.bounced?
# we skip processing auto reply emails like delivery status notifications
# out of office replies, etc.
return false if auto_reply_email?
# return if email doesn't have a valid sender
# This can happen in cases like bounce emails for invalid contact email address
# TODO: Handle the bounce separately and mark the contact as invalid in case of reply bounces
# The returned value could be "\"\"" for some email clients
return false unless Devise.email_regexp.match?(@processed_mail.original_sender)
true
end
+4
View File
@@ -157,6 +157,10 @@ class MailPresenter < SimpleDelegator
auto_submitted? || x_auto_reply?
end
def bounced?
@mail.bounced? || @mail['X-Failed-Recipients'].try(:value).present?
end
def notification_email_from_chatwoot?
# notification emails are send via mailer sender email address. so it should match
original_sender == Mail::Address.new(ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')).address
@@ -156,11 +156,18 @@ class Whatsapp::IncomingMessageBaseService
phones = contact[:phones]
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
name_info = contact['name'] || {}
contact_meta = {
firstName: name_info['first_name'],
lastName: name_info['last_name']
}.compact
phones.each do |phone|
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type(message_type),
fallback_title: phone[:phone].to_s
fallback_title: phone[:phone].to_s,
meta: contact_meta
)
end
end
+6 -5
View File
@@ -2,7 +2,7 @@
# Description: Install and manage a Chatwoot installation.
# OS: Ubuntu 20.04 LTS, 22.04 LTS, 24.04 LTS
# Script Version: 3.4.1
# Script Version: 3.4.2
# Run this script as root
set -eu -o errexit -o pipefail -o noclobber -o nounset
@@ -990,7 +990,7 @@ EOF
# Check if CW_VERSION is 4.0 or above
if [[ "$(printf '%s\n' "$CW_VERSION" "4.0" | sort -V | head -n 1)" == "4.0" ]]; then
echo "Chatwoot v4.0 and above requires pgvector support in PostgreSQL."
read -p "Does your postgres support pgvector and want to proceed with the upgrade? [Y/n]: " user_input
read -p "Does your postgres support pgvector and want to proceed with the upgrade? [y/N]: " user_input
user_input=${user_input:-Y}
if [[ "$user_input" =~ ^([yY][eE][sS]|[yY])$ ]]; then
echo "Proceeding with the upgrade..."
@@ -1005,6 +1005,7 @@ EOF
upgrade_redis
upgrade_node
get_pnpm
sudo -i -u chatwoot << EOF
# Navigate to the Chatwoot directory
@@ -1016,9 +1017,9 @@ EOF
# Ensure the ruby version is upto date
# Parse the latest ruby version
latest_ruby_version="$(cat '.ruby-version')"
rvm install "ruby-$latest_ruby_version"
rvm use "$latest_ruby_version" --default
latest_ruby_version="\$(cat '.ruby-version')"
rvm install "ruby-\$latest_ruby_version"
rvm use "\$latest_ruby_version" --default
# Update dependencies
bundle
@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
context 'when chatwoot_cloud is enabled' do
let(:account) { create(:account) }
let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
let(:imap_email_channel) { create(:channel_email, imap_enabled: true, account: account) }
let(:premium_imap_channel) { create(:channel_email, imap_enabled: true, account: premium_account) }
before do
premium_account.custom_attributes['plan_name'] = 'Startups'
InstallationConfig.where(name: 'DEPLOYMENT_ENV').first_or_create!(value: 'cloud')
InstallationConfig.where(name: 'CHATWOOT_CLOUD_PLANS').first_or_create!(value: [{ 'name' => 'Hacker' }])
end
it 'skips inboxes with default plan' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(imap_email_channel)
described_class.perform_now
end
it 'processes inboxes with premium plan' do
expect(Inboxes::FetchImapEmailsJob).to receive(:perform_later).with(premium_imap_channel)
described_class.perform_now
end
end
end
+120
View File
@@ -0,0 +1,120 @@
Delivered-To: robert.smith@gmail.com
Return-Path: <>
Subject: Delivery Status Notification (Failure)
From: Mail Delivery Subsystem <mailer-daemon@googlemail.com>
To: robert.smith@gmail.com
Content-Type: multipart/report; boundary="00000000000093475906390e1e9b"; report-type=delivery-status
Auto-Submitted: auto-replied
Message-ID: <686707c9.050a0220.302e7d.0cb2.GMR@mx.google.com>
Date: Thu, 03 Jul 2025 15:44:25 -0700 (PDT)
X-Failed-Recipients: alex.jones@fictionalcorp.com
--00000000000093475906390e1e9b
Content-Type: multipart/related; boundary="000000000000936d8406390e1ec7"
--000000000000936d8406390e1ec7
Content-Type: multipart/alternative; boundary="000000000000936d9006390e1ec8"
--000000000000936d9006390e1ec8
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
** Address not found **
Your message wasn't delivered to alex.jones@fictionalcorp.com because the address co=
uldn't be found or is unable to receive email.
Learn more here: https://support.google.com/mail/?p=3DNoSuchUser
The response was:
550 5.1.1 The email account that you tried to reach does not exist. Please =
try double-checking the recipient's email address for typos or unnecessary =
spaces. For more information, go to https://support.google.com/mail/?p=3DNo=
SuchUser d2e1a72fcca58-74ce2b0525csor332154b3a.0 - gsmtp
--000000000000936d9006390e1ec8
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
<html>
<head>
<style>
* {
font-family:Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
}
</style>
</head>
<body>
<table cellpadding=3D"0" cellspacing=3D"0" class=3D"email-wrapper" style=3D=
"padding-top:32px;background-color:#ffffff;"><tbody>
<tr><td>
<table cellpadding=3D0 cellspacing=3D0><tbody>
<tr><td style=3D"max-width:560px;padding:24px 24px 32px;background-color:#f=
afafa;border:1px solid #e0e0e0;border-radius:2px">
<img style=3D"padding:0 24px 16px 0;float:left" width=3D72 height=3D72 alt=
=3D"Error Icon" src=3D"cid:icon.png">
<table style=3D"min-width:272px;padding-top:8px"><tbody>
<tr><td><h2 style=3D"font-size:20px;color:#212121;font-weight:bold;margin:0=
">
Address not found
</h2></td></tr>
<tr><td style=3D"padding-top:20px;color:#757575;font-size:16px;font-weight:=
normal;text-align:left">
Your message wasn't delivered to <a style=3D'color:#212121;text-decoration:=
none'><b>alex.jones@fictionalcorp.com</b></a> because the address couldn't be found =
or is unable to receive email.
</td></tr>
<tr><td style=3D"padding-top:24px;color:#4285F4;font-size:14px;font-weight:=
bold;text-align:left">
<a style=3D"text-decoration:none" href=3D"https://support.google.com/mail/?=
p=3DNoSuchUser">LEARN MORE</a>
</td></tr>
</tbody></table>
</td></tr>
</tbody></table>
</td></tr>
<tr style=3D"border:none;background-color:#fff;font-size:12.8px;width:90%">
<td align=3D"left" style=3D"padding:48px 10px">
The response was:<br/>
<p style=3D"font-family:monospace">
550 5.1.1 The email account that you tried to reach does not exist. Please =
try double-checking the recipient's email address for typos or unnecessary =
spaces. For more information, go to https://support.google.com/mail/?p=3DNo=
SuchUser d2e1a72fcca58-74ce2b0525csor332154b3a.0 - gsmtp
</p>
</td>
</tr>
</tbody></table>
</body>
</html>
--000000000000936d9006390e1ec8--
--000000000000936d8406390e1ec7
Content-Type: image/png; name="icon.png"
Content-Disposition: attachment; filename="icon.png"
Content-Transfer-Encoding: base64
Content-ID: <icon.png>
--000000000000936d8406390e1ec7--
--00000000000093475906390e1e9b
Content-Type: message/delivery-status
--00000000000093475906390e1e9b
Content-Type: message/rfc822
Date: Thu, 03 Jul 2025 15:44:23 -0700
From: Robert Smith <robert.smith@gmail.com>
Reply-To: robert.smith@gmail.com
To: alex.jones@fictionalcorp.com
Message-ID: <conversation/93775311-9416-4428-964b-862001f8a8b2/messages/27855337@gmail.com>
In-Reply-To: <account/1/conversation/93775311-9416-4428-964b-862001f8a8b2@gmail.com>
Subject: Just checking in
Mime-Version: 1.0
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: 7bit
<p>Hey, just checking in. Let me know if you got my earlier message.</p>
--00000000000093475906390e1e9b--
@@ -3,6 +3,7 @@ require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailInboxesJob do
let(:account) { create(:account) }
let(:suspended_account) { create(:account, status: 'suspended') }
let(:premium_account) { create(:account, custom_attributes: { plan_name: 'Startups' }) }
let(:imap_email_channel) do
create(:channel_email, imap_enabled: true, account: account)
@@ -16,6 +17,19 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
create(:channel_email, imap_enabled: false, account: account)
end
let(:reauth_required_channel) do
create(:channel_email, imap_enabled: true, account: account)
end
let(:premium_imap_channel) do
create(:channel_email, imap_enabled: true, account: premium_account)
end
before do
reauth_required_channel.prompt_reauthorization!
premium_account.custom_attributes['plan_name'] = 'Startups'
end
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
.on_queue('scheduled_jobs')
@@ -44,5 +58,11 @@ RSpec.describe Inboxes::FetchImapEmailInboxesJob do
described_class.perform_now
end
it 'skips channels requiring reauthorization' do
expect(Inboxes::FetchImapEmailsJob).not_to receive(:perform_later).with(reauth_required_channel)
described_class.perform_now
end
end
end
+81 -1
View File
@@ -10,6 +10,8 @@ describe HookListener do
account: account, inbox: inbox, conversation: conversation)
end
let!(:event) { Events::Base.new(event_name, Time.zone.now, message: message) }
let(:contact_event) { Events::Base.new('contact.updated', Time.zone.now, contact: conversation.contact) }
let(:conversation_event) { Events::Base.new('conversation.created', Time.zone.now, conversation: conversation) }
describe '#message_created' do
let(:event_name) { 'message.created' }
@@ -42,10 +44,88 @@ describe HookListener do
context 'when hook is configured' do
it 'triggers hook job' do
hook = create(:integrations_hook, account: account)
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
expect(HookJob).to receive(:perform_later).with(hook, 'message.updated', message: message).once
listener.message_updated(event)
end
end
end
describe 'hook job enqueuing behavior' do
let(:event_name) { 'message.created' }
context 'when app_id is not in the allowed list' do
it 'does not enqueue the job' do
create(:integrations_hook, account: account, app_id: 'unsupported_app')
expect(HookJob).not_to receive(:perform_later)
listener.message_created(event)
end
end
context 'when hook is enabled and app_id is supported' do
it 'enqueues the job for slack' do
hook = create(:integrations_hook, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
it 'enqueues the job for dialogflow' do
hook = create(:integrations_hook, :dialogflow, account: account, inbox: inbox)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
it 'enqueues the job for google_translate' do
hook = create(:integrations_hook, :google_translate, account: account)
expect(HookJob).to receive(:perform_later).with(hook, event_name, message: message)
listener.message_created(event)
end
end
context 'with disabled hook' do
it 'does not enqueue job for disabled hooks' do
create(:integrations_hook, account: account, status: 'disabled', app_id: 'slack')
expect(HookJob).not_to receive(:perform_later)
listener.message_created(event)
end
end
context 'with unsupported app_id and event combination' do
it 'does not enqueue job for unsupported app_id' do
create(:integrations_hook, account: account, app_id: 'unsupported_app')
expect(HookJob).not_to receive(:perform_later)
listener.message_created(event)
end
end
context 'with leadsquared hook' do
let(:hook) { create(:integrations_hook, :leadsquared, account: account) }
before do
account.enable_features(:crm_integration)
end
it 'enqueues the job for conversation.created' do
expect(HookJob)
.to receive(:perform_later)
.with(hook, 'conversation.created', { conversation: conversation })
listener.conversation_created(conversation_event)
end
it 'enqueues the job for contact.updated' do
expect(HookJob)
.to receive(:perform_later)
.with(hook, 'contact.updated', { contact: conversation.contact })
listener.contact_updated(contact_event)
end
end
end
end
+8
View File
@@ -115,6 +115,14 @@ RSpec.describe Imap::ImapMailbox do
end
end
context 'when the email is bounced' do
let!(:bounced_mail) { create_inbound_email_from_fixture('bounced_gmail.eml') }
it 'processes the bounced email' do
expect { class_instance.process(bounced_mail.mail, channel) }.to change(Message, :count)
end
end
context 'when a reply for existing email conversation' do
let(:prev_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
let(:reply_mail) do
@@ -267,19 +267,16 @@ describe Whatsapp::IncomingMessageService do
] }] }.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(Contact.all.first.name).to eq('Kedar')
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
# Two messages are tested deliberately to ensure multiple contact attachments work.
m1 = whatsapp_channel.inbox.messages.first
contact_attachments = m1.attachments.first
expect(m1.content).to eq('Apple Inc.')
expect(contact_attachments.fallback_title).to eq('+911800')
expect(m1.attachments.first.fallback_title).to eq('+911800')
expect(m1.attachments.first.meta).to eq({})
m2 = whatsapp_channel.inbox.messages.last
contact_attachments = m2.attachments.first
expect(m2.content).to eq('Chatwoot')
expect(contact_attachments.fallback_title).to eq('+1 (415) 341-8386')
expect(m2.attachments.first.meta).to eq({ 'firstName' => 'Chatwoot' })
end
end