Skip to content

Commit

Permalink
Feat: Support for Microsoft Oauth in Email Channel (#6227)
Browse files Browse the repository at this point in the history
- 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>
  • Loading branch information
3 people authored Jan 16, 2023
1 parent d0972a2 commit 00cbdaa
Show file tree
Hide file tree
Showing 22 changed files with 611 additions and 10 deletions.
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -186,3 +188,5 @@ group :development, :test do
gem 'spring'
gem 'spring-watcher-listen'
end
# worked with microsoft refresh token
gem 'omniauth-oauth2'
24 changes: 24 additions & 0 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -735,6 +757,7 @@ DEPENDENCIES
flag_shih_tzu
foreman
geocoder
gmail_xoauth
google-cloud-dialogflow
google-cloud-storage
groupdate
Expand All @@ -756,6 +779,7 @@ DEPENDENCIES
maxminddb
mock_redis
newrelic_rpm
omniauth-oauth2
pg
pg_search
procore-sift
Expand Down
Original file line number Diff line number Diff line change
@@ -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 changes: 22 additions & 0 deletions app/controllers/concerns/microsoft_concern.rb
Original file line number Diff line number Diff line change
@@ -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 changes: 72 additions & 0 deletions app/controllers/microsoft/callbacks_controller.rb
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions app/javascript/dashboard/i18n/locale/en/inboxMgmt.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -548,6 +552,10 @@
},
"ENABLE_SSL": "Enable SSL"
},
"MICROSOFT": {
"TITLE": "Microsoft",
"SUBTITLE": "Reauthorize your MICROSOFT account"
},
"SMTP": {
"TITLE": "SMTP",
"SUBTITLE": "Set your SMTP details",
Expand Down
57 changes: 54 additions & 3 deletions app/jobs/inboxes/fetch_imap_emails_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -23,6 +21,17 @@ def should_fetch_email?(channel)
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)
Expand All @@ -41,9 +50,51 @@ def fetch_mail_for_channel(channel)
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
28 changes: 26 additions & 2 deletions app/mailers/conversation_reply_mailer_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,33 @@ def prepare_mail(cc_bcc_enabled)
@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

Expand Down Expand Up @@ -47,8 +67,12 @@ def email_imap_enabled
@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
Expand Down
Loading

0 comments on commit 00cbdaa

Please sign in to comment.