Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,52 @@
class AdministratorNotifications::AccountComplianceMailer < AdministratorNotifications::BaseMailer
def account_deleted(account)
return if instance_admin_email.blank?
subject = subject_for(account)
meta = build_meta(account)
send_notification(subject, to: instance_admin_email, meta: meta)
end
private
def build_meta(account)
deleted_users = params[:soft_deleted_users] || []
user_info_list = deleted_users.map do |user|
{
'user_id' => user[:id].to_s,
'user_email' => user[:original_email].to_s
}
end
{
'instance_url' => instance_url,
'account_id' => account.id,
'account_name' => account.name,
'deleted_at' => format_time(Time.current.iso8601),
'deletion_reason' => account.custom_attributes['marked_for_deletion_reason'] || 'not specified',
'marked_for_deletion_at' => format_time(account.custom_attributes['marked_for_deletion_at']),
'soft_deleted_users' => user_info_list,
'deleted_user_count' => user_info_list.size
}
end
def format_time(time_string)
return 'not specified' if time_string.blank?
Time.zone.parse(time_string).strftime('%B %d, %Y %H:%M:%S %Z')
end
def subject_for(account)
"Account Deletion Notice for #{account.id} - #{account.name}"
end
def instance_admin_email
GlobalConfig.get('CHATWOOT_INSTANCE_ADMIN_EMAIL')['CHATWOOT_INSTANCE_ADMIN_EMAIL']
end
def instance_url
ENV.fetch('FRONTEND_URL', 'not available')
end
end

View File

@@ -0,0 +1,70 @@
class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer
def account_deletion_user_initiated(account, reason)
subject = 'Your Chatwoot account deletion has been scheduled'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
'reason' => reason
}
send_notification(subject, action_url: action_url, meta: meta)
end
def account_deletion_for_inactivity(account, reason)
subject = 'Your Chatwoot account is scheduled for deletion due to inactivity'
action_url = settings_url('general')
meta = {
'account_name' => account.name,
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
'reason' => reason
}
send_notification(subject, action_url: action_url, meta: meta)
end
def contact_import_complete(resource)
subject = 'Contact Import Completed'
action_url = if resource.failed_records.attached?
Rails.application.routes.url_helpers.rails_blob_url(resource.failed_records)
else
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{resource.account.id}/contacts"
end
meta = {
'failed_contacts' => resource.total_records - resource.processed_records,
'imported_contacts' => resource.processed_records
}
send_notification(subject, action_url: action_url, meta: meta)
end
def contact_import_failed
subject = 'Contact Import Failed'
send_notification(subject)
end
def contact_export_complete(file_url, email_to)
subject = "Your contact's export file is available to download."
send_notification(subject, to: email_to, action_url: file_url)
end
def automation_rule_disabled(rule)
subject = 'Automation rule disabled due to validation errors.'
action_url = settings_url('automation/list')
meta = { 'rule_name' => rule.name }
send_notification(subject, action_url: action_url, meta: meta)
end
private
def format_deletion_date(deletion_date_str)
return 'Unknown' if deletion_date_str.blank?
Time.zone.parse(deletion_date_str).strftime('%B %d, %Y')
rescue StandardError
'Unknown'
end
end

View File

@@ -0,0 +1,31 @@
class AdministratorNotifications::BaseMailer < ApplicationMailer
# Common method to check SMTP configuration and send mail with liquid
def send_notification(subject, to: nil, action_url: nil, meta: {})
return unless smtp_config_set_or_development?
@action_url = action_url
@meta = meta || {}
send_mail_with_liquid(to: to || admin_emails, subject: subject) and return
end
# Helper method to generate inbox URL
def inbox_url(inbox)
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/inboxes/#{inbox.id}"
end
# Helper method to generate settings URL
def settings_url(section)
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/settings/#{section}"
end
private
def admin_emails
Current.account.administrators.pluck(:email)
end
def liquid_locals
super.merge({ meta: @meta })
end
end

View File

@@ -0,0 +1,26 @@
class AdministratorNotifications::ChannelNotificationsMailer < AdministratorNotifications::BaseMailer
def facebook_disconnect(inbox)
subject = 'Your Facebook page connection has expired'
send_notification(subject, action_url: inbox_url(inbox))
end
def instagram_disconnect(inbox)
subject = 'Your Instagram connection has expired'
send_notification(subject, action_url: inbox_url(inbox))
end
def tiktok_disconnect(inbox)
subject = 'Your TikTok connection has expired'
send_notification(subject, action_url: inbox_url(inbox))
end
def whatsapp_disconnect(inbox)
subject = 'Your Whatsapp connection has expired'
send_notification(subject, action_url: inbox_url(inbox))
end
def email_disconnect(inbox)
subject = 'Your email inbox has been disconnected. Please update the credentials for SMTP/IMAP'
send_notification(subject, action_url: inbox_url(inbox))
end
end

View File

@@ -0,0 +1,12 @@
class AdministratorNotifications::IntegrationsNotificationMailer < AdministratorNotifications::BaseMailer
def slack_disconnect
subject = 'Your Slack integration has expired'
action_url = settings_url('integrations/slack')
send_notification(subject, action_url: action_url)
end
def dialogflow_disconnect
subject = 'Your Dialogflow integration was disconnected'
send_notification(subject)
end
end

View File

@@ -0,0 +1,70 @@
class AgentNotifications::ConversationNotificationsMailer < ApplicationMailer
def conversation_creation(conversation, agent, _user)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
inbox_name = @conversation.inbox&.sanitized_name
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been created in #{inbox_name}."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
def conversation_assignment(conversation, agent, _user)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
subject = "#{@agent.available_name}, A new conversation [ID - #{@conversation.display_id}] has been assigned to you."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
def conversation_mention(conversation, agent, message)
return unless smtp_config_set_or_development?
@agent = agent
@conversation = conversation
@message = message
subject = "#{@agent.available_name}, You have been mentioned in conversation [ID - #{@conversation.display_id}]"
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
def assigned_conversation_new_message(conversation, agent, message)
return unless smtp_config_set_or_development?
# Don't spam with email notifications if agent is online
return if ::OnlineStatusTracker.get_presence(message.account_id, 'User', agent.id)
@agent = agent
@conversation = conversation
subject = "#{@agent.available_name}, New message in your assigned conversation [ID - #{@conversation.display_id}]."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
def participating_conversation_new_message(conversation, agent, message)
return unless smtp_config_set_or_development?
# Don't spam with email notifications if agent is online
return if ::OnlineStatusTracker.get_presence(message.account_id, 'User', agent.id)
@agent = agent
@conversation = conversation
subject = "#{@agent.available_name}, New message in your participating conversation [ID - #{@conversation.display_id}]."
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_mail_with_liquid(to: @agent.email, subject: subject) and return
end
private
def liquid_droppables
super.merge({
user: @agent,
conversation: @conversation,
inbox: @conversation.inbox,
message: @message
})
end
end
AgentNotifications::ConversationNotificationsMailer.prepend_mod_with('AgentNotifications::ConversationNotificationsMailer')

View File

@@ -0,0 +1,84 @@
class ApplicationMailer < ActionMailer::Base
include ActionView::Helpers::SanitizeHelper
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
before_action { ensure_current_account(params.try(:[], :account)) }
around_action :switch_locale
layout 'mailer/base'
# Fetch template from Database if available
# Order: Account Specific > Installation Specific > Fallback to file
prepend_view_path ::EmailTemplate.resolver
append_view_path Rails.root.join('app/views/mailers')
helper :frontend_urls
helper do
def global_config
@global_config ||= GlobalConfig.get('BRAND_NAME', 'BRAND_URL')
end
end
rescue_from(*ExceptionList::SMTP_EXCEPTIONS, with: :handle_smtp_exceptions)
def smtp_config_set_or_development?
ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development?
end
private
def handle_smtp_exceptions(message)
Rails.logger.warn 'Failed to send Email'
Rails.logger.error "Exception: #{message}"
end
def send_mail_with_liquid(*args)
Rails.logger.info "Email sent to #{args[0][:to]} with subject #{args[0][:subject]}"
mail(*args) do |format|
# explored sending a multipart email containing both text type and html
# parsing the html with nokogiri will remove the links as well
# might also remove tags like b,li etc. so lets rethink about this later
# format.text { Nokogiri::HTML(render(layout: false)).text }
format.html { render }
end
end
def liquid_droppables
# Merge additional objects into this in your mailer
# liquid template handler converts these objects into drop objects
{
account: Current.account,
user: @agent,
conversation: @conversation,
inbox: @conversation&.inbox
}
end
def liquid_locals
# expose variables you want to be exposed in liquid
locals = {
global_config: GlobalConfig.get('BRAND_NAME', 'BRAND_URL'),
action_url: @action_url
}
locals.merge({ attachment_url: @attachment_url }) if @attachment_url
locals.merge({ failed_contacts: @failed_contacts, imported_contacts: @imported_contacts })
locals
end
def locale_from_account(account)
return unless account
I18n.available_locales.map(&:to_s).include?(account.locale) ? account.locale : nil
end
def ensure_current_account(account)
Current.reset
Current.account = account if account.present?
end
def switch_locale(&)
locale ||= locale_from_account(Current.account)
locale ||= I18n.default_locale
# ensure locale won't bleed into other requests
# https://guides.rubyonrails.org/i18n.html#managing-the-locale-across-requests
I18n.with_locale(locale, &)
end
end

View File

@@ -0,0 +1,211 @@
class ConversationReplyMailer < ApplicationMailer
# We needs to expose large attachments to the view as links
# Small attachments are linked as mail attachments directly
attr_reader :large_attachments
include ConversationReplyMailerHelper
include ReferencesHeaderBuilder
default from: ENV.fetch('MAILER_SENDER_EMAIL', 'Chatwoot <accounts@chatwoot.com>')
layout :choose_layout
def reply_with_summary(conversation, last_queued_id)
return unless smtp_config_set_or_development?
init_conversation_attributes(conversation)
return if conversation_already_viewed?
recap_messages = @conversation.messages.chat.where('id < ?', last_queued_id).last(10)
new_messages = @conversation.messages.chat.where('id >= ?', last_queued_id)
@messages = recap_messages + new_messages
@messages = @messages.select(&:email_reply_summarizable?)
prepare_mail(true)
end
def reply_without_summary(conversation, last_queued_id)
return unless smtp_config_set_or_development?
init_conversation_attributes(conversation)
return if conversation_already_viewed?
@messages = @conversation.messages.chat.where(message_type: [:outgoing, :template]).where('id >= ?', last_queued_id)
@messages = @messages.reject { |m| m.template? && !m.input_csat? }
return false if @messages.count.zero?
prepare_mail(false)
end
def email_reply(message)
return unless smtp_config_set_or_development?
init_conversation_attributes(message.conversation)
@message = message
prepare_mail(true)
end
def conversation_transcript(conversation, to_email)
return unless smtp_config_set_or_development?
init_conversation_attributes(conversation)
@messages = @conversation.messages.chat.select(&:conversation_transcriptable?)
Rails.logger.info("Email sent from #{from_email_with_name} \
to #{to_email} with subject #{@conversation.display_id} \
#{I18n.t('conversations.reply.transcript_subject')} ")
mail({
to: to_email,
from: from_email_with_name,
subject: "[##{@conversation.display_id}] #{I18n.t('conversations.reply.transcript_subject')}"
})
end
private
def init_conversation_attributes(conversation)
@conversation = conversation
@account = @conversation.account
@contact = @conversation.contact
@agent = @conversation.assignee
@inbox = @conversation.inbox
@channel = @inbox.channel
end
def should_use_conversation_email_address?
@inbox.inbox_type == 'Email' || inbound_email_enabled?
end
def conversation_already_viewed?
# whether contact already saw the message on widget
return unless @conversation.contact_last_seen_at
return unless last_outgoing_message&.created_at
@conversation.contact_last_seen_at > last_outgoing_message&.created_at
end
def last_outgoing_message
@conversation.messages.chat.where.not(message_type: :incoming)&.last
end
def sender_name(sender_email)
if @inbox.friendly?
I18n.t('conversations.reply.email.header.friendly_name', sender_name: custom_sender_name, business_name: business_name,
from_email: sender_email)
else
I18n.t('conversations.reply.email.header.professional_name', business_name: business_name, from_email: sender_email)
end
end
def current_message
@message || @conversation.messages.outgoing.last
end
def custom_sender_name
current_message&.sender&.available_name || @agent&.available_name || I18n.t('conversations.reply.email.header.notifications')
end
def business_name
@inbox.business_name || @inbox.sanitized_name
end
def from_email
should_use_conversation_email_address? ? parse_email(@account.support_email) : parse_email(inbox_from_email_address)
end
def mail_subject
subject = @conversation.additional_attributes['mail_subject']
return "[##{@conversation.display_id}] #{I18n.t('conversations.reply.email_subject')}" if subject.nil?
chat_count = @conversation.messages.chat.count
if chat_count > 1
"Re: #{subject}"
else
subject
end
end
def reply_email
if should_use_conversation_email_address?
sender_name("reply+#{@conversation.uuid}@#{@account.inbound_email_domain}")
else
@inbox.email_address || @agent&.email
end
end
def from_email_with_name
sender_name(from_email)
end
def channel_email_with_name
sender_name(@channel.email)
end
def parse_email(email_string)
Mail::Address.new(email_string).address
end
def inbox_from_email_address
return @inbox.email_address if @inbox.email_address
@account.support_email
end
def custom_message_id
last_message = @message || @messages&.last
"<conversation/#{@conversation.uuid}/messages/#{last_message&.id}@#{channel_email_domain}>"
end
def in_reply_to_email
conversation_reply_email_id || "<account/#{@account.id}/conversation/#{@conversation.uuid}@#{channel_email_domain}>"
end
def conversation_reply_email_id
# Find the last incoming message's message_id to reply to
content_attributes = @conversation.messages.incoming.last&.content_attributes
if content_attributes && content_attributes['email'] && content_attributes['email']['message_id']
return "<#{content_attributes['email']['message_id']}>"
end
nil
end
def references_header
build_references_header(@conversation, in_reply_to_email)
end
def cc_bcc_emails
content_attributes = @conversation.messages.outgoing.last&.content_attributes
return [] unless content_attributes
return [] unless content_attributes[:cc_emails] || content_attributes[:bcc_emails]
[content_attributes[:cc_emails], content_attributes[:bcc_emails]]
end
def to_emails_from_content_attributes
content_attributes = @conversation.messages.outgoing.last&.content_attributes
return [] unless content_attributes
return [] unless content_attributes[:to_emails]
content_attributes[:to_emails]
end
def to_emails
# if there is no to_emails from content_attributes, send it to @contact&.email
to_emails_from_content_attributes.presence || [@contact&.email]
end
def inbound_email_enabled?
@inbound_email_enabled ||= @account.feature_enabled?('inbound_emails') && @account.inbound_email_domain
.present? && @account.support_email.present?
end
def choose_layout
return false if action_name == 'reply_without_summary' || action_name == 'email_reply'
'mailer/base'
end
end

View File

@@ -0,0 +1,51 @@
# Handles attachment processing for ConversationReplyMailer flows.
module ConversationReplyMailerAttachmentHelper
private
def process_attachments_as_files_for_email_reply
# Attachment processing for direct email replies (when replying to a single message)
#
# How attachments are handled:
# 1. Total file size (<20MB): Added directly to the email as proper attachments
# 2. Total file size (>20MB): Added to @large_attachments to be displayed as links in the email
@options[:attachments] = []
@large_attachments = []
current_total_size = 0
@message.attachments.each do |attachment|
current_total_size = handle_attachment_inline(current_total_size, attachment)
end
end
def read_blob_content(blob)
buffer = +''
blob.open do |file|
while (chunk = file.read(64.kilobytes))
buffer << chunk
end
end
buffer
end
def handle_attachment_inline(current_total_size, attachment)
blob = attachment.file.blob
return current_total_size if blob.blank?
file_size = blob.byte_size
attachment_name = attachment.file.filename.to_s
if current_total_size + file_size <= 20.megabytes
content = read_blob_content(blob)
mail.attachments[attachment_name] = {
mime_type: attachment.file.content_type || 'application/octet-stream',
content: content
}
@options[:attachments] << { name: attachment_name }
current_total_size + file_size
else
@large_attachments << attachment
current_total_size
end
end
end

View File

@@ -0,0 +1,113 @@
module ConversationReplyMailerHelper
include ConversationReplyMailerAttachmentHelper
def prepare_mail(cc_bcc_enabled)
@options = {
to: to_emails,
from: email_from,
reply_to: email_reply_to,
subject: mail_subject,
message_id: custom_message_id,
in_reply_to: in_reply_to_email,
references: references_header
}
if cc_bcc_enabled
@options[:cc] = cc_bcc_emails[0]
@options[:bcc] = cc_bcc_emails[1]
end
oauth_smtp_settings
set_delivery_method
# Email type detection logic:
# - email_reply: Sets @message with a single message
# - Other actions: Set @messages with a collection of messages
#
# So this check implicitly determines we're handling an email_reply
# and not one of the other email types (summary, transcript, etc.)
process_attachments_as_files_for_email_reply if @message&.attachments.present?
mail(@options)
end
private
def oauth_smtp_settings
return unless @inbox.email? && @channel.imap_enabled
return unless oauth_provider_domain
@options[:delivery_method] = :smtp
@options[:delivery_method_options] = base_smtp_settings(oauth_provider_domain)
end
def oauth_provider_domain
return 'smtp.gmail.com' if @inbox.channel.google?
return 'smtp.office365.com' if @inbox.channel.microsoft?
end
def base_smtp_settings(domain)
{
address: domain,
port: 587,
user_name: @channel.imap_login,
password: @channel.provider_config['access_token'],
domain: domain,
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
open_timeout: 15,
read_timeout: 15,
authentication: 'xoauth2'
}
end
def set_delivery_method
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled
smtp_settings = {
address: @channel.smtp_address,
port: @channel.smtp_port,
user_name: @channel.smtp_login,
password: @channel.smtp_password,
domain: @channel.smtp_domain,
tls: @channel.smtp_enable_ssl_tls,
enable_starttls_auto: @channel.smtp_enable_starttls_auto,
openssl_verify_mode: @channel.smtp_openssl_verify_mode,
authentication: @channel.smtp_authentication
}
@options[:delivery_method] = :smtp
@options[:delivery_method_options] = smtp_settings
end
def email_smtp_enabled
@inbox.inbox_type == 'Email' && @channel.smtp_enabled
end
def email_imap_enabled
@inbox.inbox_type == 'Email' && @channel.imap_enabled
end
def email_oauth_enabled
@inbox.inbox_type == 'Email' && (@channel.microsoft? || @channel.google?)
end
def email_from
return Email::FromBuilder.new(inbox: @inbox, message: current_message).build if @account.feature_enabled?(:reply_mailer_migration)
email_oauth_enabled || email_smtp_enabled ? channel_email_with_name : from_email_with_name
end
def email_reply_to
return Email::ReplyToBuilder.new(inbox: @inbox, message: current_message).build if @account.feature_enabled?(:reply_mailer_migration)
email_imap_enabled ? @channel.email : reply_email
end
# Use channel email domain in case of account email domain is not set for custom message_id and in_reply_to
def channel_email_domain
return @account.inbound_email_domain if @account.inbound_email_domain.present?
email = @inbox.channel.try(:email)
email.present? ? email.split('@').last : raise(StandardError, 'Channel email domain not present.')
end
end

View File

@@ -0,0 +1,41 @@
class PortalInstructionsMailer < ApplicationMailer
def send_cname_instructions(portal:, recipient_email:)
return unless smtp_config_set_or_development?
return if target_domain.blank?
@portal = portal
@cname_record = generate_cname_record
send_mail_with_liquid(
to: recipient_email,
subject: I18n.t('portals.send_instructions.subject', custom_domain: @portal.custom_domain)
)
end
private
def liquid_locals
super.merge({ cname_record: @cname_record })
end
def generate_cname_record
"#{@portal.custom_domain} CNAME #{target_domain}"
end
def target_domain
helpcenter_url = ENV.fetch('HELPCENTER_URL', '')
frontend_url = ENV.fetch('FRONTEND_URL', '')
return extract_hostname(helpcenter_url) if helpcenter_url.present?
return extract_hostname(frontend_url) if frontend_url.present?
''
end
def extract_hostname(url)
uri = URI.parse(url)
uri.host
rescue URI::InvalidURIError
url.gsub(%r{https?://}, '').split('/').first
end
end

View File

@@ -0,0 +1,101 @@
# Builds RFC 5322 compliant References headers for email threading
#
# This module provides functionality to construct proper References headers
# that maintain email conversation threading according to RFC 5322 standards.
module ReferencesHeaderBuilder
# Builds a complete References header for an email reply
#
# According to RFC 5322, the References header should contain:
# - References from the message being replied to (if available)
# - The In-Reply-To message ID as the final element
# - Proper line folding if the header exceeds 998 characters
#
# If the message being replied to has no stored References, we use a minimal
# approach with only the In-Reply-To message ID rather than rebuilding.
#
# @param conversation [Conversation] The conversation containing the message thread
# @param in_reply_to_message_id [String] The message ID being replied to
# @return [String] A properly formatted and folded References header value
def build_references_header(conversation, in_reply_to_message_id)
references = get_references_from_replied_message(conversation, in_reply_to_message_id)
references << in_reply_to_message_id
references = references.compact.uniq
fold_references_header(references)
rescue StandardError => e
Rails.logger.error("Error building references header for ##{conversation.id}: #{e.message}")
ChatwootExceptionTracker.new(e, account: conversation.account).capture_exception
''
end
private
# Gets References header from the message being replied to
#
# Finds the message by its source_id matching the in_reply_to_message_id
# and extracts its stored References header. If no References are found,
# we return an empty array (minimal approach - no rebuilding).
#
# @param conversation [Conversation] The conversation containing the message thread
# @param in_reply_to_message_id [String] The message ID being replied to
# @return [Array<String>] Array of properly formatted message IDs with angle brackets
def get_references_from_replied_message(conversation, in_reply_to_message_id)
return [] if in_reply_to_message_id.blank?
replied_to_message = find_replied_to_message(conversation, in_reply_to_message_id)
return [] unless replied_to_message
extract_references_from_message(replied_to_message)
end
# Finds the message being replied to based on its source_id
#
# @param conversation [Conversation] The conversation containing the message thread
# @param in_reply_to_message_id [String] The message ID to search for
# @return [Message, nil] The message being replied to
def find_replied_to_message(conversation, in_reply_to_message_id)
return nil if in_reply_to_message_id.blank?
# Remove angle brackets if present for comparison
normalized_id = in_reply_to_message_id.gsub(/[<>]/, '')
# Use database query to find the message efficiently
# Search for exact match or with angle brackets
conversation.messages
.where.not(source_id: nil)
.where('source_id = ? OR source_id = ? OR source_id = ?',
normalized_id,
"<#{normalized_id}>",
in_reply_to_message_id)
.first
end
# Extracts References header from a message's content_attributes
#
# @param message [Message] The message to extract References from
# @return [Array<String>] Array of properly formatted message IDs with angle brackets
def extract_references_from_message(message)
return [] unless message.content_attributes&.dig('email', 'references')
references = message.content_attributes['email']['references']
Array.wrap(references).map do |ref|
ref.start_with?('<') ? ref : "<#{ref}>"
end
end
# Folds References header to comply with RFC 5322 line folding requirements
#
# RFC 5322 requires that continuation lines in folded headers start with
# whitespace (space or tab). This method joins message IDs with CRLF + space,
# ensuring the first line has no leading space and all continuation lines
# start with a space as required by the RFC.
#
# @param references_array [Array<String>] Array of message IDs to be folded
# @return [String] A properly folded header value with CRLF line endings
def fold_references_header(references_array)
return '' if references_array.empty?
return references_array.first if references_array.size == 1
references_array.join("\r\n ")
end
end

View File

@@ -0,0 +1,34 @@
class TeamNotifications::AutomationNotificationMailer < ApplicationMailer
def conversation_creation(conversation, team, message)
return unless smtp_config_set_or_development?
@agents = team.team_members
@conversation = conversation
@custom_message = message
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
send_an_email_to_team
end
private
def send_an_email_to_team
subject = 'This email has been sent via automation rule actions.'
@action_url = app_account_conversation_url(account_id: @conversation.account_id, id: @conversation.display_id)
@agent_emails = @agents.collect(&:user).pluck(:email)
send_mail_with_liquid(to: @agent_emails, subject: subject) and return
end
def liquid_droppables
super.merge!({
conversation: @conversation,
inbox: @conversation.inbox
})
end
def liquid_locals
super.merge!({
custom_message: @custom_message
})
end
end