mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +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
	 Tejaswini Chile
					Tejaswini Chile