diff --git a/.env.example b/.env.example index ee1bdcda0..824a96285 100644 --- a/.env.example +++ b/.env.example @@ -35,7 +35,7 @@ REDIS_SENTINELS= REDIS_SENTINEL_MASTER_NAME= # By default Chatwoot will pass REDIS_PASSWORD as the password value for sentinels -# Use the following environment variable to customize passwords for sentinels. +# Use the following environment variable to customize passwords for sentinels. # Use empty string if sentinels are configured with out passwords # REDIS_SENTINEL_PASSWORD= @@ -45,7 +45,7 @@ REDIS_SENTINEL_MASTER_NAME= # REDIS_OPENSSL_VERIFY_MODE=none # Postgres Database config variables -# You can leave POSTGRES_DATABASE blank. The default name of +# You can leave POSTGRES_DATABASE blank. The default name of # the database in the production environment is chatwoot_production # POSTGRES_DATABASE= POSTGRES_HOST=postgres @@ -214,6 +214,10 @@ STRIPE_WEBHOOK_SECRET= # Make sure to follow https://edgeguides.rubyonrails.org/active_storage_overview.html#cross-origin-resource-sharing-cors-configuration on the cloud storage after setting this to true. 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 diff --git a/Gemfile b/Gemfile index 77720f414..bf57e2db0 100644 --- a/Gemfile +++ b/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' diff --git a/Gemfile.lock b/Gemfile.lock index d3f4f9245..3c53da637 100644 --- a/Gemfile.lock +++ b/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 diff --git a/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb new file mode 100644 index 000000000..49a4bdcb0 --- /dev/null +++ b/app/controllers/api/v1/accounts/microsoft/authorizations_controller.rb @@ -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 diff --git a/app/controllers/concerns/microsoft_concern.rb b/app/controllers/concerns/microsoft_concern.rb new file mode 100644 index 000000000..3aa3e4e81 --- /dev/null +++ b/app/controllers/concerns/microsoft_concern.rb @@ -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 diff --git a/app/controllers/microsoft/callbacks_controller.rb b/app/controllers/microsoft/callbacks_controller.rb new file mode 100644 index 000000000..5b765f001 --- /dev/null +++ b/app/controllers/microsoft/callbacks_controller.rb @@ -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 diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index 58f4e0c14..e7f8e42a5 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -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", diff --git a/app/jobs/inboxes/fetch_imap_emails_job.rb b/app/jobs/inboxes/fetch_imap_emails_job.rb index c4537ca22..b84e79ee6 100644 --- a/app/jobs/inboxes/fetch_imap_emails_job.rb +++ b/app/jobs/inboxes/fetch_imap_emails_job.rb @@ -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 diff --git a/app/mailers/conversation_reply_mailer_helper.rb b/app/mailers/conversation_reply_mailer_helper.rb index 21bc9b939..866fc2c85 100644 --- a/app/mailers/conversation_reply_mailer_helper.rb +++ b/app/mailers/conversation_reply_mailer_helper.rb @@ -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 diff --git a/app/models/channel/email.rb b/app/models/channel/email.rb index c5a67c676..0d519ec2d 100644 --- a/app/models/channel/email.rb +++ b/app/models/channel/email.rb @@ -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 diff --git a/app/services/microsoft/refresh_oauth_token_service.rb b/app/services/microsoft/refresh_oauth_token_service.rb new file mode 100644 index 000000000..d9ad6aa50 --- /dev/null +++ b/app/services/microsoft/refresh_oauth_token_service.rb @@ -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 + + # + 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 + # + + 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 diff --git a/app/views/api/v1/models/_inbox.json.jbuilder b/app/views/api/v1/models/_inbox.json.jbuilder index 0df3ca5a7..347b9358e 100644 --- a/app/views/api/v1/models/_inbox.json.jbuilder +++ b/app/views/api/v1/models/_inbox.json.jbuilder @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 787f13f75..912324842 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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' diff --git a/db/migrate/20221230113108_add_ms_oauth_token_to_channel.rb b/db/migrate/20221230113108_add_ms_oauth_token_to_channel.rb new file mode 100644 index 000000000..d1a375599 --- /dev/null +++ b/db/migrate/20221230113108_add_ms_oauth_token_to_channel.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 2153451e1..2b279b55a 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/lib/microsoft_graph_auth.rb b/lib/microsoft_graph_auth.rb new file mode 100644 index 000000000..a1c0bbea0 --- /dev/null +++ b/lib/microsoft_graph_auth.rb @@ -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 diff --git a/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb new file mode 100644 index 000000000..b65ea160d --- /dev/null +++ b/spec/controllers/api/v1/accounts/microsoft/authorization_controller_spec.rb @@ -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 diff --git a/spec/controllers/microsoft/callbacks_controller_spec.rb b/spec/controllers/microsoft/callbacks_controller_spec.rb new file mode 100644 index 000000000..7a647da6c --- /dev/null +++ b/spec/controllers/microsoft/callbacks_controller_spec.rb @@ -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 diff --git a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb index dfdf1783a..329574535 100644 --- a/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb +++ b/spec/jobs/inboxes/fetch_imap_emails_job_spec.rb @@ -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 diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index 67645e101..bc89a0aa7 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -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') } diff --git a/spec/models/channel/email_spec.rb b/spec/models/channel/email_spec.rb index af3a978f4..732be2e20 100644 --- a/spec/models/channel/email_spec.rb +++ b/spec/models/channel/email_spec.rb @@ -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 diff --git a/spec/services/microsoft/refresh_oauth_token_service_spec.rb b/spec/services/microsoft/refresh_oauth_token_service_spec.rb new file mode 100644 index 000000000..5218d388b --- /dev/null +++ b/spec/services/microsoft/refresh_oauth_token_service_spec.rb @@ -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