[#139] Send conversation emails (#442)

* [#139] Delayed emails for conversations

* Added the setex and get methods to Redis wrapper
* Set the priorities for the sidekiq queues
* Was not able to use mailhog for testing email in local, switched back to letter opener and added comments on using the SMTP settings
* Added after create hood in messages to queue the sending of mail after 2 minutes using sidekiq worker and also set the redis key for the conversation to avoid the email sending for every message
* Added the sidekiq worker to send the email and delete the conversation redis key
* Added the mailer and mail template
* mailer sends the last 10 messages along with the new messages from the time it was queued

* Send email only in development or if smtp config is set

* Send email only in development or if smtp config is set
* Set the SMTP_PORT in production variable

* Adding redis to circle CI

* Specs for the conversation email changes

* Added specs for conversation email sidekiq worker
* Added specs for conversation mailer
* Added specs in message model for the after create hook for notify email

* Send emails only when there is a reply from agent

* set development to use mailhog

* Adding comments for using letter opener
This commit is contained in:
Sony Mathew 2020-01-23 23:14:07 +05:45 committed by Sojan Jose
parent 1d3ed016be
commit d4b3ba4baa
18 changed files with 184 additions and 13 deletions

View file

@ -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

View file

@ -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=

View file

@ -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/**/*

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,16 @@
<p>Hi <%= @contact.name %>,</p>
<p>You have new messages on your conversation.</p>
<table>
<% @messages.each do |message| %>
<tr>
<td>
<b><%= message.incoming? ? 'You' : message.user.name %></b>
</td>
<td>: <%= message.content %></td>
</tr>
<% end %>
</table>
<p>Click <%= link_to 'here', app_conversation_url(id: @conversation.display_id) %> to get back to the conversation. </p>

View file

@ -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

View file

@ -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?

View file

@ -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,

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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!

View file

@ -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