Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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')
|
||||
84
research/chatwoot/app/mailers/application_mailer.rb
Normal file
84
research/chatwoot/app/mailers/application_mailer.rb
Normal 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
|
||||
211
research/chatwoot/app/mailers/conversation_reply_mailer.rb
Normal file
211
research/chatwoot/app/mailers/conversation_reply_mailer.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
41
research/chatwoot/app/mailers/portal_instructions_mailer.rb
Normal file
41
research/chatwoot/app/mailers/portal_instructions_mailer.rb
Normal 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
|
||||
101
research/chatwoot/app/mailers/references_header_builder.rb
Normal file
101
research/chatwoot/app/mailers/references_header_builder.rb
Normal 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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user