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:
@@ -35,7 +35,7 @@ REDIS_SENTINELS=
|
|||||||
REDIS_SENTINEL_MASTER_NAME=
|
REDIS_SENTINEL_MASTER_NAME=
|
||||||
|
|
||||||
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
|
# By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels
|
||||||
# Use the following environment variable to customize passwords for sentinels.
|
# Use the following environment variable to customize passwords for sentinels.
|
||||||
# Use empty string if sentinels are configured with out passwords
|
# Use empty string if sentinels are configured with out passwords
|
||||||
# REDIS_SENTINEL_PASSWORD=
|
# REDIS_SENTINEL_PASSWORD=
|
||||||
|
|
||||||
@@ -45,7 +45,7 @@ REDIS_SENTINEL_MASTER_NAME=
|
|||||||
# REDIS_OPENSSL_VERIFY_MODE=none
|
# REDIS_OPENSSL_VERIFY_MODE=none
|
||||||
|
|
||||||
# Postgres Database config variables
|
# Postgres Database config variables
|
||||||
# You can leave POSTGRES_DATABASE blank. The default name of
|
# You can leave POSTGRES_DATABASE blank. The default name of
|
||||||
# the database in the production environment is chatwoot_production
|
# the database in the production environment is chatwoot_production
|
||||||
# POSTGRES_DATABASE=
|
# POSTGRES_DATABASE=
|
||||||
POSTGRES_HOST=postgres
|
POSTGRES_HOST=postgres
|
||||||
@@ -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
|
||||||
|
|||||||
4
Gemfile
4
Gemfile
@@ -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'
|
||||||
|
|||||||
24
Gemfile.lock
24
Gemfile.lock
@@ -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
|
||||||
|
|||||||
@@ -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"
|
"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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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_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
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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.
|
# 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
|
||||||
|
|||||||
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'
|
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
|
||||||
|
|||||||
@@ -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') }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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