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. # 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= DIRECT_UPLOADS_ENABLED=
#MS OAUTH creds
AZURE_APP_ID=
AZURE_APP_SECRET=
## Advanced configurations ## Advanced configurations
## Change these values to fine tune performance ## Change these values to fine tune performance
# control the concurrency setting of sidekiq # control the concurrency setting of sidekiq

View File

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

View File

@@ -249,6 +249,8 @@ GEM
gli (2.21.0) gli (2.21.0)
globalid (1.0.0) globalid (1.0.0)
activesupport (>= 5.0) activesupport (>= 5.0)
gmail_xoauth (0.4.2)
oauth (>= 0.3.6)
google-apis-core (0.7.0) google-apis-core (0.7.0)
addressable (~> 2.5, >= 2.5.1) addressable (~> 2.5, >= 2.5.1)
googleauth (>= 0.16.2, < 2.a) googleauth (>= 0.16.2, < 2.a)
@@ -437,6 +439,20 @@ GEM
nokogiri (1.13.10-x86_64-linux) nokogiri (1.13.10-x86_64-linux)
racc (~> 1.4) racc (~> 1.4)
oauth (0.5.10) 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) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
parallel (1.22.1) parallel (1.22.1)
@@ -465,6 +481,8 @@ GEM
rack (>= 1.0, < 3) rack (>= 1.0, < 3)
rack-cors (1.1.1) rack-cors (1.1.1)
rack (>= 2.0.0) rack (>= 2.0.0)
rack-protection (3.0.5)
rack
rack-proxy (0.7.2) rack-proxy (0.7.2)
rack rack
rack-test (2.0.2) rack-test (2.0.2)
@@ -620,6 +638,9 @@ GEM
gli gli
hashie hashie
websocket-driver websocket-driver
snaky_hash (2.0.1)
hashie
version_gem (~> 1.1, >= 1.1.1)
spring (2.1.1) spring (2.1.1)
spring-watcher-listen (2.0.1) spring-watcher-listen (2.0.1)
listen (>= 2.7, < 4.0) listen (>= 2.7, < 4.0)
@@ -663,6 +684,7 @@ GEM
valid_email2 (4.0.3) valid_email2 (4.0.3)
activemodel (>= 3.2) activemodel (>= 3.2)
mail (~> 2.5) mail (~> 2.5)
version_gem (1.1.1)
warden (1.2.9) warden (1.2.9)
rack (>= 2.0.9) rack (>= 2.0.9)
web-console (4.2.0) web-console (4.2.0)
@@ -735,6 +757,7 @@ DEPENDENCIES
flag_shih_tzu flag_shih_tzu
foreman foreman
geocoder geocoder
gmail_xoauth
google-cloud-dialogflow google-cloud-dialogflow
google-cloud-storage google-cloud-storage
groupdate groupdate
@@ -756,6 +779,7 @@ DEPENDENCIES
maxminddb maxminddb
mock_redis mock_redis
newrelic_rpm newrelic_rpm
omniauth-oauth2
pg pg
pg_search pg_search
procore-sift 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" "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": { "WEBSITE_CHANNEL": {
"TITLE": "Website channel", "TITLE": "Website channel",
"DESC": "Create a channel for your website and start supporting your customers via our website widget.", "DESC": "Create a channel for your website and start supporting your customers via our website widget.",
@@ -548,6 +552,10 @@
}, },
"ENABLE_SSL": "Enable SSL" "ENABLE_SSL": "Enable SSL"
}, },
"MICROSOFT": {
"TITLE": "Microsoft",
"SUBTITLE": "Reauthorize your MICROSOFT account"
},
"SMTP": { "SMTP": {
"TITLE": "SMTP", "TITLE": "SMTP",
"SUBTITLE": "Set your SMTP details", "SUBTITLE": "Set your SMTP details",

View File

@@ -6,9 +6,7 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
def perform(channel) def perform(channel)
return unless should_fetch_email?(channel) return unless should_fetch_email?(channel)
fetch_mail_for_channel(channel) process_email_for_channel(channel)
# clearing old failures like timeouts since the mail is now successfully processed
channel.reauthorized!
rescue *ExceptionList::IMAP_EXCEPTIONS rescue *ExceptionList::IMAP_EXCEPTIONS
channel.authorization_error! channel.authorization_error!
rescue EOFError => e rescue EOFError => e
@@ -23,6 +21,17 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
channel.imap_enabled? && !channel.reauthorization_required? channel.imap_enabled? && !channel.reauthorization_required?
end 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) 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 # 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) # using Mail.retriever_method.new(params)
@@ -41,9 +50,51 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
end end
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) def process_mail(inbound_mail, channel)
Imap::ImapMailbox.new.process(inbound_mail, channel) Imap::ImapMailbox.new.process(inbound_mail, channel)
rescue StandardError => e rescue StandardError => e
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
end 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 end

View File

@@ -13,13 +13,33 @@ module ConversationReplyMailerHelper
@options[:cc] = cc_bcc_emails[0] @options[:cc] = cc_bcc_emails[0]
@options[:bcc] = cc_bcc_emails[1] @options[:bcc] = cc_bcc_emails[1]
end end
ms_smtp_settings
set_delivery_method set_delivery_method
mail(@options) mail(@options)
end end
private 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 def set_delivery_method
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled
@@ -47,8 +67,12 @@ module ConversationReplyMailerHelper
@inbox.inbox_type == 'Email' && @channel.imap_enabled @inbox.inbox_type == 'Email' && @channel.imap_enabled
end end
def email_microsoft_auth_enabled
@inbox.inbox_type == 'Email' && @channel.provider == 'microsoft'
end
def email_from 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 end
def email_reply_to def email_reply_to

View File

@@ -12,6 +12,8 @@
# imap_login :string default("") # imap_login :string default("")
# imap_password :string default("") # imap_password :string default("")
# imap_port :integer default(0) # imap_port :integer default(0)
# provider :string
# provider_config :jsonb
# smtp_address :string default("") # smtp_address :string default("")
# smtp_authentication :string default("login") # smtp_authentication :string default("login")
# smtp_domain :string default("") # smtp_domain :string default("")
@@ -41,7 +43,7 @@ class Channel::Email < ApplicationRecord
self.table_name = 'channel_email' 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, 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_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 :email, uniqueness: true
validates :forward_to_email, uniqueness: true validates :forward_to_email, uniqueness: true
@@ -52,6 +54,10 @@ class Channel::Email < ApplicationRecord
'Email' 'Email'
end end
def microsoft?
provider == 'microsoft'
end
private private
def ensure_forward_to_email 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_address resource.channel.try(:imap_address)
json.imap_port resource.channel.try(:imap_port) json.imap_port resource.channel.try(:imap_port)
json.imap_enabled resource.channel.try(:imap_enabled) 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) json.imap_enable_ssl resource.channel.try(:imap_enable_ssl)
end end

View File

@@ -17,7 +17,9 @@ Rails.application.routes.draw do
get '/app', to: 'dashboard#index' get '/app', to: 'dashboard#index'
get '/app/*params', 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/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_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] resource :widget, only: [:show]
namespace :survey do namespace :survey do
@@ -39,7 +41,6 @@ Rails.application.routes.draw do
namespace :actions do namespace :actions do
resource :contact_merge, only: [:create] resource :contact_merge, only: [:create]
end end
resource :bulk_actions, only: [:create] resource :bulk_actions, only: [:create]
resources :agents, only: [:index, :create, :update, :destroy] resources :agents, only: [:index, :create, :update, :destroy]
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] resources :agent_bots, only: [:index, :create, :show, :update, :destroy]
@@ -153,6 +154,10 @@ Rails.application.routes.draw do
resource :authorization, only: [:create] resource :authorization, only: [:create]
end end
namespace :microsoft do
resource :authorization, only: [:create]
end
resources :webhooks, only: [:index, :create, :update, :destroy] resources :webhooks, only: [:index, :create, :update, :destroy]
namespace :integrations do namespace :integrations do
resources :apps, only: [:index, :show] resources :apps, only: [:index, :show]
@@ -339,6 +344,8 @@ Rails.application.routes.draw do
resources :callback, only: [:create] resources :callback, only: [:create]
end end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# Routes for external service verifications # Routes for external service verifications
get 'apple-app-site-association' => 'apple_app#site_association' 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pg_stat_statements" 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_authentication", default: "login"
t.string "smtp_openssl_verify_mode", default: "none" t.string "smtp_openssl_verify_mode", default: "none"
t.boolean "smtp_enable_ssl_tls", default: false 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 ["email"], name: "index_channel_email_on_email", unique: true
t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true t.index ["forward_to_email"], name: "index_channel_email_on_forward_to_email", unique: true
end 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' require 'rails_helper'
RSpec.describe Inboxes::FetchImapEmailsJob, type: :job do RSpec.describe Inboxes::FetchImapEmailsJob, type: :job do
include ActionMailbox::TestHelper
let(:account) { create(:account) } let(:account) { create(:account) }
let(:imap_email_channel) do let(:imap_email_channel) do
create(:channel_email, imap_enabled: true, imap_address: 'imap.gmail.com', imap_port: 993, imap_login: 'imap@gmail.com', 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) imap_password: 'password', imap_inbox_synced_at: Time.now.utc, account: account)
end 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!(: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 it 'enqueues the job' do
expect { described_class.perform_later }.to have_enqueued_job(described_class) expect { described_class.perform_later }.to have_enqueued_job(described_class)
@@ -31,6 +40,35 @@ RSpec.describe Inboxes::FetchImapEmailsJob, type: :job do
end end
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 context 'when imap fetch existing emails' do
it 'does not process the email' do it 'does not process the email' do
email = Mail.new do email = Mail.new do

View File

@@ -170,6 +170,22 @@ RSpec.describe ConversationReplyMailer, type: :mailer do
end end
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 context 'when smtp disabled for email channel', :test do
let(:conversation) { create(:conversation, assignee: agent, inbox: email_channel.inbox, account: account).reload } 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') } 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 it 'has a valid name' do
expect(channel.name).to eq('Email') expect(channel.name).to eq('Email')
end 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 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