Feat: Support for Microsoft Oauth in Email Channel (#6227)

- Adds the backend APIs required for Microsoft Email Channels

Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
Co-authored-by: Sojan <sojan@pepalo.com>
This commit is contained in:
Tejaswini Chile
2023-01-17 02:39:05 +05:30
committed by GitHub
parent d0972a22b4
commit 00cbdaa8ca
22 changed files with 611 additions and 10 deletions

View File

@@ -214,6 +214,10 @@ STRIPE_WEBHOOK_SECRET=
# Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true.
DIRECT_UPLOADS_ENABLED=
#MS OAUTH creds
AZURE_APP_ID=
AZURE_APP_SECRET=
## Advanced configurations
## Change these values to fine tune performance
# control the concurrency setting of sidekiq

View File

@@ -37,6 +37,8 @@ gem 'json_schemer'
gem 'rack-attack'
# a utility tool for streaming, flexible and safe downloading of remote files
gem 'down', '~> 5.0'
# authentication type to fetch and send mail over oauth2.0
gem 'gmail_xoauth'
##-- for active storage --##
gem 'aws-sdk-s3', require: false
@@ -186,3 +188,5 @@ group :development, :test do
gem 'spring'
gem 'spring-watcher-listen'
end
# worked with microsoft refresh token
gem 'omniauth-oauth2'

View File

@@ -249,6 +249,8 @@ GEM
gli (2.21.0)
globalid (1.0.0)
activesupport (>= 5.0)
gmail_xoauth (0.4.2)
oauth (>= 0.3.6)
google-apis-core (0.7.0)
addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a)
@@ -437,6 +439,20 @@ GEM
nokogiri (1.13.10-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
oauth2 (2.0.9)
faraday (>= 0.17.3, < 3.0)
jwt (>= 1.0, < 3.0)
multi_xml (~> 0.5)
rack (>= 1.2, < 4)
snaky_hash (~> 2.0)
version_gem (~> 1.1)
omniauth (2.1.0)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-oauth2 (1.8.0)
oauth2 (>= 1.4, < 3)
omniauth (~> 2.0)
orm_adapter (0.5.0)
os (1.1.4)
parallel (1.22.1)
@@ -465,6 +481,8 @@ GEM
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-protection (3.0.5)
rack
rack-proxy (0.7.2)
rack
rack-test (2.0.2)
@@ -620,6 +638,9 @@ GEM
gli
hashie
websocket-driver
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
spring (2.1.1)
spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0)
@@ -663,6 +684,7 @@ GEM
valid_email2 (4.0.3)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.1)
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.0)
@@ -735,6 +757,7 @@ DEPENDENCIES
flag_shih_tzu
foreman
geocoder
gmail_xoauth
google-cloud-dialogflow
google-cloud-storage
groupdate
@@ -756,6 +779,7 @@ DEPENDENCIES
maxminddb
mock_redis
newrelic_rpm
omniauth-oauth2
pg
pg_search
procore-sift

View File

@@ -0,0 +1,27 @@
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
include MicrosoftConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = microsoft_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid',
prompt: 'consent'
}
)
if redirect_url
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end

View File

@@ -0,0 +1,22 @@
module MicrosoftConcern
extend ActiveSupport::Concern
def microsoft_client
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil),
{
site: 'https://login.microsoftonline.com',
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
})
end
private
def parsed_body
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end

View File

@@ -0,0 +1,72 @@
class Microsoft::CallbacksController < ApplicationController
include MicrosoftConcern
def show
@response = microsoft_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/microsoft/callback"
)
inbox = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'])
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end
private
def oauth_code
params[:code]
end
def users_data
decoded_token = JWT.decode parsed_body[:id_token], nil, false
decoded_token[0]
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def account_id
::Redis::Alfred.get(users_data['email'])
end
def account
@account ||= Account.find(account_id)
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
channel_email ||= create_microsoft_channel_with_inbox
update_microsoft_channel(channel_email)
channel_email.inbox
end
def create_microsoft_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
name: users_data['name']
)
channel_email
end
end
def update_microsoft_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: 'outlook.office365.com',
imap_port: '993', imap_enabled: true,
provider: 'microsoft',
provider_config: {
access_token: parsed_body['access_token'],
refresh_token: parsed_body['refresh_token'],
expires_on: (Time.current.utc + 1.hour).to_s
}
})
end
end

View File

@@ -53,6 +53,10 @@
"ENABLE": "Create conversations from mentioned Tweets"
}
},
"MICROSOFT": {
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"WEBSITE_CHANNEL": {
"TITLE": "Website channel",
"DESC": "Create a channel for your website and start supporting your customers via our website widget.",
@@ -548,6 +552,10 @@
},
"ENABLE_SSL": "Enable SSL"
},
"MICROSOFT": {
"TITLE": "Microsoft",
"SUBTITLE": "Reauthorize your MICROSOFT account"
},
"SMTP": {
"TITLE": "SMTP",
"SUBTITLE": "Set your SMTP details",

View File

@@ -6,9 +6,7 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
def perform(channel)
return unless should_fetch_email?(channel)
fetch_mail_for_channel(channel)
# clearing old failures like timeouts since the mail is now successfully processed
channel.reauthorized!
process_email_for_channel(channel)
rescue *ExceptionList::IMAP_EXCEPTIONS
channel.authorization_error!
rescue EOFError => e
@@ -23,6 +21,17 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
channel.imap_enabled? && !channel.reauthorization_required?
end
def process_email_for_channel(channel)
# fetching email for microsoft provider
if channel.microsoft?
fetch_mail_for_ms_provider(channel)
else
fetch_mail_for_channel(channel)
end
# clearing old failures like timeouts since the mail is now successfully processed
channel.reauthorized!
end
def fetch_mail_for_channel(channel)
# TODO: rather than setting this as default method for all mail objects, lets if can do new mail object
# using Mail.retriever_method.new(params)
@@ -41,9 +50,51 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end
end
def fetch_mail_for_ms_provider(channel)
return if channel.provider_config['access_token'].blank?
access_token = valid_access_token channel
return unless access_token
imap = imap_authenticate(channel, access_token)
process_mails(imap, channel)
end
def process_mails(imap, channel)
imap.search(['BEFORE', tomorrow, 'SINCE', yesterday]).each do |message_id|
inbound_mail = Mail.read_from_string imap.fetch(message_id, 'RFC822')[0].attr['RFC822']
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
process_mail(inbound_mail, channel)
end
end
def imap_authenticate(channel, access_token)
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
imap.authenticate('XOAUTH2', channel.imap_login, access_token)
imap.select('INBOX')
imap
end
def yesterday
(Time.zone.today - 1).strftime('%d-%b-%Y')
end
def tomorrow
(Time.zone.today + 1).strftime('%d-%b-%Y')
end
def process_mail(inbound_mail, channel)
Imap::ImapMailbox.new.process(inbound_mail, channel)
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end
# Making sure the access token is valid for microsoft provider
def valid_access_token(channel)
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end
end

View File

@@ -13,13 +13,33 @@ module ConversationReplyMailerHelper
@options[:cc] = cc_bcc_emails[0]
@options[:bcc] = cc_bcc_emails[1]
end
ms_smtp_settings
set_delivery_method
mail(@options)
end
private
def ms_smtp_settings
return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.provider == 'microsoft'
smtp_settings = {
address: 'smtp.office365.com',
port: 587,
user_name: @channel.imap_login,
password: @channel.provider_config['access_token'],
domain: 'smtp.office365.com',
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
authentication: 'xoauth2'
}
@options[:delivery_method] = :smtp
@options[:delivery_method_options] = smtp_settings
end
def set_delivery_method
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled
@@ -47,8 +67,12 @@ module ConversationReplyMailerHelper
@inbox.inbox_type == 'Email' && @channel.imap_enabled
end
def email_microsoft_auth_enabled
@inbox.inbox_type == 'Email' && @channel.provider == 'microsoft'
end
def email_from
email_smtp_enabled ? @channel.email : from_email_with_name
email_microsoft_auth_enabled || email_smtp_enabled ? @channel.email : from_email_with_name
end
def email_reply_to

View File

@@ -12,6 +12,8 @@
# imap_login :string default("")
# imap_password :string default("")
# imap_port :integer default(0)
# provider :string
# provider_config :jsonb
# smtp_address :string default("")
# smtp_authentication :string default("login")
# smtp_domain :string default("")
@@ -41,7 +43,7 @@ class Channel::Email < ApplicationRecord
self.table_name = 'channel_email'
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl, :imap_inbox_synced_at,
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication].freeze
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication, :provider].freeze
validates :email, uniqueness: true
validates :forward_to_email, uniqueness: true
@@ -52,6 +54,10 @@ class Channel::Email < ApplicationRecord
'Email'
end
def microsoft?
provider == 'microsoft'
end
private
def ensure_forward_to_email

View File

@@ -0,0 +1,53 @@
# refer: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
# refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
# https://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-configurable-token-lifetimes
class Microsoft::RefreshOauthTokenService
pattr_initialize [:channel!]
# if the token is not expired yet then skip the refresh token step
def access_token
provider_config = channel.provider_config.with_indifferent_access
if Time.current.utc >= expires_on(provider_config['expires_on'])
# Token expired, refresh
new_hash = refresh_tokens
new_hash[:access_token]
else
provider_config[:access_token]
end
end
def expires_on(expiry)
# we will give it a 5 minute gap for safety
expiry.presence ? DateTime.parse(expiry) - 5.minutes : Time.current.utc
end
# <RefreshTokensSnippet>
def refresh_tokens
token_hash = channel.provider_config.with_indifferent_access
oauth_strategy = ::MicrosoftGraphAuth.new(
nil, ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil)
)
token_service = OAuth2::AccessToken.new(
oauth_strategy.client, token_hash['access_token'],
refresh_token: token_hash['refresh_token']
)
# Refresh the tokens
new_tokens = token_service.refresh!.to_hash.slice(:access_token, :refresh_token, :expires_at)
update_channel_provider_config(new_tokens)
channel.provider_config
end
# </RefreshTokensSnippet>
def update_channel_provider_config(new_tokens)
new_tokens = new_tokens.with_indifferent_access
channel.provider_config = {
access_token: new_tokens[:access_token],
refresh_token: new_tokens[:refresh_token],
expires_on: Time.at(new_tokens[:expires_at]).utc.to_s
}
channel.save!
end
end

View File

@@ -62,6 +62,7 @@ if resource.email?
json.imap_address resource.channel.try(:imap_address)
json.imap_port resource.channel.try(:imap_port)
json.imap_enabled resource.channel.try(:imap_enabled)
json.microsoft_reauthorization resource.channel.try(:microsoft?) && resource.channel.try(:provider_config).empty?
json.imap_enable_ssl resource.channel.try(:imap_enable_ssl)
end

View File

@@ -17,7 +17,9 @@ Rails.application.routes.draw do
get '/app', to: 'dashboard#index'
get '/app/*params', to: 'dashboard#index'
get '/app/accounts/:account_id/settings/inboxes/new/twitter', to: 'dashboard#index', as: 'app_new_twitter_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/microsoft', to: 'dashboard#index', as: 'app_new_microsoft_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_microsoft_inbox_agents'
resource :widget, only: [:show]
namespace :survey do
@@ -39,7 +41,6 @@ Rails.application.routes.draw do
namespace :actions do
resource :contact_merge, only: [:create]
end
resource :bulk_actions, only: [:create]
resources :agents, only: [:index, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy]
@@ -153,6 +154,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
namespace :microsoft do
resource :authorization, only: [:create]
end
resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do
resources :apps, only: [:index, :show]
@@ -339,6 +344,8 @@ Rails.application.routes.draw do
resources :callback, only: [:create]
end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
# ----------------------------------------------------------------------
# Routes for external service verifications
get 'apple-app-site-association' => 'apple_app#site_association'

View File

@@ -0,0 +1,8 @@
class AddMsOauthTokenToChannel < ActiveRecord::Migration[6.1]
def change
change_table :channel_email, bulk: true do |t|
t.jsonb :provider_config, default: {}
t.string :provider
end
end
end

View File

@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2022_12_19_162759) do
ActiveRecord::Schema.define(version: 2022_12_30_113108) do
# These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements"
@@ -252,6 +252,8 @@ ActiveRecord::Schema.define(version: 2022_12_19_162759) do
t.string "smtp_authentication", default: "login"
t.string "smtp_openssl_verify_mode", default: "none"
t.boolean "smtp_enable_ssl_tls", default: false
t.jsonb "provider_config", default: {}
t.string "provider"
t.index ["email"], name: "index_channel_email_on_email", unique: true
t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true
end

View File

@@ -0,0 +1,55 @@
# Copyright (c) Microsoft Corporation.
# Licensed under the MIT License.
# frozen_string_literal: true
# Refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp
require 'omniauth-oauth2'
# Implements an OmniAuth strategy to get a Microsoft Graph
# compatible token from Azure AD
class MicrosoftGraphAuth < OmniAuth::Strategies::OAuth2
option :name, :microsoft_graph_auth
DEFAULT_SCOPE = 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send'
# Configure the Microsoft identity platform endpoints
option :client_options,
site: 'https://login.microsoftonline.com',
authorize_url: '/common/oauth2/v2.0/authorize',
token_url: '/common/oauth2/v2.0/token'
option :pcke, true
# Send the scope parameter during authorize
option :authorize_options, [:scope]
# Unique ID for the user is the id field
uid { raw_info['id'] }
# Get additional information after token is retrieved
extra do
{
'raw_info' => raw_info
}
end
def raw_info
# Get user profile information from the /me endpoint
@raw_info ||= access_token.get('https://graph.microsoft.com/v1.0/me?$select=displayName').parsed
end
def authorize_params
super.tap do |params|
params[:scope] = request.params['scope'] if request.params['scope']
params[:scope] ||= DEFAULT_SCOPE
end
end
# Override callback URL
# OmniAuth by default passes the entire URL of the callback, including
# query parameters. Azure fails validation because that doesn't match the
# registered callback.
def callback_url
ENV.fetch('FRONTEND_URL', nil) + app_path
end
end

View File

@@ -0,0 +1,48 @@
require 'rails_helper'
RSpec.describe 'Microsoft Authorization API', type: :request do
let(:account) { create(:account) }
describe 'POST /api/v1/accounts/{account.id}/microsoft/authorization' do
context 'when it is an unauthenticated user' do
it 'returns unauthorized' do
post "/api/v1/accounts/#{account.id}/microsoft/authorization"
expect(response).to have_http_status(:unauthorized)
end
end
context 'when it is an authenticated user' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:administrator) { create(:user, account: account, role: :administrator) }
it 'returns unathorized for agent' do
post "/api/v1/accounts/#{account.id}/microsoft/authorization",
headers: agent.create_new_auth_token,
params: { email: administrator.email },
as: :json
expect(response).to have_http_status(:unauthorized)
end
it 'creates a new authorization and returns the redirect url' do
post "/api/v1/accounts/#{account.id}/microsoft/authorization",
headers: administrator.create_new_auth_token,
params: { email: administrator.email },
as: :json
expect(response).to have_http_status(:success)
microsoft_service = Class.new { extend MicrosoftConcern }
response_url = microsoft_service.microsoft_client.auth_code.authorize_url(
{
redirect_uri: "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid',
prompt: 'consent'
}
)
expect(JSON.parse(response.body)['url']).to eq response_url
expect(::Redis::Alfred.get(administrator.email)).to eq(account.id.to_s)
end
end
end
end

View File

@@ -0,0 +1,67 @@
require 'rails_helper'
RSpec.describe 'Microsoft::CallbacksController', type: :request do
let(:account) { create(:account) }
let(:code) { SecureRandom.hex(10) }
let(:email) { Faker::Internet.email }
before do
Redis::Alfred.set(email, account.id)
end
describe 'GET /microsoft/callback' do
let(:response_body_success) do
{ id_token: JWT.encode({ email: email, name: 'test' }, false), access_token: SecureRandom.hex(10), token_type: 'Bearer',
refresh_token: SecureRandom.hex(10) }
end
it 'creates inboxes if authentication is successful' do
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code }
expect(response).to redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1
inbox = account.inboxes.last
expect(inbox.name).to eq 'test'
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
expect(Redis::Alfred.get(email)).to be_nil
end
it 'creates updates inbox channel config if inbox exists and authentication is successful' do
inbox = create(:channel_email, account: account, email: email)&.inbox
expect(inbox.channel.provider_config).to eq({})
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 200, body: response_body_success.to_json, headers: { 'Content-Type' => 'application/json' })
get microsoft_callback_url, params: { code: code }
expect(response).to redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: account.inboxes.last.id)
expect(account.inboxes.count).to be 1
expect(inbox.channel.reload.provider_config.keys).to include('access_token', 'refresh_token', 'expires_on')
expect(inbox.channel.reload.provider_config['access_token']).to eq response_body_success[:access_token]
expect(inbox.channel.imap_address).to eq 'outlook.office365.com'
expect(Redis::Alfred.get(email)).to be_nil
end
it 'redirects to microsoft app in case of error' do
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token')
.with(body: { 'code' => code, 'grant_type' => 'authorization_code',
'redirect_uri' => "#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback" })
.to_return(status: 401)
get microsoft_callback_url, params: { code: code }
expect(response).to redirect_to '/'
expect(Redis::Alfred.get(email).to_i).to eq account.id
end
end
end

View File

@@ -1,12 +1,21 @@
require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailsJob, type: :job do
include ActionMailbox::TestHelper
let(:account) { create(:account) }
let(:imap_email_channel) do
create(:channel_email, imap_enabled: true, imap_address: 'imap.gmail.com', imap_port: 993, imap_login: 'imap@gmail.com',
imap_password: 'password', imap_inbox_synced_at: Time.now.utc, account: account)
end
let(:microsoft_imap_email_channel) do
create(:channel_email, provider: 'microsoft', imap_enabled: true, imap_address: 'outlook.office365.com',
imap_port: 993, imap_login: 'imap@outlook.com', imap_password: 'password', account: account,
provider_config: { access_token: 'access_token' })
end
let(:ms_email_inbox) { create(:inbox, channel: microsoft_imap_email_channel, account: account) }
let!(:conversation) { create(:conversation, inbox: imap_email_channel.inbox, account: account) }
let(:inbound_mail) { create_inbound_email_from_mail(from: 'testemail@gmail.com', to: 'imap@outlook.com', subject: 'Hello!') }
it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class)
@@ -31,6 +40,35 @@ RSpec.describe Inboxes::FetchImapEmailsJob, type: :job do
end
end
context 'when imap fetch new emails for microsoft mailer' do
it 'fetch and process all emails' do
email = Mail.new do
to 'test@outlook.com'
from 'test@gmail.com'
subject :test.to_s
body 'hello'
end
imap_fetch_mail = Net::IMAP::FetchData.new
imap_fetch_mail.attr = { RFC822: email }.with_indifferent_access
ms_imap = double
allow(Net::IMAP).to receive(:new).and_return(ms_imap)
allow(ms_imap).to receive(:authenticate)
allow(ms_imap).to receive(:select)
allow(ms_imap).to receive(:search).and_return([1])
allow(ms_imap).to receive(:fetch).and_return([imap_fetch_mail])
allow(Mail).to receive(:read_from_string).and_return(inbound_mail)
ms_imap_email_inbox = double
allow(Imap::ImapMailbox).to receive(:new).and_return(ms_imap_email_inbox)
expect(ms_imap_email_inbox).to receive(:process).with(inbound_mail, microsoft_imap_email_channel).once
described_class.perform_now(microsoft_imap_email_channel)
end
end
context 'when imap fetch existing emails' do
it 'does not process the email' do
email = Mail.new do

View File

@@ -170,6 +170,22 @@ RSpec.describe ConversationReplyMailer, type: :mailer do
end
end
context 'when smtp enabled for microsoft email channel' do
let(:ms_smtp_email_channel) do
create(:channel_email, imap_login: 'smtp@outlook.com',
imap_enabled: true, account: account, provider: 'microsoft', provider_config: { access_token: 'access_token' })
end
let(:conversation) { create(:conversation, assignee: agent, inbox: ms_smtp_email_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }
it 'use smtp mail server' do
mail = described_class.email_reply(message)
expect(mail.delivery_method.settings.empty?).to be false
expect(mail.delivery_method.settings[:address]).to eq 'smtp.office365.com'
expect(mail.delivery_method.settings[:port]).to eq 587
end
end
context 'when smtp disabled for email channel', :test do
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload }
let(:message) { create(:message, conversation: conversation, account: account, message_type: 'outgoing', content: 'Outgoing Message 2') }

View File

@@ -24,4 +24,15 @@ RSpec.describe Channel::Email do
it 'has a valid name' do
expect(channel.name).to eq('Email')
end
context 'when microsoft?' do
it 'returns false' do
expect(channel.microsoft?).to be(false)
end
it 'returns true' do
channel.provider = 'microsoft'
expect(channel.microsoft?).to be(true)
end
end
end

View File

@@ -0,0 +1,53 @@
require 'rails_helper'
RSpec.describe Microsoft::RefreshOauthTokenService do
let(:access_token) { SecureRandom.hex }
let(:refresh_token) { SecureRandom.hex }
let(:expires_on) { Time.zone.now + 3600 }
let!(:microsoft_email_channel) do
create(:channel_email, provider_config: { access_token: access_token, refresh_token: refresh_token, expires_on: expires_on })
end
let(:new_tokens) { { access_token: access_token, refresh_token: refresh_token, expires_at: expires_on.to_i, token_type: 'bearer' } }
describe '#access_token' do
context 'when token is not expired' do
it 'returns the existing access token' do
expect(described_class.new(channel: microsoft_email_channel).access_token).to eq(access_token)
expect(microsoft_email_channel.reload.provider_config['refresh_token']).to eq(refresh_token)
end
end
context 'when token is expired' do
let(:expires_on) { 1.minute.from_now }
before do
stub_request(:post, 'https://login.microsoftonline.com/common/oauth2/v2.0/token').with(
body: { 'grant_type' => 'refresh_token', 'refresh_token' => refresh_token }
).to_return(status: 200, body: new_tokens.to_json, headers: { 'Content-Type' => 'application/json' })
end
it 'fetches new access token and refresh tokens' do
microsoft_email_channel.provider_config['expires_on'] = Time.zone.now - 3600
microsoft_email_channel.save!
expect(described_class.new(channel: microsoft_email_channel).access_token).not_to eq(access_token)
expect(microsoft_email_channel.reload.provider_config['access_token']).to eq(new_tokens[:access_token])
expect(microsoft_email_channel.reload.provider_config['refresh_token']).to eq(new_tokens[:refresh_token])
expect(microsoft_email_channel.reload.provider_config['expires_on']).to eq(Time.at(new_tokens[:expires_at]).utc.to_s)
end
end
context 'when refresh token is not present in provider config and access token is expired' do
it 'throws an error' do
microsoft_email_channel.update(provider_config: {
access_token: access_token,
expires_on: expires_on - 3600
})
expect do
described_class.new(channel: microsoft_email_channel).access_token
end.to raise_error(RuntimeError, 'A refresh_token is not available')
end
end
end
end