mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 18:47:51 +00:00
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:
@@ -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
|
||||
|
||||
4
Gemfile
4
Gemfile
@@ -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'
|
||||
|
||||
24
Gemfile.lock
24
Gemfile.lock
@@ -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
|
||||
|
||||
@@ -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
|
||||
22
app/controllers/concerns/microsoft_concern.rb
Normal file
22
app/controllers/concerns/microsoft_concern.rb
Normal 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
|
||||
72
app/controllers/microsoft/callbacks_controller.rb
Normal file
72
app/controllers/microsoft/callbacks_controller.rb
Normal 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
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
53
app/services/microsoft/refresh_oauth_token_service.rb
Normal file
53
app/services/microsoft/refresh_oauth_token_service.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
55
lib/microsoft_graph_auth.rb
Normal file
55
lib/microsoft_graph_auth.rb
Normal 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
|
||||
@@ -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
|
||||
67
spec/controllers/microsoft/callbacks_controller_spec.rb
Normal file
67
spec/controllers/microsoft/callbacks_controller_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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') }
|
||||
|
||||
@@ -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
|
||||
|
||||
53
spec/services/microsoft/refresh_oauth_token_service_spec.rb
Normal file
53
spec/services/microsoft/refresh_oauth_token_service_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user