diff --git a/.circleci/config.yml b/.circleci/config.yml index 235289a2f..2c55d21b2 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -13,6 +13,7 @@ defaults: &defaults # CircleCI maintains a library of pre-built images # documented at https://circleci.com/docs/2.0/circleci-images/ - image: circleci/postgres:9.4 + - image: circleci/redis:5.0.7-alpine environment: - CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f diff --git a/.env.example b/.env.example index b83e8dac1..ba3269134 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,8 @@ FB_APP_ID= MAILER_SENDER_EMAIL=accounts@chatwoot.com SMTP_PORT=1025 SMTP_DOMAIN=chatwoot.com +# if you are running docker-compose, set SMTP_ADDRESS value as "mailhog", +# else set the value as "localhost" SMTP_ADDRESS=mailhog SMTP_USERNAME= SMTP_PASSWORD= diff --git a/.rubocop.yml b/.rubocop.yml index b007be1d5..09b36e576 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,6 +16,10 @@ Style/FrozenStringLiteralComment: Enabled: false Style/SymbolArray: Enabled: false +Style/GlobalVars: + Exclude: + - 'config/initializers/redis.rb' + - 'lib/redis/alfred.rb' Metrics/BlockLength: Exclude: - spec/**/* diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb index b4715b0af..24f927f14 100644 --- a/app/mailers/application_mailer.rb +++ b/app/mailers/application_mailer.rb @@ -4,4 +4,8 @@ class ApplicationMailer < ActionMailer::Base # helpers helper :frontend_urls + + def smtp_config_set_or_development? + ENV.fetch('SMTP_ADDRESS', nil).present? || Rails.env.development? + end end diff --git a/app/mailers/assignment_mailer.rb b/app/mailers/assignment_mailer.rb index 5ba9f874d..54920f2cf 100644 --- a/app/mailers/assignment_mailer.rb +++ b/app/mailers/assignment_mailer.rb @@ -3,7 +3,7 @@ class AssignmentMailer < ApplicationMailer layout 'mailer' def conversation_assigned(conversation, agent) - return if ENV.fetch('SMTP_ADDRESS', nil).blank? + return unless smtp_config_set_or_development? @agent = agent @conversation = conversation diff --git a/app/mailers/conversation_mailer.rb b/app/mailers/conversation_mailer.rb new file mode 100644 index 000000000..557625f2c --- /dev/null +++ b/app/mailers/conversation_mailer.rb @@ -0,0 +1,26 @@ +class ConversationMailer < ApplicationMailer + default from: ENV.fetch('MAILER_SENDER_EMAIL', 'accounts@chatwoot.com') + layout 'mailer' + + def new_message(conversation, message_queued_time) + return unless smtp_config_set_or_development? + + @conversation = conversation + @contact = @conversation.contact + @agent = @conversation.assignee + + recap_messages = @conversation.messages.where('created_at < ?', message_queued_time).order(created_at: :asc).last(10) + new_messages = @conversation.messages.where('created_at >= ?', message_queued_time) + + @messages = recap_messages + new_messages + @messages = @messages.select(&:reportable?) + + mail(to: @contact&.email, from: @agent&.email, subject: mail_subject(@messages.last)) + end + + private + + def mail_subject(last_message, trim_length = 30) + "[##{@conversation.display_id}] #{last_message.content.truncate(trim_length)}" + end +end diff --git a/app/models/message.rb b/app/models/message.rb index 8221fffe8..4e2e40abf 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -49,7 +49,8 @@ class Message < ApplicationRecord after_create :reopen_conversation, :dispatch_event, :send_reply, - :execute_message_template_hooks + :execute_message_template_hooks, + :notify_via_mail def channel_token @token ||= inbox.channel.try(:page_access_token) @@ -94,4 +95,17 @@ class Message < ApplicationRecord def execute_message_template_hooks ::MessageTemplates::HookExecutionService.new(message: self).perform end + + def notify_via_mail + conversation_mail_key = Redis::Alfred::CONVERSATION_MAILER_KEY % conversation.id + if Redis::Alfred.get(conversation_mail_key).nil? && conversation.contact.email? && outgoing? + # set a redis key for the conversation so that we don't need to send email for every + # new message that comes in and we dont enque the delayed sidekiq job for every message + Redis::Alfred.setex(conversation_mail_key, Time.zone.now) + + # Since this is live chat, send the email after few minutes so the only one email with + # last few messages coupled together is sent rather than email for each message + ConversationEmailWorker.perform_in(2.minutes, conversation.id, Time.zone.now) + end + end end diff --git a/app/views/conversation_mailer/new_message.html.erb b/app/views/conversation_mailer/new_message.html.erb new file mode 100644 index 000000000..fd59b027c --- /dev/null +++ b/app/views/conversation_mailer/new_message.html.erb @@ -0,0 +1,16 @@ +

Hi <%= @contact.name %>,

+ +

You have new messages on your conversation.

+ + + <% @messages.each do |message| %> + + + + + <% end %> +
+ <%= message.incoming? ? 'You' : message.user.name %> + : <%= message.content %>
+ +

Click <%= link_to 'here', app_conversation_url(id: @conversation.display_id) %> to get back to the conversation.

diff --git a/app/workers/conversation_email_worker.rb b/app/workers/conversation_email_worker.rb new file mode 100644 index 000000000..161c32521 --- /dev/null +++ b/app/workers/conversation_email_worker.rb @@ -0,0 +1,15 @@ +class ConversationEmailWorker + include Sidekiq::Worker + sidekiq_options queue: :mailers + + def perform(conversation_id, queued_time) + @conversation = Conversation.find(conversation_id) + + # send the email + ConversationMailer.new_message(@conversation, queued_time).deliver_later + + # delete the redis set from the first new message on the conversation + conversation_mail_key = Redis::Alfred::CONVERSATION_MAILER_KEY % @conversation.id + Redis::Alfred.delete(conversation_mail_key) + end +end diff --git a/config/environments/development.rb b/config/environments/development.rb index 3021513c4..5ef763329 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -34,7 +34,7 @@ Rails.application.configure do # Don't care if the mailer can't send. config.active_job.queue_adapter = :sidekiq - + config.action_mailer.raise_delivery_errors = false config.action_mailer.perform_caching = false @@ -42,13 +42,17 @@ Rails.application.configure do config.action_mailer.perform_deliveries = true config.action_mailer.raise_delivery_errors = true - config.action_mailer.delivery_method = :smtp config.action_mailer.default_url_options = { host: 'localhost:3000' } + # If you want to use letter opener instead of mailhog for testing emails locally, + # uncomment the following line L49 and comment lines L51 through to L65 + # config.action_mailer.delivery_method = :letter_opener + + config.action_mailer.delivery_method = :smtp smtp_settings = { - port: ENV['SMTP_PORT'], - domain: ENV['SMTP_DOMAIN'], - address: ENV['SMTP_ADDRESS'] + port: ENV['SMTP_PORT'] || 25, + domain: ENV['SMTP_DOMAIN'] || 'localhost', + address: ENV['SMTP_ADDRESS'] || 'chatwoot.com' } if ENV['SMTP_AUTHENTICATION'].present? diff --git a/config/environments/production.rb b/config/environments/production.rb index e6534d8ed..a2031ce37 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -59,7 +59,7 @@ Rails.application.configure do config.action_mailer.default_url_options = { host: ENV['FRONTEND_URL'] } config.action_mailer.smtp_settings = { address: ENV['SMTP_ADDRESS'], - port: 587, + port: ENV['SMTP_PORT'] || 587, user_name: ENV['SMTP_USERNAME'], password: ENV['SMTP_PASSWORD'], authentication: :login, diff --git a/config/initializers/redis.rb b/config/initializers/redis.rb index 4b9bffc03..d9e22832e 100644 --- a/config/initializers/redis.rb +++ b/config/initializers/redis.rb @@ -2,5 +2,6 @@ uri = URI.parse(ENV.fetch('REDIS_URL', 'redis://127.0.0.1:6379')) redis = Rails.env.test? ? MockRedis.new : Redis.new(url: uri) Nightfury.redis = Redis::Namespace.new('reports', redis: redis) -# Alfred - Used currently for Round Robin. Add here as you use it for more features +# Alfred - Used currently for round robin and conversation emails. +# Add here as you use it for more features $alfred = Redis::Namespace.new('alfred', redis: redis, warning: true) diff --git a/config/sidekiq.yml b/config/sidekiq.yml index 57abb9933..0f8bb4bf2 100644 --- a/config/sidekiq.yml +++ b/config/sidekiq.yml @@ -11,10 +11,10 @@ # even put in dynamic logic, like a host-specific queue. # http://www.mikeperham.com/2013/11/13/advanced-sidekiq-host-specific-queues/ :queues: - - critical - - default - - low - - mailers + - [critical, 5] + - [default, 2] + - [low, 1] + - [mailers, 2] # you can override concurrency based on environment production: diff --git a/lib/redis/alfred.rb b/lib/redis/alfred.rb index 664d19225..d811df028 100644 --- a/lib/redis/alfred.rb +++ b/lib/redis/alfred.rb @@ -1,4 +1,6 @@ module Redis::Alfred + CONVERSATION_MAILER_KEY = 'CONVERSATION::%d'.freeze + class << self def rpoplpush(source, destination) $alfred.rpoplpush(source, destination) @@ -15,5 +17,13 @@ module Redis::Alfred def lrem(key, value, count = 0) $alfred.lrem(key, count, value) end + + def setex(key, value, expiry = 1.day) + $alfred.setex(key, expiry, value) + end + + def get(key) + $alfred.get(key) + end end end diff --git a/spec/mailers/conversation_mailer_spec.rb b/spec/mailers/conversation_mailer_spec.rb new file mode 100644 index 000000000..759db30db --- /dev/null +++ b/spec/mailers/conversation_mailer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe ConversationMailer, type: :mailer do + describe 'new_message' do + let(:agent) { create(:user, email: 'agent1@example.com') } + let(:conversation) { create(:conversation, assignee: agent) } + let(:message) { create(:message, conversation: conversation) } + let(:mail) { described_class.new_message(message.conversation, Time.zone.now).deliver_now } + let(:class_instance) { described_class.new } + + before do + allow(described_class).to receive(:new).and_return(class_instance) + allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true) + end + + it 'renders the subject' do + expect(mail.subject).to eq("[##{message.conversation.display_id}] #{message.content.truncate(30)}") + end + + it 'renders the receiver email' do + expect(mail.to).to eq([message&.conversation&.contact&.email]) + end + + it 'renders the sender email' do + expect(mail.from).to eq([message&.conversation&.assignee&.email]) + end + end +end diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index 6df065b24..7c3dea709 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -22,5 +22,11 @@ RSpec.describe Message, type: :model do expect(::MessageTemplates::HookExecutionService).to have_received(:new).with(message: message) expect(hook_execution_service).to have_received(:perform) end + + it 'calls notify email method on after save' do + allow(message).to receive(:notify_via_mail).and_return(true) + message.save! + expect(message).to have_received(:notify_via_mail) + end end end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 9f5d94278..1693f1e76 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -5,6 +5,7 @@ require File.expand_path('../config/environment', __dir__) abort('The Rails environment is running in production mode!') if Rails.env.production? require 'rspec/rails' require 'pundit/rspec' +require 'sidekiq/testing' # Add additional requires below this line. Rails is not loaded until this point! diff --git a/spec/workers/conversation_email_worker_spec.rb b/spec/workers/conversation_email_worker_spec.rb new file mode 100644 index 000000000..aafd41ee8 --- /dev/null +++ b/spec/workers/conversation_email_worker_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +Sidekiq::Testing.fake! +RSpec.describe ConversationEmailWorker, type: :worker do + let(:perform_at) { (Time.zone.today + 6.hours).to_datetime } + let(:scheduled_job) { described_class.perform_at(perform_at, 1, Time.zone.now) } + let(:conversation) { build(:conversation, display_id: nil) } + + describe 'testing ConversationEmailWorker' do + before do + conversation.save! + allow(Conversation).to receive(:find).and_return(conversation) + mailer = double + allow(ConversationMailer).to receive(:new_message).and_return(mailer) + allow(mailer).to receive(:deliver_later).and_return(true) + end + + it 'worker jobs are enqueued in the mailers queue' do + described_class.perform_async + assert_equal :mailers, described_class.queue + end + + it 'goes into the jobs array for testing environment' do + expect do + described_class.perform_async + end.to change(described_class.jobs, :size).by(1) + described_class.new.perform(1, Time.zone.now) + end + + context 'with actions performed by the worker' do + it 'calls ConversationMailer' do + described_class.new.perform(1, Time.zone.now) + expect(ConversationMailer).to have_received(:new_message) + end + end + end +end