Merge branch 'develop' into fix/conversation-tabs-limit

This commit is contained in:
Shivam Mishra
2025-11-13 13:03:22 +05:30
committed by GitHub
64 changed files with 3799 additions and 783 deletions
+32 -43
View File
@@ -1,5 +1,8 @@
class Messages::MessageBuilder
include ::FileTypeHelper
include ::EmailHelper
include ::DataHelper
attr_reader :message
def initialize(user, conversation, params)
@@ -38,30 +41,12 @@ class Messages::MessageBuilder
params = convert_to_hash(@params)
content_attributes = params.fetch(:content_attributes, {})
return parse_json(content_attributes) if content_attributes.is_a?(String)
return safe_parse_json(content_attributes) if content_attributes.is_a?(String)
return content_attributes if content_attributes.is_a?(Hash)
{}
end
# Converts the given object to a hash.
# If it's an instance of ActionController::Parameters, converts it to an unsafe hash.
# Otherwise, returns the object as-is.
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
# Attempts to parse a string as JSON.
# If successful, returns the parsed hash with symbolized names.
# If unsuccessful, returns nil.
def parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
def process_attachments
return if @attachments.blank?
@@ -110,12 +95,6 @@ class Messages::MessageBuilder
email_string.gsub(/\s+/, '').split(',')
end
def validate_email_addresses(all_emails)
all_emails&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
def message_type
if @conversation.inbox.channel_type != 'Channel::Api' && @message_type == 'incoming'
raise StandardError, 'Incoming messages are only allowed in Api inboxes'
@@ -178,14 +157,17 @@ class Messages::MessageBuilder
email_attributes = ensure_indifferent_access(@message.content_attributes[:email] || {})
normalized_content = normalize_email_body(@message.content)
# Process liquid templates in normalized content with code block protection
processed_content = process_liquid_in_email_body(normalized_content)
# Use custom HTML content if provided, otherwise generate from message content
email_attributes[:html_content] = if custom_email_content_provided?
build_custom_html_content
else
build_html_content(normalized_content)
build_html_content(processed_content)
end
email_attributes[:text_content] = build_text_content(normalized_content)
email_attributes[:text_content] = build_text_content(processed_content)
email_attributes
end
@@ -204,22 +186,6 @@ class Messages::MessageBuilder
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
def custom_email_content_provided?
@params[:email_html_content].present?
end
@@ -232,4 +198,27 @@ class Messages::MessageBuilder
html_content
end
# Liquid processing methods for email content
def process_liquid_in_email_body(content)
return content if content.blank?
return content unless should_process_liquid?
# Protect code blocks from liquid processing
modified_content = modified_liquid_content(content)
template = Liquid::Template.parse(modified_content)
template.render(drops_with_sender)
rescue Liquid::Error
content
end
def should_process_liquid?
@message_type == 'outgoing' || @message_type == 'template'
end
def drops_with_sender
message_drops(@conversation).merge({
'agent' => UserDrop.new(sender)
})
end
end
@@ -22,9 +22,10 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
def edit; end
def create
@article = @portal.articles.create!(article_params)
params_with_defaults = article_params
params_with_defaults[:status] ||= :draft
@article = @portal.articles.create!(params_with_defaults)
@article.associate_root_article(article_params[:associated_article_id])
@article.draft!
render json: { error: @article.errors.messages }, status: :unprocessable_entity and return unless @article.valid?
end
+24
View File
@@ -0,0 +1,24 @@
# Provides utility methods for data transformation, hash manipulation, and JSON parsing.
# This module contains helper methods for converting between different data types,
# normalizing hashes, and safely handling JSON operations.
module DataHelper
# Ensures a hash supports indifferent access (string or symbol keys).
# Returns an empty hash if the input is blank.
def ensure_indifferent_access(hash)
return {} if hash.blank?
hash.respond_to?(:with_indifferent_access) ? hash.with_indifferent_access : hash
end
def convert_to_hash(obj)
return obj.to_unsafe_h if obj.instance_of?(ActionController::Parameters)
obj
end
def safe_parse_json(content)
JSON.parse(content, symbolize_names: true)
rescue JSON::ParserError
{}
end
end
+21 -1
View File
@@ -4,6 +4,19 @@ module EmailHelper
domain.split('.').first
end
def render_email_html(content)
return '' if content.blank?
ChatwootMarkdownRenderer.new(content).render_message.to_s
end
# Raise a standard error if any email address is invalid
def validate_email_addresses(emails_to_test)
emails_to_test&.each do |email|
raise StandardError, 'Invalid email address' unless email.match?(URI::MailTo::EMAIL_REGEXP)
end
end
# ref: https://www.rfc-editor.org/rfc/rfc5233.html
# This is not a mandatory requirement for email addresses, but it is a common practice.
# john+test@xyc.com is the same as john@xyc.com
@@ -21,6 +34,10 @@ module EmailHelper
end
end
def normalize_email_body(content)
content.to_s.gsub("\r\n", "\n")
end
def modified_liquid_content(email)
# This regex is used to match the code blocks in the content
# We don't want to process liquid in code blocks
@@ -29,7 +46,10 @@ module EmailHelper
def message_drops(conversation)
{
'contact' => ContactDrop.new(conversation.contact)
'contact' => ContactDrop.new(conversation.contact),
'conversation' => ConversationDrop.new(conversation),
'inbox' => InboxDrop.new(conversation.inbox),
'account' => AccountDrop.new(conversation.account)
}
end
end
@@ -4,7 +4,6 @@ import { OnClickOutside } from '@vueuse/components';
import { useRoute } from 'vue-router';
import { useMapGetter } from 'dashboard/composables/store.js';
import { usePolicy } from 'dashboard/composables/usePolicy';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import BackButton from 'dashboard/components/widgets/BackButton.vue';
import PaginationFooter from 'dashboard/components-next/pagination/PaginationFooter.vue';
@@ -117,58 +116,57 @@ const handleCreateAssistant = () => {
<div
class="flex items-start lg:items-center justify-between w-full py-6 lg:py-0 lg:h-20 gap-4 lg:gap-2 flex-col lg:flex-row"
>
<div class="flex gap-4 items-center">
<div class="flex gap-3 items-center">
<BackButton v-if="backUrl" :back-url="backUrl" />
<slot name="headerTitle">
<div v-if="showAssistantSwitcher" class="flex items-center gap-2">
<div class="flex items-center gap-1">
<span
v-if="activeAssistantName"
class="text-xl font-medium truncate text-n-slate-12"
<div v-if="showAssistantSwitcher" class="flex items-center gap-2">
<div class="flex items-center gap-2">
<span
v-if="activeAssistantName"
class="text-xl font-medium truncate text-n-slate-12"
>
{{ activeAssistantName }}
</span>
<div v-if="activeAssistantName" class="relative group">
<OnClickOutside
@trigger="showAssistantSwitcherDropdown = false"
>
{{ activeAssistantName }}
</span>
<div v-if="activeAssistantName" class="relative group">
<OnClickOutside
@trigger="showAssistantSwitcherDropdown = false"
>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="toggleAssistantSwitcher"
/>
<Button
icon="i-lucide-chevron-down"
variant="ghost"
color="slate"
size="xs"
class="rounded-md group-hover:bg-n-slate-3 hover:bg-n-slate-3 [&>span]:size-4"
@click="toggleAssistantSwitcher"
/>
<AssistantSwitcher
v-if="showAssistantSwitcherDropdown"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showAssistantSwitcherDropdown = false"
@create-assistant="handleCreateAssistant"
/>
</OnClickOutside>
</div>
<Icon
v-if="activeAssistantName"
icon="i-lucide-chevron-right"
class="size-6 text-n-slate-11"
/>
<span class="text-xl font-medium text-n-slate-11">
{{ headerTitle }}
</span>
<AssistantSwitcher
v-if="showAssistantSwitcherDropdown"
class="absolute ltr:left-0 rtl:right-0 top-9"
@close="showAssistantSwitcherDropdown = false"
@create-assistant="handleCreateAssistant"
/>
</OnClickOutside>
</div>
</div>
<span v-else class="text-xl font-medium text-n-slate-12">
</div>
<div class="flex items-center gap-4">
<div
v-if="showAssistantSwitcher && headerTitle"
class="w-0.5 h-4 rounded-2xl bg-n-weak"
/>
<span
v-if="headerTitle"
class="text-xl font-medium text-n-slate-12"
>
{{ headerTitle }}
</span>
</slot>
<div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
<div
v-if="!isEmpty && showKnowMore"
class="flex items-center gap-2"
>
<div class="w-0.5 h-4 rounded-2xl bg-n-weak" />
<slot name="knowMore" />
</div>
</div>
</div>
@@ -75,9 +75,9 @@ const sendMessage = async () => {
<template>
<div
class="flex flex-col h-full rounded-lg p-4 border border-n-slate-4 text-n-slate-11"
class="flex flex-col h-full rounded-xl border py-6 border-n-weak text-n-slate-11"
>
<div class="mb-4">
<div class="mb-8 px-6">
<div class="flex justify-between items-center mb-1">
<h3 class="text-lg font-medium">
{{ t('CAPTAIN.PLAYGROUND.HEADER') }}
@@ -85,6 +85,7 @@ const sendMessage = async () => {
<NextButton
ghost
sm
slate
icon="i-lucide-rotate-ccw"
@click="resetConversation"
/>
@@ -97,11 +98,11 @@ const sendMessage = async () => {
<MessageList :messages="messages" :is-loading="isLoading" />
<div
class="flex items-center bg-n-solid-1 outline outline-n-container rounded-lg p-3"
class="flex items-center mx-6 bg-n-background outline outline-1 outline-n-weak rounded-xl p-3"
>
<input
v-model="newMessage"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0"
class="flex-1 bg-transparent border-none focus:outline-none text-sm mb-0 text-n-slate-12 placeholder:text-n-slate-10"
:placeholder="t('CAPTAIN.PLAYGROUND.MESSAGE_PLACEHOLDER')"
@keyup.enter="sendMessage"
/>
@@ -35,8 +35,8 @@ const getAvatarName = sender =>
const getMessageStyle = sender =>
isUserMessage(sender)
? 'bg-n-strong text-n-white'
: 'bg-n-solid-iris text-n-slate-12';
? 'bg-n-solid-blue text-n-slate-12 rounded-br-sm rounded-bl-xl rounded-t-xl'
: 'bg-n-solid-iris text-n-slate-12 rounded-bl-sm rounded-br-xl rounded-t-xl';
const scrollToBottom = async () => {
await nextTick();
@@ -49,7 +49,10 @@ watch(() => props.messages.length, scrollToBottom);
</script>
<template>
<div ref="messageContainer" class="flex-1 overflow-y-auto mb-4 space-y-2">
<div
ref="messageContainer"
class="flex-1 overflow-y-auto mb-4 px-6 space-y-6"
>
<div
v-for="(message, index) in messages"
:key="index"
@@ -57,15 +60,20 @@ watch(() => props.messages.length, scrollToBottom);
:class="getMessageAlignment(message.sender)"
>
<div
class="flex items-start gap-1.5"
class="flex items-end gap-1.5 max-w-[90%] md:max-w-[60%]"
:class="getMessageDirection(message.sender)"
>
<Avatar :name="getAvatarName(message.sender)" rounded-full :size="24" />
<Avatar
:name="getAvatarName(message.sender)"
rounded-full
:size="24"
class="shrink-0"
/>
<div
class="max-w-[80%] rounded-lg p-3 text-sm"
class="px-4 py-3 text-sm [overflow-wrap:break-word]"
:class="getMessageStyle(message.sender)"
>
<div class="break-words" v-html="formatMessage(message.content)" />
<div v-html="formatMessage(message.content)" />
</div>
</div>
</div>
@@ -8,11 +8,15 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({
menuItems: {
type: Array,
required: true,
default: () => [],
validator: value => {
return value.every(item => item.action && item.value && item.label);
},
},
menuSections: {
type: Array,
default: () => [],
},
thumbnailSize: {
type: Number,
default: 20,
@@ -42,19 +46,62 @@ const { t } = useI18n();
const searchInput = ref(null);
const searchQuery = ref('');
const filteredMenuItems = computed(() => {
if (!searchQuery.value) return props.menuItems;
const hasSections = computed(() => props.menuSections.length > 0);
return props.menuItems.filter(item =>
const flattenedMenuItems = computed(() => {
if (!hasSections.value) {
return props.menuItems;
}
return props.menuSections.flatMap(section => section.items || []);
});
const filteredMenuItems = computed(() => {
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
);
});
const filteredMenuSections = computed(() => {
if (!hasSections.value) {
return [];
}
if (!searchQuery.value) {
return props.menuSections;
}
const query = searchQuery.value.toLowerCase();
return props.menuSections
.map(section => {
const filteredItems = (section.items || []).filter(item =>
item.label.toLowerCase().includes(query)
);
return {
...section,
items: filteredItems,
};
})
.filter(section => section.items.length > 0);
});
const handleAction = item => {
const { action, value, ...rest } = item;
emit('action', { action, value, ...rest });
};
const shouldShowEmptyState = computed(() => {
if (hasSections.value) {
return filteredMenuSections.value.length === 0;
}
return filteredMenuItems.value.length === 0;
});
onMounted(() => {
if (searchInput.value && props.showSearch) {
searchInput.value.focus();
@@ -64,54 +111,122 @@ onMounted(() => {
<template>
<div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg"
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
>
<div v-if="showSearch" class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" />
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="filteredMenuItems.length === 0"
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input
ref="searchInput"
v-model="searchQuery"
type="search"
:placeholder="
searchPlaceholder || t('DROPDOWN_MENU.SEARCH_PLACEHOLDER')
"
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/>
</div>
</div>
<template v-if="hasSections">
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
>
{{ section.title }}
</p>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<button
v-for="(item, index) in filteredMenuItems"
:key="index"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
</template>
<div
v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5"
>
{{
@@ -14,7 +14,7 @@ import {
} from 'dashboard/constants/automation';
/**
* This is a shared composables that holds utilites used to build dropdown and file options
* This is a shared composables that holds utilities used to build dropdown and file options
* @returns {Object} An object containing various automation-related functions and computed properties.
*/
export default function useAutomationValues() {
@@ -21,7 +21,7 @@ export const initializeAudioAlerts = user => {
enable_audio_alerts: audioAlertType,
alert_if_unread_assigned_conversation_exist: alertIfUnreadConversationExist,
notification_tone: audioAlertTone,
// UI Settings can be undefined initally as we don't send the
// UI Settings can be undefined initially as we don't send the
// entire payload for the user during the signup process.
} = uiSettings || {};
@@ -53,6 +53,8 @@
"LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month",
"LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year",
@@ -10,14 +10,14 @@ const assistantId = computed(() => Number(route.params.assistantId));
<template>
<PageLayout
:header-title="$t('CAPTAIN.PLAYGROUND.HEADER')"
show-assistant-switcher
:show-pagination-footer="false"
:show-know-more="false"
class="h-full"
>
<template #body>
<div class="flex flex-col h-full">
<AssistantPlayground :assistant-id="assistantId" />
<AssistantPlayground :assistant-id="assistantId" class="bg-n-solid-1" />
</div>
</template>
</PageLayout>
@@ -104,7 +104,6 @@ const handleDeleteSuccess = () => {
<template>
<PageLayout
:header-title="$t('CAPTAIN.ASSISTANTS.SETTINGS.HEADER')"
:is-fetching="isFetching"
:show-pagination-footer="false"
:show-know-more="false"
@@ -195,7 +195,10 @@ const navigateToPendingFAQs = () => {
onMounted(() => {
initializeFromURL();
store.dispatch('captainResponses/fetchPendingCount', selectedAssistantId);
store.dispatch(
'captainResponses/fetchPendingCount',
selectedAssistantId.value
);
});
</script>
@@ -119,7 +119,7 @@ const tooltip = useHeatmapTooltip();
<!-- eslint-disable vue/no-static-inline-styles -->
<template>
<div
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72"
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr]"
>
<template v-if="isLoading">
<div class="grid gap-[5px] flex-shrink-0">
@@ -1,15 +1,18 @@
<script setup>
import { onMounted, ref, computed } from 'vue';
import { onMounted, ref, computed, watch } from 'vue';
import { useToggle } from '@vueuse/core';
import MetricCard from '../overview/MetricCard.vue';
import BaseHeatmap from './BaseHeatmap.vue';
import HeatmapDateRangeSelector from './HeatmapDateRangeSelector.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import endOfDay from 'date-fns/endOfDay';
import format from 'date-fns/format';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import startOfMonth from 'date-fns/startOfMonth';
import subDays from 'date-fns/subDays';
import format from 'date-fns/format';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n';
@@ -57,27 +60,33 @@ const uiFlags = useMapGetter('getOverviewUIFlags');
const heatmapData = useMapGetter(props.storeGetter);
const inboxes = useMapGetter('inboxes/getInboxes');
const menuItems = [
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
value: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
value: 13,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 29,
},
];
const selectedDays = ref(6);
const selectedFrom = ref(null);
const selectedTo = ref(null);
const selectedDaysBefore = ref(null);
const selectedInbox = ref(null);
const isMonthFilter = ref(false);
const currentMonthOffset = ref(0);
const selectedDayFilter = computed(() =>
menuItems.find(menuItem => menuItem.value === selectedDays.value)
);
const selectedRange = computed(() => {
if (!selectedFrom.value || !selectedTo.value) {
return null;
}
return {
from: selectedFrom.value,
to: selectedTo.value,
};
});
const numberOfRows = computed(() => {
if (!selectedRange.value) {
return 0;
}
const dateDifference = differenceInCalendarDays(
selectedRange.value.to,
selectedRange.value.from
);
return dateDifference + 1;
});
const inboxMenuItems = computed(() => {
return [
@@ -105,13 +114,42 @@ const selectedInboxFilter = computed(() => {
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
// Keeps relative presets (last 7 days / this month) aligned with "now" during live refreshes.
const resolveActiveRange = () => {
if (isMonthFilter.value && currentMonthOffset.value === 0) {
const now = new Date();
const monthStart = startOfMonth(now);
return {
from: startOfDay(monthStart),
to: endOfDay(now),
};
}
if (!isMonthFilter.value && selectedDaysBefore.value !== null) {
const to = endOfDay(new Date());
return {
from: startOfDay(subDays(to, Number(selectedDaysBefore.value))),
to,
};
}
return selectedRange.value;
};
const downloadHeatmapData = () => {
const to = endOfDay(new Date());
const range = resolveActiveRange();
if (!range) {
return;
}
const { to } = range;
const shouldUseBackendDownload =
!isMonthFilter.value && !selectedInbox.value && props.downloadAction;
// If no inbox is selected and download action exists, use backend endpoint
if (!selectedInbox.value && props.downloadAction) {
if (shouldUseBackendDownload) {
store.dispatch(props.downloadAction, {
daysBefore: selectedDays.value,
daysBefore: selectedDaysBefore.value,
to: getUnixTime(to),
});
return;
@@ -150,7 +188,6 @@ const downloadHeatmapData = () => {
downloadCsvFile(fileName, csvContent);
};
const [showDropdown, toggleDropdown] = useToggle();
const [showInboxDropdown, toggleInboxDropdown] = useToggle();
const fetchHeatmapData = () => {
@@ -158,8 +195,12 @@ const fetchHeatmapData = () => {
return;
}
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, Number(selectedDays.value)));
const range = resolveActiveRange();
if (!range) {
return;
}
const { from, to } = range;
const params = {
metric: props.metric,
@@ -178,25 +219,43 @@ const fetchHeatmapData = () => {
store.dispatch(props.storeAction, params);
};
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedDays.value = value;
fetchHeatmapData();
};
const handleInboxAction = ({ value }) => {
toggleInboxDropdown(false);
selectedInbox.value = value
? inboxes.value.find(inbox => inbox.id === value)
: null;
fetchHeatmapData();
};
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
const handleRangeTypeChange = type => {
isMonthFilter.value = type === 'month';
};
const handleMonthOffsetChange = offset => {
currentMonthOffset.value = offset;
};
watch(
() => [selectedFrom.value, selectedTo.value],
([from, to]) => {
if (from && to) {
fetchHeatmapData();
}
}
);
watch(
() => selectedInbox.value,
() => {
if (selectedRange.value) {
fetchHeatmapData();
}
}
);
onMounted(() => {
store.dispatch('inboxes/get');
fetchHeatmapData();
startRefetching();
});
</script>
@@ -205,25 +264,13 @@ onMounted(() => {
<div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="title">
<template #control>
<div
v-on-clickaway="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedDayFilter.label"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
<HeatmapDateRangeSelector
v-model:from="selectedFrom"
v-model:to="selectedTo"
v-model:days-num="selectedDaysBefore"
@range-type-change="handleRangeTypeChange"
@month-offset-change="handleMonthOffsetChange"
/>
<div
v-on-clickaway="() => toggleInboxDropdown(false)"
class="relative flex items-center group"
@@ -241,22 +288,23 @@ onMounted(() => {
:menu-items="inboxMenuItems"
show-search
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full !min-w-56 max-w-56 max-h-96 overflow-y-auto"
@action="handleInboxAction($event)"
/>
</div>
<Button
v-tooltip="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
sm
slate
faded
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
icon="i-lucide-download"
class="rounded-md group-hover:bg-n-alpha-2"
@click="downloadHeatmapData"
/>
</template>
<BaseHeatmap
:heatmap-data="heatmapData"
:number-of-rows="selectedDays + 1"
:number-of-rows="numberOfRows"
:is-loading="isLoading"
:color-scheme="colorScheme"
/>
@@ -0,0 +1,211 @@
<script setup>
import { computed, ref, watch, defineModel } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import addMonths from 'date-fns/addMonths';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import endOfDay from 'date-fns/endOfDay';
import endOfMonth from 'date-fns/endOfMonth';
import startOfDay from 'date-fns/startOfDay';
import startOfMonth from 'date-fns/startOfMonth';
import subDays from 'date-fns/subDays';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['rangeTypeChange', 'monthOffsetChange']);
const fromModel = defineModel('from', { type: Date, default: null });
const toModel = defineModel('to', { type: Date, default: null });
const daysNumModel = defineModel('daysNum', { type: Number, default: null });
const { t, locale } = useI18n();
const DATE_FILTER_TYPES = {
DAY: 'day',
MONTH: 'month',
};
const DATE_FILTER_ACTION = 'select_date_range';
const dayMenuItemConfigs = computed(() => [
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
value: 'last_7_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
value: 'last_14_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 13,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 'last_30_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 29,
},
]);
const resolvedLocale = computed(
() =>
locale.value ||
(typeof navigator !== 'undefined' ? navigator.language : 'en')
);
const monthFormatter = computed(
() =>
new Intl.DateTimeFormat(resolvedLocale.value, {
month: 'long',
year: 'numeric',
})
);
const monthMenuItemConfigs = computed(() => {
const now = new Date();
const offsets = [0, -1, -2];
return offsets.map(offset => ({
label:
offset === 0
? t('REPORT.DATE_RANGE_OPTIONS.THIS_MONTH')
: monthFormatter.value.format(addMonths(now, offset)),
value: offset === 0 ? 'this_month' : `month_${offset}`,
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.MONTH,
monthOffset: offset,
}));
});
const selectedDateRangeValue = ref('');
const [showDropdown, toggleDropdown] = useToggle();
const monthOffset = ref(0);
const menuItems = computed(() => {
const selectedValue = selectedDateRangeValue.value;
return [...dayMenuItemConfigs.value, ...monthMenuItemConfigs.value].map(
config => ({
...config,
isSelected: selectedValue === config.value,
})
);
});
selectedDateRangeValue.value = menuItems.value[0]?.value || '';
const menuSections = computed(() => {
const dayItems = menuItems.value.filter(
item => item.type === DATE_FILTER_TYPES.DAY
);
const monthItems = menuItems.value.filter(
item => item.type === DATE_FILTER_TYPES.MONTH
);
return [{ items: dayItems }, { items: monthItems }].filter(
section => section.items.length > 0
);
});
const selectedConfig = computed(
() =>
menuItems.value.find(
menuItem => menuItem.value === selectedDateRangeValue.value
) || menuItems.value[0]
);
const selectedLabel = computed(() => {
const selectedItem = menuItems.value.find(
item => item.value === selectedDateRangeValue.value
);
return selectedItem?.label || '';
});
const computeRange = config => {
if (!config) {
return null;
}
if (config.type === DATE_FILTER_TYPES.MONTH) {
const now = new Date();
const baseMonthStart = startOfMonth(addMonths(now, monthOffset.value));
const from = startOfDay(baseMonthStart);
const isCurrentMonth =
config.value === 'this_month' && monthOffset.value === 0;
const to = isCurrentMonth
? endOfDay(now)
: endOfDay(endOfMonth(baseMonthStart));
const daysBefore = differenceInCalendarDays(to, from);
return { from, to, daysBefore };
}
const to = endOfDay(new Date());
const from = startOfDay(subDays(to, Number(config.daysBefore)));
return { from, to, daysBefore: Number(config.daysBefore) };
};
const applySelection = config => {
if (!config) return;
if (config.type === DATE_FILTER_TYPES.MONTH) {
monthOffset.value = config.monthOffset || 0;
} else {
monthOffset.value = 0;
}
const range = computeRange(config);
if (!range) return;
const { from, to, daysBefore } = range;
fromModel.value = from;
toModel.value = to;
daysNumModel.value = daysBefore;
emit('rangeTypeChange', config.type);
emit('monthOffsetChange', monthOffset.value);
};
const handleAction = ({ action, value }) => {
toggleDropdown(false);
if (action !== DATE_FILTER_ACTION) {
return;
}
selectedDateRangeValue.value = value;
};
watch(
() => selectedConfig.value,
config => {
applySelection(config);
},
{ immediate: true }
);
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>
+18 -1
View File
@@ -8,7 +8,7 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
def perform(entries)
@entries = entries
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: sender_id, ig_account_id: ig_account_id)
key = format(::Redis::Alfred::IG_MESSAGE_MUTEX, sender_id: contact_instagram_id, ig_account_id: ig_account_id)
with_lock(key) do
process_entries(entries)
end
@@ -77,6 +77,23 @@ class Webhooks::InstagramEventsJob < MutexApplicationJob
@entries&.first&.dig(:id)
end
def contact_instagram_id
entry = @entries&.first
return nil unless entry
# Handle both messaging and standby arrays
messaging = (entry[:messaging].presence || entry[:standby] || []).first
return nil unless messaging
# For echo messages (outgoing from our account), use recipient's ID (the contact)
# For incoming messages (from contact), use sender's ID (the contact)
if messaging.dig(:message, :is_echo)
messaging.dig(:recipient, :id)
else
messaging.dig(:sender, :id)
end
end
def sender_id
@entries&.dig(0, :messaging, 0, :sender, :id)
end
+7 -24
View File
@@ -4,38 +4,21 @@ class ApplicationMailbox < ActionMailbox::Base
# Last part is the regex for the UUID
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
REPLY_EMAIL_UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
CONVERSATION_MESSAGE_ID_PATTERN = %r{conversation/([a-zA-Z0-9-]*?)/messages/(\d+?)@(\w+\.\w+)}
# routes as a reply to existing conversations
# Route all emails to verified channels to the unified reply mailbox
# The ConversationFinder will determine if it's a reply or new conversation
routing(
->(inbound_mail) { valid_to_address?(inbound_mail) && (reply_uuid_mail?(inbound_mail) || in_reply_to_mail?(inbound_mail)) } => :reply
)
# routes as a new conversation in email channel
routing(
->(inbound_mail) { valid_to_address?(inbound_mail) && EmailChannelFinder.new(inbound_mail.mail).perform.present? } => :support
lambda { |inbound_mail|
valid_to_address?(inbound_mail) &&
(reply_uuid_mail?(inbound_mail) || EmailChannelFinder.new(inbound_mail.mail).perform.present?)
} => :reply
)
# catchall
routing(all: :default)
class << self
# checks if follow this pattern then send it to reply_mailbox
# <account/#{@account.id}/conversation/#{@conversation.uuid}@#{@account.inbound_email_domain}>
def in_reply_to_mail?(inbound_mail)
in_reply_to = inbound_mail.mail.in_reply_to
in_reply_to.present? && (
in_reply_to_matches?(in_reply_to) || Message.exists?(source_id: in_reply_to)
)
end
def in_reply_to_matches?(in_reply_to)
Array.wrap(in_reply_to).any? { it.match?(CONVERSATION_MESSAGE_ID_PATTERN) }
end
# checks if follow this pattern send it to reply_mailbox
# reply+<conversation-uuid>@<mailer-domain.com>
# checks if follows this pattern: reply+<conversation-uuid>@<mailer-domain.com>
def reply_uuid_mail?(inbound_mail)
inbound_mail.mail.to&.any? do |email|
conversation_uuid = email.split('@')[0]
+19 -4
View File
@@ -3,6 +3,8 @@ class Imap::ImapMailbox
include IncomingEmailValidityHelper
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
FALLBACK_CONVERSATION_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
def process(mail, channel)
@inbound_mail = mail
@channel = channel
@@ -49,19 +51,32 @@ class Imap::ImapMailbox
end
def find_conversation_by_reference_ids
return if @inbound_mail.references.blank? && in_reply_to.present?
return if @inbound_mail.references.blank?
message = find_message_by_references
if message.present?
conversation = @inbox.conversations.find_by(id: message.conversation_id)
return conversation if conversation.present?
end
return if message.nil?
@inbox.conversations.find(message.conversation_id)
# FALLBACK_PATTERN use to find a conversation that is started by an agent (no incoming message yet)
conversation_id = find_conversation_by_references
@inbox.conversations.find_by(uuid: conversation_id) if conversation_id.present?
end
def in_reply_to
@processed_mail.in_reply_to
end
def find_conversation_by_references
references = Array.wrap(@inbound_mail.references)
references.each do |message_id|
match = FALLBACK_CONVERSATION_PATTERN.match(message_id)
return match[2] if match.present?
end
end
def find_message_by_references
message_to_return = nil
+23 -73
View File
@@ -1,88 +1,38 @@
class ReplyMailbox < ApplicationMailbox
attr_accessor :conversation_uuid, :processed_mail
attr_accessor :conversation, :processed_mail
# Last part is the regex for the UUID
# Eg: email should be something like : reply+6bdc3f4d-0bec-4515-a284-5d916fdde489@domain.com
EMAIL_PART_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
before_processing :conversation_uuid_from_to_address,
:find_relative_conversation
before_processing :find_conversation
def process
return if @conversation.blank?
# Return early if no conversation was found (e.g., notification emails, suspended accounts)
return unless @conversation
decorate_mail
create_message
add_attachments_to_message
# Wrap everything in a transaction to ensure atomicity
# This prevents orphan conversations if message/attachment creation fails
# and ensures idempotency on job retry (conversation won't be duplicated)
ActiveRecord::Base.transaction do
persist_conversation_if_needed
decorate_mail
create_message
add_attachments_to_message
end
end
private
def find_relative_conversation
if @conversation_uuid
find_conversation_with_uuid
elsif mail.in_reply_to.present?
find_conversation_with_in_reply_to
end
def find_conversation
@conversation = Mailbox::ConversationFinder.new(mail).find
# Log when email is rejected
Rails.logger.info "Email #{mail.message_id} rejected - no conversation found" unless @conversation
end
def conversation_uuid_from_to_address
@mail = MailPresenter.new(mail)
def persist_conversation_if_needed
# Save the conversation if it's a new record (from NewConversationStrategy)
# We persist here instead of in the strategy to maintain transaction integrity
return unless @conversation.new_record?
return if @mail.mail_receiver.blank?
@mail.mail_receiver.each do |email|
username = email.split('@')[0]
match_result = username.match(ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN)
if match_result
@conversation_uuid = match_result.captures
break
end
end
@conversation_uuid
end
# find conversation uuid from below pattern
# reply+<conversation-uuid>@<mailer-domain.com>
def find_conversation_with_uuid
@conversation = Conversation.find_by(uuid: conversation_uuid)
validate_resource @conversation
end
def find_conversation_by_uuid(match_result)
@conversation_uuid = match_result.captures[0]
find_conversation_with_uuid
end
def find_conversation_by_message_id(in_reply_to)
@message = Message.find_by(source_id: in_reply_to)
@conversation = @message.conversation if @message.present?
@conversation_uuid = @conversation.uuid if @conversation.present?
end
# find conversation uuid from below pattern
# <conversation/#{@conversation.uuid}/messages/#{@messages&.last&.id}@#{@account.inbound_email_domain}>
def find_conversation_with_in_reply_to
match_result = nil
in_reply_to_addresses = mail.in_reply_to
in_reply_to_addresses = [in_reply_to_addresses] if in_reply_to_addresses.is_a?(String)
in_reply_to_addresses.each do |in_reply_to|
match_result = in_reply_to.match(::ApplicationMailbox::CONVERSATION_MESSAGE_ID_PATTERN)
break if match_result
end
find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
end
def find_by_in_reply_to_addresses(match_result, in_reply_to_addresses)
find_conversation_by_uuid(match_result) if match_result
find_conversation_by_message_id(in_reply_to_addresses) if @conversation.blank?
end
def validate_resource(resource)
Rails.logger.error "[App::Mailboxes::ReplyMailbox] Email conversation with uuid: #{conversation_uuid} not found" if resource.nil?
resource
@conversation.save!
Rails.logger.info "Created new conversation #{@conversation.id} for email #{mail.message_id}"
end
def decorate_mail
-94
View File
@@ -1,94 +0,0 @@
class SupportMailbox < ApplicationMailbox
include IncomingEmailValidityHelper
attr_accessor :channel, :account, :inbox, :conversation, :processed_mail
before_processing :find_channel,
:load_account,
:load_inbox,
:decorate_mail
def process
Rails.logger.info "Processing email #{mail.message_id} from #{original_sender_email} to #{mail.to} with subject #{mail.subject}"
# Skip processing email if it belongs to any of the edge cases
return unless incoming_email_from_valid_email?
ActiveRecord::Base.transaction do
find_or_create_contact
find_or_create_conversation
create_message
add_attachments_to_message
end
end
private
def find_channel
find_channel_with_to_mail if @channel.blank?
raise 'Email channel/inbox not found' if @channel.nil?
@channel
end
def find_channel_with_to_mail
@channel = EmailChannelFinder.new(mail).perform
end
def load_account
@account = @channel.account
end
def load_inbox
@inbox = @channel.inbox
end
def decorate_mail
@processed_mail = MailPresenter.new(mail, @account)
end
def find_conversation_by_in_reply_to
return if in_reply_to.blank?
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
end
def in_reply_to
mail['In-Reply-To'].try(:value)
end
def original_sender_email
@processed_mail.original_sender&.downcase
end
def find_or_create_conversation
@conversation = find_conversation_by_in_reply_to || ::Conversation.create!({
account_id: @account.id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
}
})
end
def find_or_create_contact
@contact = @inbox.contacts.from_email(original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
create_contact
end
end
def identify_contact_name
processed_mail.sender_name || processed_mail.from.first.split('@').first
end
end
+17
View File
@@ -35,4 +35,21 @@ class ReportingEvent < ApplicationRecord
belongs_to :user, optional: true
belongs_to :inbox, optional: true
belongs_to :conversation, optional: true
# Scopes for filtering
scope :filter_by_date_range, lambda { |range|
where(created_at: range) if range.present?
}
scope :filter_by_inbox_id, lambda { |inbox_id|
where(inbox_id: inbox_id) if inbox_id.present?
}
scope :filter_by_user_id, lambda { |user_id|
where(user_id: user_id) if user_id.present?
}
scope :filter_by_name, lambda { |name|
where(name: name) if name.present?
}
end
@@ -0,0 +1,29 @@
class Mailbox::ConversationFinder
DEFAULT_STRATEGIES = [
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
Mailbox::ConversationFinderStrategies::ReferencesStrategy,
Mailbox::ConversationFinderStrategies::NewConversationStrategy
].freeze
def initialize(mail, strategies: DEFAULT_STRATEGIES)
@mail = mail
@strategies = strategies
end
def find
@strategies.each do |strategy_class|
conversation = strategy_class.new(@mail).find
next unless conversation
strategy_name = strategy_class.name.demodulize.underscore
Rails.logger.info "Conversation found via #{strategy_name} strategy"
return conversation
end
# Should not reach here if NewConversationStrategy is in the chain
Rails.logger.error 'No conversation found via any strategy (NewConversationStrategy missing?)'
nil
end
end
@@ -0,0 +1,13 @@
class Mailbox::ConversationFinderStrategies::BaseStrategy
attr_reader :mail
def initialize(mail)
@mail = mail
end
# Returns Conversation or nil
# Subclasses must implement this method
def find
raise NotImplementedError, "#{self.class} must implement #find"
end
end
@@ -0,0 +1,48 @@
class Mailbox::ConversationFinderStrategies::InReplyToStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Patterns from ApplicationMailbox
MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
# FALLBACK_PATTERN is used when building In-Reply-To headers in ConversationReplyMailer
# when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
# This happens when:
# - A conversation is started by an agent (no incoming message yet)
# - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
# - The incoming message doesn't have email metadata with a message_id
# In these cases, we use a conversation-level identifier instead of a message-level one.
FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
def find
return nil if mail.in_reply_to.blank?
in_reply_to_addresses = Array.wrap(mail.in_reply_to)
in_reply_to_addresses.each do |in_reply_to|
# Try extracting UUID from patterns
uuid = extract_uuid_from_patterns(in_reply_to)
if uuid
conversation = Conversation.find_by(uuid: uuid)
return conversation if conversation
end
# Try finding by message source_id
message = Message.find_by(source_id: in_reply_to)
return message.conversation if message&.conversation
end
nil
end
private
def extract_uuid_from_patterns(message_id)
# Try message-specific pattern first
match = MESSAGE_PATTERN.match(message_id)
return match[1] if match
# Try conversation fallback pattern
match = FALLBACK_PATTERN.match(message_id)
return match[2] if match
nil
end
end
@@ -0,0 +1,83 @@
class Mailbox::ConversationFinderStrategies::NewConversationStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
include MailboxHelper
include IncomingEmailValidityHelper
attr_accessor :processed_mail, :account, :inbox, :contact, :contact_inbox, :conversation, :channel
def initialize(mail)
super(mail)
@channel = EmailChannelFinder.new(mail).perform
return unless @channel
@account = @channel.account
@inbox = @channel.inbox
@processed_mail = MailPresenter.new(mail, @account)
end
# This strategy prepares a new conversation but doesn't persist it yet.
# Why we don't use create! here:
# - Avoids orphan conversations if message/attachment creation fails later
# - Prevents duplicate conversations on job retry (no idempotency issue)
# - Follows the pattern from old SupportMailbox where everything was in one transaction
# The actual persistence happens in ReplyMailbox within a transaction that includes message creation.
def find
return nil unless @channel # No valid channel found
return nil unless incoming_email_from_valid_email? # Skip edge cases
# Check if conversation already exists by in_reply_to
existing_conversation = find_conversation_by_in_reply_to
return existing_conversation if existing_conversation
# Prepare contact (persisted) and build conversation (not persisted)
find_or_create_contact
build_conversation
end
private
def find_or_create_contact
@contact = @inbox.contacts.from_email(original_sender_email)
if @contact.present?
@contact_inbox = ContactInbox.find_by(inbox: @inbox, contact: @contact)
else
create_contact
end
end
def original_sender_email
@processed_mail.original_sender&.downcase
end
def identify_contact_name
@processed_mail.sender_name || @processed_mail.from.first.split('@').first
end
def build_conversation
# Build but don't persist - ReplyMailbox will save in transaction with message
@conversation = ::Conversation.new(
account_id: @account.id,
inbox_id: @inbox.id,
contact_id: @contact.id,
contact_inbox_id: @contact_inbox.id,
additional_attributes: {
in_reply_to: in_reply_to,
source: 'email',
auto_reply: @processed_mail.auto_reply?,
mail_subject: @processed_mail.subject,
initiated_at: {
timestamp: Time.now.utc
}
}
)
end
def in_reply_to
mail['In-Reply-To'].try(:value)
end
def find_conversation_by_in_reply_to
return if in_reply_to.blank?
@account.conversations.where("additional_attributes->>'in_reply_to' = ?", in_reply_to).first
end
end
@@ -0,0 +1,26 @@
class Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Pattern from ApplicationMailbox::REPLY_EMAIL_UUID_PATTERN
UUID_PATTERN = /^reply\+([0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12})$/i
def find
uuid = extract_uuid_from_receivers
return nil unless uuid
Conversation.find_by(uuid: uuid)
end
private
def extract_uuid_from_receivers
mail_presenter = MailPresenter.new(mail)
return nil if mail_presenter.mail_receiver.blank?
mail_presenter.mail_receiver.each do |email|
username = email.split('@').first
match = username.match(UUID_PATTERN)
return match[1] if match
end
nil
end
end
@@ -0,0 +1,59 @@
class Mailbox::ConversationFinderStrategies::ReferencesStrategy < Mailbox::ConversationFinderStrategies::BaseStrategy
# Patterns from ApplicationMailbox
MESSAGE_PATTERN = %r{conversation/([a-zA-Z0-9-]+)/messages/(\d+)@}
# FALLBACK_PATTERN is used when building References headers in ConversationReplyMailer
# when there's no actual message to reply to (see app/mailers/conversation_reply_mailer.rb#in_reply_to_email).
# This happens when:
# - A conversation is started by an agent (no incoming message yet)
# - The conversation originated from a non-email channel (widget, WhatsApp, etc.) but is now using email
# - The incoming message doesn't have email metadata with a message_id
# In these cases, we use a conversation-level identifier instead of a message-level one.
FALLBACK_PATTERN = %r{account/(\d+)/conversation/([a-zA-Z0-9-]+)@}
def initialize(mail)
super(mail)
# Get channel once upfront to use for scoped queries
@channel = EmailChannelFinder.new(mail).perform
end
def find
return nil if mail.references.blank?
return nil unless @channel # No valid channel found
references = Array.wrap(mail.references)
references.each do |reference|
conversation = find_conversation_from_reference(reference)
return conversation if conversation
end
nil
end
private
def find_conversation_from_reference(reference)
# Try extracting UUID from patterns
uuid = extract_uuid_from_patterns(reference)
if uuid
# Query scoped to inbox - prevents cross-account/cross-inbox matches at database level
conversation = Conversation.find_by(uuid: uuid, inbox_id: @channel.inbox.id)
return conversation if conversation
end
# We scope to the inbox, that way we filter out messages and conversations that don't belong to the channel
message = Message.find_by(source_id: reference, inbox_id: @channel.inbox.id)
message&.conversation
end
def extract_uuid_from_patterns(message_id)
match = MESSAGE_PATTERN.match(message_id)
return match[1] if match
match = FALLBACK_PATTERN.match(message_id)
return match[2] if match
nil
end
end
+2
View File
@@ -141,6 +141,7 @@ Rails.application.routes.draw do
post :custom_attributes
get :attachments
get :inbox_assistant
get :reporting_events if ChatwootApp.enterprise?
end
end
@@ -186,6 +187,7 @@ Rails.application.routes.draw do
get :download
end
end
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
@@ -0,0 +1,30 @@
class Api::V1::Accounts::ReportingEventsController < Api::V1::Accounts::EnterpriseAccountsController
include DateRangeHelper
RESULTS_PER_PAGE = 25
before_action :check_admin_authorization?
before_action :set_reporting_events, only: [:index]
before_action :set_current_page, only: [:index]
def index
@reporting_events = @reporting_events.page(@current_page).per(RESULTS_PER_PAGE)
@total_count = @reporting_events.total_count
end
private
def set_reporting_events
@reporting_events = Current.account.reporting_events
.includes(:conversation, :user, :inbox)
.filter_by_date_range(range)
.filter_by_inbox_id(params[:inbox_id])
.filter_by_user_id(params[:user_id])
.filter_by_name(params[:name])
.order(created_at: :desc)
end
def set_current_page
@current_page = (params[:page] || 1).to_i
end
end
@@ -11,6 +11,10 @@ module Enterprise::Api::V1::Accounts::ConversationsController
end
end
def reporting_events
@reporting_events = @conversation.reporting_events.order(created_at: :asc)
end
def permitted_update_params
super.merge(params.permit(:sla_policy_id))
end
@@ -0,0 +1,3 @@
json.array! @reporting_events do |reporting_event|
json.partial! 'api/v1/models/reporting_event', formats: [:json], reporting_event: reporting_event
end
@@ -0,0 +1,11 @@
json.payload do
json.array! @reporting_events do |reporting_event|
json.partial! 'api/v1/models/reporting_event', formats: [:json], reporting_event: reporting_event
end
end
json.meta do
json.count @total_count
json.current_page @current_page
json.total_pages @reporting_events.total_pages
end
@@ -0,0 +1,12 @@
json.id reporting_event.id
json.name reporting_event.name
json.value reporting_event.value
json.value_in_business_hours reporting_event.value_in_business_hours
json.event_start_time reporting_event.event_start_time
json.event_end_time reporting_event.event_end_time
json.account_id reporting_event.account_id
json.inbox_id reporting_event.inbox_id
json.user_id reporting_event.user_id
json.conversation_id reporting_event.conversation_id
json.created_at reporting_event.created_at
json.updated_at reporting_event.updated_at
@@ -28,7 +28,7 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
Question: #{response.question}
Answer: #{response.answer}
"
if response.documentable.present? && response.documentable.try(:external_link)
if should_show_source?(response)
formatted_response += "
Source: #{response.documentable.external_link}
"
@@ -36,4 +36,13 @@ class Captain::Tools::FaqLookupTool < Captain::Tools::BasePublicTool
formatted_response
end
def should_show_source?(response)
return false if response.documentable.blank?
return false unless response.documentable.try(:external_link)
# Don't show source if it's a PDF placeholder
external_link = response.documentable.external_link
!external_link.start_with?('PDF:')
end
end
@@ -223,6 +223,69 @@ describe Messages::MessageBuilder do
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Regular **markdown** content'
end
end
context 'when liquid templates are present in email content' do
let(:contact) { create(:contact, name: 'John', email: 'john@example.com') }
let(:conversation) { create(:conversation, inbox: channel_email.inbox, account: account, contact: contact) }
it 'processes liquid variables in email content' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}, your email is {{contact.email}}'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('Hello John')
expect(message.content_attributes.dig('email', 'html_content', 'full')).to include('john@example.com')
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, your email is john@example.com'
end
it 'does not process liquid in code blocks' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}, use this code: `{{contact.email}}`'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello John, use this code: `{{contact.email}}`'
end
it 'handles broken liquid syntax gracefully' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name} {{invalid}}'
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'text_content', 'full')).to eq 'Hello {{contact.name} {{invalid}}'
end
it 'does not process liquid for incoming messages' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}',
message_type: 'incoming'
})
api_channel = create(:channel_api, account: account)
api_conversation = create(:conversation, inbox: api_channel.inbox, account: account, contact: contact)
message = described_class.new(user, api_conversation, params).perform
expect(message.content).to eq 'Hello {{contact.name}}'
end
it 'does not process liquid for private messages' do
params = ActionController::Parameters.new({
content: 'Hello {{contact.name}}',
private: true
})
message = described_class.new(user, conversation, params).perform
expect(message.content_attributes.dig('email', 'html_content')).to be_nil
expect(message.content_attributes.dig('email', 'text_content')).to be_nil
end
end
end
end
end
@@ -36,7 +36,7 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
expect(json_response['payload']['status']).to eql('draft')
expect(json_response['payload']['status']).to eql('published')
expect(json_response['payload']['position']).to be(3)
end
@@ -59,10 +59,30 @@ RSpec.describe 'Api::V1::Accounts::Articles', type: :request do
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('MyTitle')
expect(json_response['payload']['status']).to eql('draft')
expect(json_response['payload']['status']).to eql('published')
expect(json_response['payload']['position']).to be(3)
end
it 'creates article as draft when status is not provided' do
article_params = {
article: {
category_id: category.id,
description: 'test description',
title: 'DraftTitle',
slug: 'draft-title',
content: 'This is my draft content.',
author_id: agent.id
}
}
post "/api/v1/accounts/#{account.id}/portals/#{portal.slug}/articles",
params: article_params,
headers: admin.create_new_auth_token
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload']['title']).to eql('DraftTitle')
expect(json_response['payload']['status']).to eql('draft')
end
it 'associate to the root article' do
root_article = create(:article, category: category, slug: 'root-article', portal: portal, account_id: account.id, author_id: agent.id,
associated_article_id: nil)
@@ -102,4 +102,146 @@ RSpec.describe 'Conversations API', type: :request do
end
end
end
describe 'GET /api/v1/accounts/{account.id}/conversations/:id/reporting_events' do
let(:conversation) { create(:conversation, account: account) }
let(:inbox) { conversation.inbox }
let(:agent) { administrator }
before do
# Create reporting events for this conversation
@event1 = create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'first_response',
value: 120,
created_at: 3.hours.ago)
@event2 = create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'reply_time',
value: 45,
created_at: 2.hours.ago)
@event3 = create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'resolution',
value: 300,
created_at: 1.hour.ago)
# Create an event for a different conversation (should not be included)
other_conversation = create(:conversation, account: account)
create(:reporting_event,
account: account,
conversation: other_conversation,
inbox: other_conversation.inbox,
user: agent,
name: 'other_conversation_event',
value: 60)
end
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user with conversation access' do
it 'returns all reporting events for the conversation' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
# Should return array directly (no pagination)
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(3)
# Check they are sorted by created_at asc (oldest first)
expect(json_response.first['name']).to eq('first_response')
expect(json_response.last['name']).to eq('resolution')
# Verify it doesn't include events from other conversations
event_names = json_response.map { |e| e['name'] }
expect(event_names).not_to include('other_conversation_event')
end
it 'returns empty array when conversation has no reporting events' do
conversation_without_events = create(:conversation, account: account)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation_without_events.display_id}/reporting_events",
headers: administrator.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to be_an(Array)
expect(json_response).to be_empty
end
end
context 'when agent has limited access' do
let(:limited_agent) { create(:user, account: account, role: :agent) }
it 'returns unauthorized for unassigned conversation without permission' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
headers: limited_agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'returns reporting events when agent is assigned to the conversation' do
conversation.update!(assignee: limited_agent)
# Also create inbox member for the agent
create(:inbox_member, user: limited_agent, inbox: conversation.inbox)
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
headers: limited_agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(3)
end
end
context 'when agent has team access' do
let(:team_agent) { create(:user, account: account, role: :agent) }
let(:team) { create(:team, account: account) }
before do
create(:team_member, team: team, user: team_agent)
conversation.update!(team: team)
end
it 'allows accessing conversation reporting events via team membership' do
get "/api/v1/accounts/#{account.id}/conversations/#{conversation.display_id}/reporting_events",
headers: team_agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response).to be_an(Array)
expect(json_response.size).to eq(3)
end
end
end
end
@@ -0,0 +1,217 @@
require 'rails_helper'
RSpec.describe 'Enterprise Reporting Events API', type: :request do
let!(:account) { create(:account) }
let!(:admin) { create(:user, account: account, role: :administrator) }
let!(:agent) { create(:user, account: account, role: :agent) }
let!(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: agent) }
describe 'GET /api/v1/accounts/{account.id}/reporting_events' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/reporting_events",
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated normal agent user' do
it 'returns unauthorized' do
get "/api/v1/accounts/#{account.id}/reporting_events",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated admin user' do
before do
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'first_response',
value: 120,
created_at: 3.days.ago)
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'resolution',
value: 300,
created_at: 2.days.ago)
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: 'reply_time',
value: 45,
created_at: 1.day.ago)
end
it 'fetches reporting events with pagination' do
get "/api/v1/accounts/#{account.id}/reporting_events",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
# Check structure and pagination
expect(json_response).to have_key('payload')
expect(json_response).to have_key('meta')
expect(json_response['meta']['count']).to eq(3)
# Check events are sorted by created_at desc (newest first)
events = json_response['payload']
expect(events.size).to eq(3)
expect(events.first['name']).to eq('reply_time')
expect(events.last['name']).to eq('first_response')
end
it 'filters reporting events by date range using since and until' do
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { since: 2.5.days.ago.to_time.to_i.to_s, until: 1.5.days.ago.to_time.to_i.to_s },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(1)
expect(json_response['payload'].first['name']).to eq('resolution')
end
it 'filters reporting events by inbox_id' do
other_inbox = create(:inbox, account: account)
other_conversation = create(:conversation, account: account, inbox: other_inbox)
create(:reporting_event,
account: account,
conversation: other_conversation,
inbox: other_inbox,
user: agent,
name: 'other_inbox_event')
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { inbox_id: inbox.id },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(3)
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_inbox_event')
end
it 'filters reporting events by user_id (agent)' do
other_agent = create(:user, account: account, role: :agent)
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: other_agent,
name: 'other_agent_event')
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { user_id: agent.id },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(3)
expect(json_response['payload'].map { |e| e['name'] }).not_to include('other_agent_event')
end
it 'filters reporting events by name' do
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { name: 'first_response' },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(1)
expect(json_response['payload'].first['name']).to eq('first_response')
end
it 'supports combining multiple filters' do
# Create more test data
other_conversation = create(:conversation, account: account, inbox: inbox, assignee: agent)
create(:reporting_event,
account: account,
conversation: other_conversation,
inbox: inbox,
user: agent,
name: 'first_response',
value: 90,
created_at: 2.days.ago)
get "/api/v1/accounts/#{account.id}/reporting_events",
params: {
inbox_id: inbox.id,
user_id: agent.id,
name: 'first_response',
since: 4.days.ago.to_time.to_i.to_s,
until: Time.zone.now.to_time.to_i.to_s
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['meta']['count']).to eq(2)
expect(json_response['payload'].map { |e| e['name'] }).to all(eq('first_response'))
end
context 'with pagination' do
before do
# Create more events to test pagination
30.times do |i|
create(:reporting_event,
account: account,
conversation: conversation,
inbox: inbox,
user: agent,
name: "event_#{i}",
created_at: i.hours.ago)
end
end
it 'returns 25 events per page by default' do
get "/api/v1/accounts/#{account.id}/reporting_events",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].size).to eq(25)
expect(json_response['meta']['count']).to eq(33) # 30 + 3 original events
expect(json_response['meta']['current_page']).to eq(1)
end
it 'supports page navigation' do
get "/api/v1/accounts/#{account.id}/reporting_events",
params: { page: 2 },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].size).to eq(8) # Remaining events
expect(json_response['meta']['current_page']).to eq(2)
end
end
end
end
end
+7 -5
View File
@@ -1,11 +1,13 @@
FactoryBot.define do
factory :reporting_event do
name { 'MyString' }
name { 'first_response' }
value { 1.5 }
value_in_business_hours { 1 }
account_id { 1 }
inbox_id { 1 }
user_id { 1 }
conversation_id { 1 }
account
inbox { association :inbox, account: account }
user { association :user, account: account }
conversation { association :conversation, account: account, inbox: inbox }
event_start_time { 2.hours.ago }
event_end_time { 1.hour.ago }
end
end
+9 -6
View File
@@ -41,28 +41,31 @@ RSpec.describe ApplicationMailbox do
describe 'Support' do
let!(:channel_email) { create(:channel_email) }
it 'routes support emails to Support Mailbox when mail is to channel email' do
it 'routes support emails to Reply Mailbox when mail is to channel email' do
# this email is hardcoded in the support.eml, that's why we are updating this
# With NewConversationStrategy, all channel emails route to ReplyMailbox
channel_email.update(email: 'care@example.com')
dbl = double
expect(SupportMailbox).to receive(:new).and_return(dbl)
expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route support_mail
end
it 'routes support emails to Support Mailbox when mail is to channel forward to email' do
it 'routes support emails to Reply Mailbox when mail is to channel forward to email' do
# this email is hardcoded in the support.eml, that's why we are updating this
# With NewConversationStrategy, all channel emails route to ReplyMailbox
channel_email.update(forward_to_email: 'care@example.com')
dbl = double
expect(SupportMailbox).to receive(:new).and_return(dbl)
expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route support_mail
end
it 'routes support emails to Support Mailbox with cc email' do
it 'routes support emails to Reply Mailbox with cc email' do
# With NewConversationStrategy, all channel emails route to ReplyMailbox
channel_email.update(email: 'test@example.com')
dbl = double
expect(SupportMailbox).to receive(:new).and_return(dbl)
expect(ReplyMailbox).to receive(:new).and_return(dbl)
expect(dbl).to receive(:perform_processing).and_return(true)
described_class.route reply_cc_mail
end
+49
View File
@@ -264,5 +264,54 @@ RSpec.describe Imap::ImapMailbox do
expect(conversation.additional_attributes['in_reply_to']).to eq(multiple_in_reply_to_mail.in_reply_to.first)
end
end
context 'when a reply to a conversation started by an agent' do
let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
let(:reply_mail_with_fallback_reference) do
# Simulate an email reply with a reference that matches FALLBACK_PATTERN
reference_id = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
create_inbound_email_from_mail(
from: 'email@gmail.com',
to: 'imap@gmail.com',
subject: 'Re: Agent started conversation',
references: [reference_id]
)
end
it 'appends email to the existing conversation using FALLBACK_PATTERN' do
expect(agent_conversation.messages.size).to eq(0)
class_instance.process(reply_mail_with_fallback_reference.mail, channel)
agent_conversation.reload
expect(agent_conversation.messages.size).to eq(1)
expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_fallback_reference.mail.from)
end
end
context 'when references contain both message and fallback patterns' do
let(:agent_conversation) { create(:conversation, account: account, inbox: channel.inbox, assignee: agent) }
let(:reply_mail_with_multiple_references) do
# Multiple references including both patterns
fallback_reference = "account/#{account.id}/conversation/#{agent_conversation.uuid}@chatwoot.com"
other_reference = 'some-other-message-id@example.com'
create_inbound_email_from_mail(
from: 'email@gmail.com',
to: 'imap@gmail.com',
subject: 'Re: Multiple references',
references: [other_reference, fallback_reference]
)
end
it 'finds conversation using fallback pattern when message lookup fails' do
expect(agent_conversation.messages.size).to eq(0)
class_instance.process(reply_mail_with_multiple_references.mail, channel)
agent_conversation.reload
expect(agent_conversation.messages.size).to eq(1)
expect(agent_conversation.messages.last.content_attributes['email']['from']).to eq(reply_mail_with_multiple_references.mail.from)
end
end
end
end
+443
View File
@@ -248,5 +248,448 @@ RSpec.describe ReplyMailbox do
)
end
end
context 'with references header' do
let(:reply_mail_with_references) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation_1) do
create(
:conversation,
assignee: agent,
inbox: email_channel.inbox,
account: account,
additional_attributes: { mail_subject: "Discussion: Let's debate these attachments" }
)
end
before do
conversation_1.update!(uuid: '6bdc3f4d-0bec-4515-a284-5d916fdde489')
end
context 'with message-specific pattern in references' do
before do
reply_mail_with_references.mail['References'] = '<conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123@test.com>'
end
it 'finds conversation from references header with message pattern' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with conversation fallback pattern in references' do
before do
reply_mail_with_references.mail['References'] = "<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>"
end
it 'finds conversation from references header with fallback pattern' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with multiple references including conversation pattern' do
before do
reply_mail_with_references.mail['References'] = [
'<some-random-message-id@gmail.com>',
"<account/#{account.id}/conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489@test.com>",
'<another-random-message-id@outlook.com>'
].join("\r\n ")
end
it 'finds conversation from any reference in the chain' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with message source_id in references' do
before do
conversation_1.messages.create!(
source_id: 'original-message-id@test.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Original message'
)
reply_mail_with_references.mail['References'] = '<original-message-id@test.com>'
end
it 'finds conversation from message source_id in references' do
described_class.receive reply_mail_with_references
expect(conversation_1.messages.last.content).to include("Let's talk about these images:")
end
end
context 'with conversation from different channel in references' do
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
let(:other_conversation) do
create(
:conversation,
assignee: agent,
inbox: other_email_channel.inbox,
account: account
)
end
before do
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
reply_mail_with_references.mail['References'] = '<conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@test.com>'
end
it 'does not use conversation from different channel' do
described_class.receive reply_mail_with_references
expect(other_conversation.messages.count).to eq(0)
end
end
end
end
describe 'when a chatwoot notification email is received' do
let(:account) { create(:account) }
let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
let(:described_subject) { described_class.receive notification_mail }
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
it 'shouldnt create a conversation in the channel' do
described_subject
expect(conversation.present?).to be(false)
end
end
describe 'when bounced email with out a sender is recieved' do
let(:account) { create(:account) }
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
let(:described_subject) { described_class.receive bounced_email }
it 'shouldnt throw an error' do
create(:channel_email, email: 'support@example.com', account: account)
expect { described_subject }.not_to raise_error
end
end
describe 'when an account is suspended' do
let(:account) { create(:account, status: :suspended) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let!(:channel_email) { create(:channel_email, account: account) }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
before do
# this email is hardcoded in the support.eml, that's why we are updating this
channel_email.email = 'care@example.com'
channel_email.save!
end
it 'shouldnt create a conversation in the channel' do
described_subject
expect(conversation.present?).to be(false)
end
end
describe 'add mail as a new ticket in the email inbox' do
let(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let!(:channel_email) { create(:channel_email, account: account) }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
before do
# this email is hardcoded in the support.eml, that's why we are updating this
channel_email.email = 'care@example.com'
channel_email.save!
end
describe 'covers email address format' do
before do
described_class.receive support_in_reply_to_mail
end
it 'creates contact with proper email address' do
expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew <sony@chatwoot.com>')
expect(conversation.contact.email).to eq('sony@chatwoot.com')
end
end
describe 'covers basic ticket creation' do
before do
described_subject
end
it 'create the conversation in the inbox of the email channel' do
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.additional_attributes['source']).to eq('email')
expect(conversation.contact.email).to eq(support_mail.mail.from.first)
end
it 'create a new contact as the sender of the email' do
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq(email_sender)
end
it 'add the mail content as new message on the conversation' do
expect(conversation.messages.last.content).to eq("Let's talk about these images:")
end
it 'add the attachments' do
expect(conversation.messages.last.attachments.count).to eq(2)
end
it 'have proper content_attributes with details of email' do
expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
end
it 'set proper content_type' do
expect(conversation.messages.last.content_type).to eq('incoming_email')
end
end
describe 'email with references header' do
let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
let(:described_subject) { described_class.receive mail_with_references }
before do
# reuse the existing channel_email that's already set to 'care@example.com'
described_subject
end
it 'includes references in the message content_attributes' do
message = conversation.messages.last
email_attributes = message.content_attributes['email']
expect(email_attributes['references']).to be_present
expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
end
it 'includes references in serialized email attributes' do
message = conversation.messages.last
expect(message.content_attributes['email'].keys).to include('references')
end
end
describe 'Sender without name' do
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
let(:described_subject) { described_class.receive support_mail_without_sender_name }
it 'create a new contact with the email' do
described_subject
email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq(email_sender)
end
end
describe 'Sender with upcase mail address' do
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
let(:described_subject) { described_class.receive support_mail_without_sender_name }
it 'create a new inbox with the email case insensitive' do
described_subject
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
end
end
describe 'handle inbox contacts' do
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
it 'does not create new contact if that contact exists in the inbox' do
expect do
described_subject
end
.to(not_change { Contact.count }
.and(not_change { ContactInbox.count }))
expect(conversation.messages.last.sender.id).to eq(contact.id)
expect(conversation.contact_inbox).to eq(contact_inbox)
end
context 'with uppercase reply-to' do
let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
it 'does not create new contact if that contact exists in the inbox' do
expect do
described_subject
end
.to(not_change { Contact.count }
.and(not_change { ContactInbox.count }))
expect(conversation.messages.last.sender.id).to eq(contact.id)
expect(conversation.contact_inbox).to eq(contact_inbox)
end
end
end
describe 'group email sender' do
let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
let(:described_subject) { described_class.receive group_sender_support_mail }
before do
# this email is hardcoded eml fixture file that's why we are updating this
channel_email.email = 'support@chatwoot.com'
channel_email.save!
end
it 'create new contact with original sender' do
described_subject
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
expect(conversation.contact.name).to eq(email_sender)
end
end
describe 'when mail has in reply to email' do
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
let(:described_subject) { described_class.receive reply_mail_without_uuid }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
before do
email_channel
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
end
it 'create channel with reply to mail' do
described_subject
conversation_1 = Conversation.last
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
end
it 'append message to email conversation with same in reply to' do
described_subject
conversation_1 = Conversation.last
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
expect(conversation_1.messages.count).to eq(1)
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
described_class.receive reply_mail_without_uuid
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
expect(conversation_1.messages.count).to eq(2)
end
end
describe 'Sender with reply_to email address' do
let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
it 'prefer reply-to over from address' do
email_channel
described_class.receive reply_to_mail
conversation_1 = Conversation.last
email = conversation_1.messages.last.content_attributes['email']
expect(reply_to_mail.mail['From'].value).to be_present
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
end
end
describe 'when mail part is not present' do
let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
let(:described_subject) { described_class.receive support_mail }
it 'Considers raw html mail body' do
described_subject
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
<<~BODY.chomp
Hi,
We are providing you platform from here you can sell paid posts on your website.
Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
Skype: live:.cid.something
[]
BODY
)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
end
it 'Considers only text body' do
described_class.receive only_text
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq('text only mail')
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
end
it 'Considers only html body' do
described_class.receive only_html
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq(
<<~BODY.chomp
This is html only mail
BODY
)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
end
it 'Considers only attachments' do
described_class.receive only_attachments
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to be_nil
expect(conversation.messages.last.attachments.count).to eq(1)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
end
it 'Considers html and attachments' do
described_class.receive html_and_attachments
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
expect(conversation.messages.last.attachments.count).to eq(1)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
end
end
describe 'when BCC processing is disabled for account' do
before do
allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
end
it 'does not process BCC-only emails' do
bcc_mail = create_inbound_email_from_fixture('support.eml')
bcc_mail.mail['to'] = nil
bcc_mail.mail['bcc'] = 'care@example.com'
described_class.receive bcc_mail
expect(conversation.present?).to be(false)
end
end
end
end
-352
View File
@@ -1,352 +0,0 @@
require 'rails_helper'
RSpec.describe SupportMailbox do
include ActionMailbox::TestHelper
describe 'when a chatwoot notification email is received' do
let(:account) { create(:account) }
let!(:channel_email) { create(:channel_email, email: 'sojan@chatwoot.com', account: account) }
let(:notification_mail) { create_inbound_email_from_fixture('notification.eml') }
let(:described_subject) { described_class.receive notification_mail }
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
it 'shouldnt create a conversation in the channel' do
described_subject
expect(conversation.present?).to be(false)
end
end
describe 'when bounced email with out a sender is recieved' do
let(:account) { create(:account) }
let(:bounced_email) { create_inbound_email_from_fixture('bounced_with_no_from.eml') }
let(:described_subject) { described_class.receive bounced_email }
it 'shouldnt throw an error' do
create(:channel_email, email: 'support@example.com', account: account)
expect { described_subject }.not_to raise_error
end
end
describe 'when an account is suspended' do
let(:account) { create(:account, status: :suspended) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let!(:channel_email) { create(:channel_email, account: account) }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
before do
# this email is hardcoded in the support.eml, that's why we are updating this
channel_email.email = 'care@example.com'
channel_email.save!
end
it 'shouldnt create a conversation in the channel' do
described_subject
expect(conversation.present?).to be(false)
end
end
describe 'add mail as a new ticket in the email inbox' do
let(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
let!(:channel_email) { create(:channel_email, account: account) }
let(:support_mail) { create_inbound_email_from_fixture('support.eml') }
let(:support_in_reply_to_mail) { create_inbound_email_from_fixture('support_in_reply_to.eml') }
let(:described_subject) { described_class.receive support_mail }
let(:serialized_attributes) do
%w[bcc cc content_type date from html_content in_reply_to message_id multipart number_of_attachments references subject
text_content to auto_reply]
end
let(:conversation) { Conversation.where(inbox_id: channel_email.inbox).last }
before do
# this email is hardcoded in the support.eml, that's why we are updating this
channel_email.email = 'care@example.com'
channel_email.save!
end
describe 'covers email address format' do
before do
described_class.receive support_in_reply_to_mail
end
it 'creates contact with proper email address' do
expect(support_in_reply_to_mail.mail['reply_to'].try(:value)).to eq('Sony Mathew <sony@chatwoot.com>')
expect(conversation.contact.email).to eq('sony@chatwoot.com')
end
end
describe 'covers basic ticket creation' do
before do
described_subject
end
it 'create the conversation in the inbox of the email channel' do
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.additional_attributes['source']).to eq('email')
expect(conversation.contact.email).to eq(support_mail.mail.from.first)
end
it 'create a new contact as the sender of the email' do
email_sender = Mail::Address.new(support_mail.mail[:from].value).name
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq(email_sender)
end
it 'add the mail content as new message on the conversation' do
expect(conversation.messages.last.content).to eq("Let's talk about these images:")
end
it 'add the attachments' do
expect(conversation.messages.last.attachments.count).to eq(2)
end
it 'have proper content_attributes with details of email' do
expect(conversation.messages.last.content_attributes[:email].keys).to eq(serialized_attributes)
end
it 'set proper content_type' do
expect(conversation.messages.last.content_type).to eq('incoming_email')
end
end
describe 'email with references header' do
let(:mail_with_references) { create_inbound_email_from_fixture('mail_with_references.eml') }
let(:described_subject) { described_class.receive mail_with_references }
before do
# reuse the existing channel_email that's already set to 'care@example.com'
described_subject
end
it 'includes references in the message content_attributes' do
message = conversation.messages.last
email_attributes = message.content_attributes['email']
expect(email_attributes['references']).to be_present
expect(email_attributes['references']).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
end
it 'includes references in serialized email attributes' do
message = conversation.messages.last
expect(message.content_attributes['email'].keys).to include('references')
end
end
describe 'Sender without name' do
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
let(:described_subject) { described_class.receive support_mail_without_sender_name }
it 'create a new contact with the email' do
described_subject
email_sender = support_mail_without_sender_name.mail.from.first.split('@').first
expect(conversation.messages.last.sender.email).to eq(support_mail.mail.from.first)
expect(conversation.contact.name).to eq(email_sender)
end
end
describe 'Sender with upcase mail address' do
let(:support_mail_without_sender_name) { create_inbound_email_from_fixture('support_without_sender_name.eml') }
let(:described_subject) { described_class.receive support_mail_without_sender_name }
it 'create a new inbox with the email case insensitive' do
described_subject
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
end
end
describe 'handle inbox contacts' do
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
it 'does not create new contact if that contact exists in the inbox' do
expect do
described_subject
end
.to(not_change { Contact.count }
.and(not_change { ContactInbox.count }))
expect(conversation.messages.last.sender.id).to eq(contact.id)
expect(conversation.contact_inbox).to eq(contact_inbox)
end
context 'with uppercase reply-to' do
let(:support_mail) { create_inbound_email_from_fixture('support_uppercase.eml') }
let!(:contact) { create(:contact, account: account, email: support_mail.mail.from.first) }
let!(:contact_inbox) { create(:contact_inbox, inbox: channel_email.inbox, contact: contact) }
it 'does not create new contact if that contact exists in the inbox' do
expect do
described_subject
end
.to(not_change { Contact.count }
.and(not_change { ContactInbox.count }))
expect(conversation.messages.last.sender.id).to eq(contact.id)
expect(conversation.contact_inbox).to eq(contact_inbox)
end
end
end
describe 'group email sender' do
let(:group_sender_support_mail) { create_inbound_email_from_fixture('group_sender_support.eml') }
let(:described_subject) { described_class.receive group_sender_support_mail }
before do
# this email is hardcoded eml fixture file that's why we are updating this
channel_email.email = 'support@chatwoot.com'
channel_email.save!
end
it 'create new contact with original sender' do
described_subject
email_sender = Mail::Address.new(group_sender_support_mail.mail[:from].value).name
expect(conversation.contact.email).to eq(group_sender_support_mail.mail['X-Original-Sender'].value)
expect(conversation.contact.name).to eq(email_sender)
end
end
describe 'when mail has in reply to email' do
let(:reply_mail_without_uuid) { create_inbound_email_from_fixture('reply_mail_without_uuid.eml') }
let(:described_subject) { described_class.receive reply_mail_without_uuid }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
before do
email_channel
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
end
it 'create channel with reply to mail' do
described_subject
conversation_1 = Conversation.last
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
end
it 'append message to email conversation with same in reply to' do
described_subject
conversation_1 = Conversation.last
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
expect(conversation_1.messages.count).to eq(1)
reply_mail_without_uuid.mail['In-Reply-To'] = 'conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123'
reply_mail_without_uuid.mail['Message-Id'] = '0CB459E0-0336-41DA-BC88-E6E28C697SFC@chatwoot.com'
described_class.receive reply_mail_without_uuid
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(conversation_1.additional_attributes['in_reply_to']).to eq('conversation/6bdc3f4d-0bec-4515-a284-5d916fdde489/messages/123')
expect(conversation_1.messages.count).to eq(2)
end
end
describe 'Sender with reply_to email address' do
let(:reply_to_mail) { create_inbound_email_from_fixture('reply_to.eml') }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
it 'prefer reply-to over from address' do
email_channel
described_class.receive reply_to_mail
conversation_1 = Conversation.last
email = conversation_1.messages.last.content_attributes['email']
expect(reply_to_mail.mail['From'].value).to be_present
expect(conversation_1.messages.last.content).to eq("Let's talk about these images:")
expect(reply_to_mail.mail['Reply-To'].value).to include(email['from'][0])
expect(reply_to_mail.mail['Reply-To'].value).to include(conversation_1.contact.email)
expect(reply_to_mail.mail['From'].value).not_to include(conversation_1.contact.email)
end
end
describe 'when mail part is not present' do
let(:support_mail) { create_inbound_email_from_fixture('support_1.eml') }
let(:only_text) { create_inbound_email_from_fixture('only_text.eml') }
let(:only_html) { create_inbound_email_from_fixture('only_html.eml') }
let(:only_attachments) { create_inbound_email_from_fixture('only_attachments.eml') }
let(:html_and_attachments) { create_inbound_email_from_fixture('html_and_attachments.eml') }
let(:described_subject) { described_class.receive support_mail }
it 'Considers raw html mail body' do
described_subject
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content_attributes['email']['html_content']['reply']).to include(
<<~BODY.chomp
Hi,
We are providing you platform from here you can sell paid posts on your website.
Chatwoot | CS team | [C](https://d33wubrfki0l68.cloudfront.net/973467c532160fd8b940300a43fa85fa2d060307/dc9a0/static/brand-73f58cdefae282ae74cebfa74c1d7003.svg)
Skype: live:.cid.something
[]
BODY
)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('Get Paid to post an article')
end
it 'Considers only text body' do
described_class.receive only_text
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq('text only mail')
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test text only mail')
end
it 'Considers only html body' do
described_class.receive only_html
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq(
<<~BODY.chomp
This is html only mail
BODY
)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('test html only mail')
end
it 'Considers only attachments' do
described_class.receive only_attachments
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to be_nil
expect(conversation.messages.last.attachments.count).to eq(1)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('only attachments')
end
it 'Considers html and attachments' do
described_class.receive html_and_attachments
expect(conversation.inbox.id).to eq(channel_email.inbox.id)
expect(conversation.messages.last.content).to eq('This is html and attachments only mail')
expect(conversation.messages.last.attachments.count).to eq(1)
expect(conversation.messages.last.content_attributes['email']['subject']).to eq('attachment with html')
end
end
describe 'when BCC processing is disabled for account' do
before do
allow(GlobalConfigService).to receive(:load).with('SKIP_INCOMING_BCC_PROCESSING', '').and_return(account.id.to_s)
end
it 'does not process BCC-only emails' do
bcc_mail = create_inbound_email_from_fixture('support.eml')
bcc_mail.mail['to'] = nil
bcc_mail.mail['bcc'] = 'care@example.com'
expect { described_class.receive bcc_mail }.to raise_error('Email channel/inbox not found')
end
end
end
end
@@ -0,0 +1,133 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinder do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
let(:mail) { Mail.new }
describe '#find' do
context 'when receiver uuid strategy finds conversation' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
end
it 'returns the conversation' do
finder = described_class.new(mail)
expect(finder.find).to eq(conversation)
end
it 'logs which strategy succeeded' do
allow(Rails.logger).to receive(:info)
finder = described_class.new(mail)
finder.find
expect(Rails.logger).to have_received(:info).with('Conversation found via receiver_uuid_strategy strategy')
end
end
context 'when in_reply_to strategy finds conversation' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'returns the conversation' do
finder = described_class.new(mail)
expect(finder.find).to eq(conversation)
end
it 'logs which strategy succeeded' do
allow(Rails.logger).to receive(:info)
finder = described_class.new(mail)
finder.find
expect(Rails.logger).to have_received(:info).with('Conversation found via in_reply_to_strategy strategy')
end
end
context 'when references strategy finds conversation' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test@example.com'
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'returns the conversation' do
finder = described_class.new(mail)
expect(finder.find).to eq(conversation)
end
it 'logs which strategy succeeded' do
allow(Rails.logger).to receive(:info)
finder = described_class.new(mail)
finder.find
expect(Rails.logger).to have_received(:info).with('Conversation found via references_strategy strategy')
end
end
context 'when no strategy finds conversation' do
# With NewConversationStrategy in default strategies, this scenario only happens
# when using custom strategies that exclude NewConversationStrategy
let(:finding_strategies) do
[
Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy,
Mailbox::ConversationFinderStrategies::InReplyToStrategy,
Mailbox::ConversationFinderStrategies::ReferencesStrategy
]
end
it 'returns nil' do
finder = described_class.new(mail, strategies: finding_strategies)
expect(finder.find).to be_nil
end
it 'logs that no conversation was found' do
allow(Rails.logger).to receive(:error)
finder = described_class.new(mail, strategies: finding_strategies)
finder.find
expect(Rails.logger).to have_received(:error).with('No conversation found via any strategy (NewConversationStrategy missing?)')
end
end
context 'with custom strategies' do
let(:custom_strategy_class) do
Class.new(Mailbox::ConversationFinderStrategies::BaseStrategy) do
def find
# Always return nil for testing
nil
end
end
end
it 'uses provided strategies instead of defaults' do
finder = described_class.new(mail, strategies: [custom_strategy_class])
expect(finder.find).to be_nil
end
end
context 'with strategy execution order' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
# Set up mail so all strategies could match
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/456@example.com'
end
it 'returns conversation from first matching strategy' do
allow(Rails.logger).to receive(:info)
finder = described_class.new(mail)
result = finder.find
expect(result).to eq(conversation)
# Should only log the first strategy that succeeded (ReceiverUuidStrategy)
expect(Rails.logger).to have_received(:info).once.with('Conversation found via receiver_uuid_strategy strategy')
end
end
end
end
@@ -0,0 +1,118 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinderStrategies::InReplyToStrategy do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
let(:mail) { Mail.new }
describe '#find' do
context 'when in_reply_to has message-specific pattern' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.in_reply_to = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'extracts UUID and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when in_reply_to has conversation fallback pattern' do
before do
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
mail.in_reply_to = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
end
it 'extracts UUID and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when in_reply_to matches message source_id' do
let(:message) do
conversation.messages.create!(
source_id: 'original-message-id@example.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Original message'
)
end
before do
message # Create the message
mail.in_reply_to = 'original-message-id@example.com'
end
it 'finds conversation from message source_id' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when in_reply_to has multiple values' do
let(:message) do
conversation.messages.create!(
source_id: 'message-123@example.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Test message'
)
end
before do
message # Create the message
mail.in_reply_to = ['some-other-id@example.com', 'message-123@example.com']
end
it 'finds conversation from any in_reply_to value' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when in_reply_to is blank' do
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when in_reply_to does not match any pattern or source_id' do
before do
mail.in_reply_to = 'random-message-id@gmail.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when UUID exists but conversation does not' do
before do
mail.in_reply_to = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'with malformed in_reply_to pattern' do
before do
mail.in_reply_to = 'conversation/not-a-uuid/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
end
end
@@ -0,0 +1,161 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinderStrategies::NewConversationStrategy do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, account: account) }
let(:mail) { Mail.new }
before do
mail.to = [email_channel.email]
mail.from = 'sender@example.com'
mail.subject = 'Test Subject'
mail.message_id = '<test@example.com>'
end
describe '#find' do
context 'when channel is found' do
context 'with new contact' do
it 'builds a new conversation with new contact' do
strategy = described_class.new(mail)
expect do
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true) # Not persisted yet
expect(conversation.inbox).to eq(email_channel.inbox)
expect(conversation.account).to eq(account)
end.to not_change(Conversation, :count) # No conversation created yet
.and change(Contact, :count).by(1) # Contact is created
.and change(ContactInbox, :count).by(1)
end
it 'sets conversation attributes correctly' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.additional_attributes['source']).to eq('email')
expect(conversation.additional_attributes['mail_subject']).to eq('Test Subject')
expect(conversation.additional_attributes['initiated_at']).to have_key('timestamp')
end
it 'sets contact attributes correctly' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.contact.email).to eq('sender@example.com')
expect(conversation.contact.name).to eq('sender')
end
end
context 'with existing contact' do
let!(:existing_contact) { create(:contact, email: 'sender@example.com', account: account) }
before do
create(:contact_inbox, contact: existing_contact, inbox: email_channel.inbox)
end
it 'builds conversation with existing contact' do
strategy = described_class.new(mail)
expect do
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true) # Not persisted yet
expect(conversation.contact).to eq(existing_contact)
end.to not_change(Conversation, :count) # No conversation created yet
.and not_change(Contact, :count)
.and not_change(ContactInbox, :count)
end
end
context 'when mail has In-Reply-To header' do
before do
mail['In-Reply-To'] = '<previous-message@example.com>'
end
it 'stores in_reply_to in additional_attributes' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.additional_attributes['in_reply_to']).to eq('<previous-message@example.com>')
end
end
context 'when mail is auto reply' do
before do
mail['X-Autoreply'] = 'yes'
end
it 'marks conversation as auto_reply' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.additional_attributes['auto_reply']).to be true
end
end
context 'when sender has name in From header' do
before do
mail.from = 'John Doe <john@example.com>'
end
it 'uses sender name from mail' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation.contact.name).to eq('John Doe')
end
end
end
context 'when channel is not found' do
before do
mail.to = ['nonexistent@example.com']
end
it 'returns nil' do
strategy = described_class.new(mail)
expect do
result = strategy.find
expect(result).to be_nil
end.not_to change(Conversation, :count)
end
end
context 'when contact creation fails' do
before do
builder = instance_double(ContactInboxWithContactBuilder)
allow(ContactInboxWithContactBuilder).to receive(:new).and_return(builder)
allow(builder).to receive(:perform).and_raise(ActiveRecord::RecordInvalid)
end
it 'rolls back the transaction' do
strategy = described_class.new(mail)
expect do
strategy.find
end.to raise_error(ActiveRecord::RecordInvalid)
.and not_change(Conversation, :count)
.and not_change(Contact, :count)
.and not_change(ContactInbox, :count)
end
end
context 'when conversation creation fails' do
before do
# Make conversation build fail with invalid attributes
allow(Conversation).to receive(:new).and_return(Conversation.new)
end
it 'returns invalid conversation object' do
strategy = described_class.new(mail)
conversation = strategy.find
expect(conversation).to be_a(Conversation)
expect(conversation.new_record?).to be(true)
expect(conversation.valid?).to be(false)
end
end
end
end
@@ -0,0 +1,99 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinderStrategies::ReceiverUuidStrategy do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
let(:mail) { Mail.new }
describe '#find' do
context 'when mail has valid reply+uuid format' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'reply+12345678-1234-1234-1234-123456789012@example.com'
end
it 'returns the conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when mail has uppercase UUID' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'reply+12345678-1234-1234-1234-123456789012@EXAMPLE.COM'
end
it 'returns the conversation (case-insensitive matching)' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when mail has multiple recipients with valid UUID' do
before do
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
mail.to = ['other@example.com', 'reply+aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com']
end
it 'extracts UUID from any recipient' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when UUID does not exist in database' do
before do
mail.to = 'reply+99999999-9999-9999-9999-999999999999@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail has no recipients' do
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail recipient has malformed UUID' do
before do
mail.to = 'reply+not-a-valid-uuid@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail recipient has no reply+ prefix' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test+12345678-1234-1234-1234-123456789012@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail recipient has additional text after UUID' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'reply+12345678-1234-1234-1234-123456789012-extra@example.com'
end
it 'returns nil (UUID must be exact)' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
end
end
@@ -0,0 +1,208 @@
require 'rails_helper'
RSpec.describe Mailbox::ConversationFinderStrategies::ReferencesStrategy do
let(:account) { create(:account) }
let(:email_channel) { create(:channel_email, email: 'test@example.com', account: account) }
let(:conversation) { create(:conversation, inbox: email_channel.inbox, account: account) }
let(:mail) { Mail.new }
describe '#find' do
context 'when references has message-specific pattern' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test@example.com'
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'extracts UUID and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when references has conversation fallback pattern' do
before do
conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
mail.to = 'test@example.com'
mail.references = "account/#{account.id}/conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee@example.com"
end
it 'extracts UUID and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when references matches message source_id' do
let(:message) do
conversation.messages.create!(
source_id: 'original-message-id@example.com',
account_id: account.id,
message_type: 'outgoing',
inbox_id: email_channel.inbox.id,
content: 'Original message'
)
end
before do
message # Create the message
mail.to = 'test@example.com'
mail.references = 'original-message-id@example.com'
end
it 'finds conversation from message source_id' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when references has multiple values' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test@example.com'
mail.references = [
'some-random-message@gmail.com',
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com',
'another-message@outlook.com'
]
end
it 'finds conversation from any reference' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when references is blank' do
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when references does not match any pattern or source_id' do
before do
mail.references = 'random-message-id@gmail.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'with channel validation' do
context 'when conversation belongs to the correct channel' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'test@example.com'
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'returns the conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
context 'when conversation belongs to a different channel' do
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
let(:other_conversation) do
create(
:conversation,
inbox: other_email_channel.inbox,
account: account
)
end
before do
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
# Mail is addressed to test@example.com but references conversation from other@example.com
mail.to = 'test@example.com'
mail.references = 'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com'
end
it 'returns nil (prevents cross-channel hijacking)' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when channel cannot be determined from mail' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = 'unknown@example.com' # Email not associated with any channel
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when mail has multiple recipients including correct channel' do
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
mail.to = ['other@example.com', 'test@example.com']
mail.references = 'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com'
end
it 'finds the correct channel and returns conversation' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
end
context 'when UUID exists but conversation does not' do
before do
mail.to = 'test@example.com'
mail.references = 'conversation/99999999-9999-9999-9999-999999999999/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'with malformed references pattern' do
before do
mail.references = 'conversation/not-a-uuid/messages/123@example.com'
end
it 'returns nil' do
strategy = described_class.new(mail)
expect(strategy.find).to be_nil
end
end
context 'when first reference fails channel validation but second succeeds' do
let(:other_email_channel) { create(:channel_email, email: 'other@example.com', account: account) }
let(:other_conversation) do
create(
:conversation,
inbox: other_email_channel.inbox,
account: account
)
end
before do
conversation.update!(uuid: '12345678-1234-1234-1234-123456789012')
other_conversation.update!(uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee')
mail.to = 'test@example.com'
mail.references = [
'conversation/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/messages/456@example.com', # Wrong channel
'conversation/12345678-1234-1234-1234-123456789012/messages/123@example.com' # Correct channel
]
end
it 'skips invalid reference and returns conversation from valid reference' do
strategy = described_class.new(mail)
expect(strategy.find).to eq(conversation)
end
end
end
end
+6
View File
@@ -248,3 +248,9 @@ contact_conversations_response:
$ref: ./resource/contact_conversations_response.yml
contactable_inboxes_response:
$ref: ./resource/contactable_inboxes_response.yml
reporting_event:
$ref: ./resource/reporting_event.yml
reporting_event_meta:
$ref: ./resource/reporting_event_meta.yml
reporting_events_list_response:
$ref: ./resource/reporting_events_list_response.yml
@@ -0,0 +1,47 @@
type: object
properties:
id:
type: number
description: ID of the reporting event
name:
type: string
description: Name of the event (e.g., first_response, resolution, reply_time)
value:
type: number
format: double
description: Value of the metric in seconds
value_in_business_hours:
type: number
format: double
description: Value of the metric in seconds, calculated only for business hours
event_start_time:
type: string
format: date-time
description: The timestamp when the event started
event_end_time:
type: string
format: date-time
description: The timestamp when the event ended
account_id:
type: number
description: ID of the account
conversation_id:
type: number
nullable: true
description: ID of the conversation
inbox_id:
type: number
nullable: true
description: ID of the inbox
user_id:
type: number
nullable: true
description: ID of the user/agent
created_at:
type: string
format: date-time
description: The timestamp when the reporting event was created
updated_at:
type: string
format: date-time
description: The timestamp when the reporting event was last updated
@@ -0,0 +1,11 @@
type: object
properties:
count:
type: integer
description: Total number of reporting events
current_page:
type: integer
description: Current page number
total_pages:
type: integer
description: Total number of pages
@@ -0,0 +1,10 @@
type: object
properties:
meta:
$ref: '#/components/schemas/reporting_event_meta'
description: Metadata about the reporting events list response
payload:
type: array
items:
$ref: '#/components/schemas/reporting_event'
description: List of reporting events
@@ -1,6 +1,6 @@
post:
tags:
- Contact
- Contacts
operationId: contactInboxCreation
description: Create a contact inbox record for an inbox
summary: Create contact inbox
@@ -1,6 +1,6 @@
get:
tags:
- Contact
- Contacts
operationId: contactableInboxesGet
description: Get List of contactable Inboxes
summary: Get Contactable Inboxes
@@ -0,0 +1,29 @@
tags:
- Conversations
operationId: get-conversation-reporting-events
summary: Conversation Reporting Events
security:
- userApiKey: []
description: Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.
responses:
'200':
description: Success
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/reporting_event'
description: Array of reporting events for the conversation
'403':
description: Access denied
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
'404':
description: Conversation not found
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
@@ -0,0 +1,47 @@
tags:
- Reports
operationId: get-account-reporting-events
summary: Account Reporting Events
security:
- userApiKey: []
description: Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.
parameters:
- $ref: '#/components/parameters/page'
- in: query
name: since
schema:
type: string
description: The timestamp from where events should start (Unix timestamp in seconds)
- in: query
name: until
schema:
type: string
description: The timestamp from where events should stop (Unix timestamp in seconds)
- in: query
name: inbox_id
schema:
type: number
description: Filter events by inbox ID
- in: query
name: user_id
schema:
type: number
description: Filter events by user/agent ID
- in: query
name: name
schema:
type: string
description: Filter events by event name (e.g., first_response, resolution, reply_time)
responses:
'200':
description: Success
content:
application/json:
schema:
$ref: '#/components/schemas/reporting_events_list_response'
'403':
description: Access denied - Only administrators can access this endpoint
content:
application/json:
schema:
$ref: '#/components/schemas/bad_request_error'
+16
View File
@@ -389,6 +389,15 @@
post:
$ref: ./application/conversation/labels/create.yml
# Conversation Reporting Events
/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events:
parameters:
- $ref: '#/components/parameters/account_id'
- $ref: '#/components/parameters/conversation_id'
get:
$ref: ./application/conversation/reporting_events.yml
# Inboxes
/api/v1/accounts/{account_id}/inboxes:
$ref: ./application/inboxes/index.yml
@@ -535,6 +544,13 @@
### Reports
# Account Reporting Events
/api/v1/accounts/{account_id}/reporting_events:
parameters:
- $ref: '#/components/parameters/account_id'
get:
$ref: ./application/reporting_events/index.yml
# List
/api/v2/accounts/{account_id}/reports:
parameters:
+242 -2
View File
@@ -3281,7 +3281,7 @@
"/api/v1/accounts/{account_id}/contacts/{id}/contact_inboxes": {
"post": {
"tags": [
"Contact"
"Contacts"
],
"operationId": "contactInboxCreation",
"description": "Create a contact inbox record for an inbox",
@@ -3366,7 +3366,7 @@
"/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes": {
"get": {
"tags": [
"Contact"
"Contacts"
],
"operationId": "contactableInboxesGet",
"description": "Get List of contactable Inboxes",
@@ -5140,6 +5140,65 @@
}
}
},
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"$ref": "#/components/parameters/conversation_id"
}
],
"get": {
"tags": [
"Conversations"
],
"operationId": "get-conversation-reporting-events",
"summary": "Conversation Reporting Events",
"security": [
{
"userApiKey": []
}
],
"description": "Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/reporting_event"
},
"description": "Array of reporting events for the conversation"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"404": {
"description": "Conversation not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v1/accounts/{account_id}/inboxes": {
"get": {
"tags": [
@@ -7311,6 +7370,93 @@
}
}
},
"/api/v1/accounts/{account_id}/reporting_events": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-account-reporting-events",
"summary": "Account Reporting Events",
"security": [
{
"userApiKey": []
}
],
"description": "Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.",
"parameters": [
{
"$ref": "#/components/parameters/page"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where events should start (Unix timestamp in seconds)"
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where events should stop (Unix timestamp in seconds)"
},
{
"in": "query",
"name": "inbox_id",
"schema": {
"type": "number"
},
"description": "Filter events by inbox ID"
},
{
"in": "query",
"name": "user_id",
"schema": {
"type": "number"
},
"description": "Filter events by user/agent ID"
},
{
"in": "query",
"name": "name",
"schema": {
"type": "string"
},
"description": "Filter events by event name (e.g., first_response, resolution, reply_time)"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/reporting_events_list_response"
}
}
}
},
"403": {
"description": "Access denied - Only administrators can access this endpoint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/reports": {
"parameters": [
{
@@ -12066,6 +12212,100 @@
"description": "List of contactable inboxes for the contact"
}
}
},
"reporting_event": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "ID of the reporting event"
},
"name": {
"type": "string",
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
},
"value": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds"
},
"value_in_business_hours": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds, calculated only for business hours"
},
"event_start_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event started"
},
"event_end_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event ended"
},
"account_id": {
"type": "number",
"description": "ID of the account"
},
"conversation_id": {
"type": "number",
"nullable": true,
"description": "ID of the conversation"
},
"inbox_id": {
"type": "number",
"nullable": true,
"description": "ID of the inbox"
},
"user_id": {
"type": "number",
"nullable": true,
"description": "ID of the user/agent"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was created"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was last updated"
}
}
},
"reporting_event_meta": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "Total number of reporting events"
},
"current_page": {
"type": "integer",
"description": "Current page number"
},
"total_pages": {
"type": "integer",
"description": "Total number of pages"
}
}
},
"reporting_events_list_response": {
"type": "object",
"properties": {
"meta": {
"$ref": "#/components/schemas/reporting_event_meta"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/components/schemas/reporting_event"
},
"description": "List of reporting events"
}
}
}
},
"parameters": {
+386
View File
@@ -1821,6 +1821,152 @@
}
}
},
"/api/v1/accounts/{account_id}/contacts/{id}/contact_inboxes": {
"post": {
"tags": [
"Contacts"
],
"operationId": "contactInboxCreation",
"description": "Create a contact inbox record for an inbox",
"summary": "Create contact inbox",
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"name": "id",
"in": "path",
"schema": {
"type": "number"
},
"description": "ID of the contact",
"required": true
}
],
"security": [
{
"userApiKey": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"inbox_id"
],
"properties": {
"inbox_id": {
"type": "number",
"description": "The ID of the inbox",
"example": 1
},
"source_id": {
"type": "string",
"description": "Contact Inbox Source Id"
}
}
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/contact_inboxes"
}
}
}
},
"401": {
"description": "Authentication error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"422": {
"description": "Incorrect payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v1/accounts/{account_id}/contacts/{id}/contactable_inboxes": {
"get": {
"tags": [
"Contacts"
],
"operationId": "contactableInboxesGet",
"description": "Get List of contactable Inboxes",
"summary": "Get Contactable Inboxes",
"security": [
{
"userApiKey": []
}
],
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"name": "id",
"in": "path",
"schema": {
"type": "number"
},
"description": "ID of the contact",
"required": true
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/contactable_inboxes_response"
}
}
}
},
"401": {
"description": "Authentication error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"422": {
"description": "Incorrect payload",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v1/accounts/{account_id}/automation_rules": {
"parameters": [
{
@@ -3537,6 +3683,65 @@
}
}
},
"/api/v1/accounts/{account_id}/conversations/{conversation_id}/reporting_events": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
},
{
"$ref": "#/components/parameters/conversation_id"
}
],
"get": {
"tags": [
"Conversations"
],
"operationId": "get-conversation-reporting-events",
"summary": "Conversation Reporting Events",
"security": [
{
"userApiKey": []
}
],
"description": "Get reporting events for a specific conversation. This endpoint returns events such as first response time, resolution time, and other metrics for the conversation, sorted by creation time in ascending order.",
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/reporting_event"
},
"description": "Array of reporting events for the conversation"
}
}
}
},
"403": {
"description": "Access denied",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
},
"404": {
"description": "Conversation not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v1/accounts/{account_id}/inboxes": {
"get": {
"tags": [
@@ -5708,6 +5913,93 @@
}
}
},
"/api/v1/accounts/{account_id}/reporting_events": {
"parameters": [
{
"$ref": "#/components/parameters/account_id"
}
],
"get": {
"tags": [
"Reports"
],
"operationId": "get-account-reporting-events",
"summary": "Account Reporting Events",
"security": [
{
"userApiKey": []
}
],
"description": "Get paginated reporting events for the account. This endpoint returns reporting events such as first response time, resolution time, and other metrics. Only administrators can access this endpoint. Results are paginated with 25 items per page.",
"parameters": [
{
"$ref": "#/components/parameters/page"
},
{
"in": "query",
"name": "since",
"schema": {
"type": "string"
},
"description": "The timestamp from where events should start (Unix timestamp in seconds)"
},
{
"in": "query",
"name": "until",
"schema": {
"type": "string"
},
"description": "The timestamp from where events should stop (Unix timestamp in seconds)"
},
{
"in": "query",
"name": "inbox_id",
"schema": {
"type": "number"
},
"description": "Filter events by inbox ID"
},
{
"in": "query",
"name": "user_id",
"schema": {
"type": "number"
},
"description": "Filter events by user/agent ID"
},
{
"in": "query",
"name": "name",
"schema": {
"type": "string"
},
"description": "Filter events by event name (e.g., first_response, resolution, reply_time)"
}
],
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/reporting_events_list_response"
}
}
}
},
"403": {
"description": "Access denied - Only administrators can access this endpoint",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/bad_request_error"
}
}
}
}
}
}
},
"/api/v2/accounts/{account_id}/reports": {
"parameters": [
{
@@ -10427,6 +10719,100 @@
"description": "List of contactable inboxes for the contact"
}
}
},
"reporting_event": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "ID of the reporting event"
},
"name": {
"type": "string",
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
},
"value": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds"
},
"value_in_business_hours": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds, calculated only for business hours"
},
"event_start_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event started"
},
"event_end_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event ended"
},
"account_id": {
"type": "number",
"description": "ID of the account"
},
"conversation_id": {
"type": "number",
"nullable": true,
"description": "ID of the conversation"
},
"inbox_id": {
"type": "number",
"nullable": true,
"description": "ID of the inbox"
},
"user_id": {
"type": "number",
"nullable": true,
"description": "ID of the user/agent"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was created"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was last updated"
}
}
},
"reporting_event_meta": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "Total number of reporting events"
},
"current_page": {
"type": "integer",
"description": "Current page number"
},
"total_pages": {
"type": "integer",
"description": "Total number of pages"
}
}
},
"reporting_events_list_response": {
"type": "object",
"properties": {
"meta": {
"$ref": "#/components/schemas/reporting_event_meta"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/components/schemas/reporting_event"
},
"description": "List of reporting events"
}
}
}
},
"parameters": {
+94
View File
@@ -5019,6 +5019,100 @@
"description": "List of contactable inboxes for the contact"
}
}
},
"reporting_event": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "ID of the reporting event"
},
"name": {
"type": "string",
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
},
"value": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds"
},
"value_in_business_hours": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds, calculated only for business hours"
},
"event_start_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event started"
},
"event_end_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event ended"
},
"account_id": {
"type": "number",
"description": "ID of the account"
},
"conversation_id": {
"type": "number",
"nullable": true,
"description": "ID of the conversation"
},
"inbox_id": {
"type": "number",
"nullable": true,
"description": "ID of the inbox"
},
"user_id": {
"type": "number",
"nullable": true,
"description": "ID of the user/agent"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was created"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was last updated"
}
}
},
"reporting_event_meta": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "Total number of reporting events"
},
"current_page": {
"type": "integer",
"description": "Current page number"
},
"total_pages": {
"type": "integer",
"description": "Total number of pages"
}
}
},
"reporting_events_list_response": {
"type": "object",
"properties": {
"meta": {
"$ref": "#/components/schemas/reporting_event_meta"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/components/schemas/reporting_event"
},
"description": "List of reporting events"
}
}
}
},
"parameters": {
+94
View File
@@ -4434,6 +4434,100 @@
"description": "List of contactable inboxes for the contact"
}
}
},
"reporting_event": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "ID of the reporting event"
},
"name": {
"type": "string",
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
},
"value": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds"
},
"value_in_business_hours": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds, calculated only for business hours"
},
"event_start_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event started"
},
"event_end_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event ended"
},
"account_id": {
"type": "number",
"description": "ID of the account"
},
"conversation_id": {
"type": "number",
"nullable": true,
"description": "ID of the conversation"
},
"inbox_id": {
"type": "number",
"nullable": true,
"description": "ID of the inbox"
},
"user_id": {
"type": "number",
"nullable": true,
"description": "ID of the user/agent"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was created"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was last updated"
}
}
},
"reporting_event_meta": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "Total number of reporting events"
},
"current_page": {
"type": "integer",
"description": "Current page number"
},
"total_pages": {
"type": "integer",
"description": "Total number of pages"
}
}
},
"reporting_events_list_response": {
"type": "object",
"properties": {
"meta": {
"$ref": "#/components/schemas/reporting_event_meta"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/components/schemas/reporting_event"
},
"description": "List of reporting events"
}
}
}
},
"parameters": {
+94
View File
@@ -5195,6 +5195,100 @@
"description": "List of contactable inboxes for the contact"
}
}
},
"reporting_event": {
"type": "object",
"properties": {
"id": {
"type": "number",
"description": "ID of the reporting event"
},
"name": {
"type": "string",
"description": "Name of the event (e.g., first_response, resolution, reply_time)"
},
"value": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds"
},
"value_in_business_hours": {
"type": "number",
"format": "double",
"description": "Value of the metric in seconds, calculated only for business hours"
},
"event_start_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event started"
},
"event_end_time": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the event ended"
},
"account_id": {
"type": "number",
"description": "ID of the account"
},
"conversation_id": {
"type": "number",
"nullable": true,
"description": "ID of the conversation"
},
"inbox_id": {
"type": "number",
"nullable": true,
"description": "ID of the inbox"
},
"user_id": {
"type": "number",
"nullable": true,
"description": "ID of the user/agent"
},
"created_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was created"
},
"updated_at": {
"type": "string",
"format": "date-time",
"description": "The timestamp when the reporting event was last updated"
}
}
},
"reporting_event_meta": {
"type": "object",
"properties": {
"count": {
"type": "integer",
"description": "Total number of reporting events"
},
"current_page": {
"type": "integer",
"description": "Current page number"
},
"total_pages": {
"type": "integer",
"description": "Total number of pages"
}
}
},
"reporting_events_list_response": {
"type": "object",
"properties": {
"meta": {
"$ref": "#/components/schemas/reporting_event_meta"
},
"payload": {
"type": "array",
"items": {
"$ref": "#/components/schemas/reporting_event"
},
"description": "List of reporting events"
}
}
}
},
"parameters": {