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. | # 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
	 Tejaswini Chile
					Tejaswini Chile