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:
@@ -0,0 +1,27 @@
|
||||
class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts::BaseController
|
||||
include MicrosoftConcern
|
||||
before_action :check_authorization
|
||||
|
||||
def create
|
||||
email = params[:authorization][:email]
|
||||
redirect_url = microsoft_client.auth_code.authorize_url(
|
||||
{
|
||||
redirect_uri: "#{base_url}/microsoft/callback",
|
||||
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid',
|
||||
prompt: 'consent'
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
|
||||
render json: { success: true, url: redirect_url }
|
||||
else
|
||||
render json: { success: false }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
end
|
||||
22
app/controllers/concerns/microsoft_concern.rb
Normal file
22
app/controllers/concerns/microsoft_concern.rb
Normal file
@@ -0,0 +1,22 @@
|
||||
module MicrosoftConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
def microsoft_client
|
||||
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil),
|
||||
{
|
||||
site: 'https://login.microsoftonline.com',
|
||||
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
|
||||
token_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/token'
|
||||
})
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
end
|
||||
72
app/controllers/microsoft/callbacks_controller.rb
Normal file
72
app/controllers/microsoft/callbacks_controller.rb
Normal file
@@ -0,0 +1,72 @@
|
||||
class Microsoft::CallbacksController < ApplicationController
|
||||
include MicrosoftConcern
|
||||
|
||||
def show
|
||||
@response = microsoft_client.auth_code.get_token(
|
||||
oauth_code,
|
||||
redirect_uri: "#{base_url}/microsoft/callback"
|
||||
)
|
||||
|
||||
inbox = find_or_create_inbox
|
||||
::Redis::Alfred.delete(users_data['email'])
|
||||
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
redirect_to '/'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def oauth_code
|
||||
params[:code]
|
||||
end
|
||||
|
||||
def users_data
|
||||
decoded_token = JWT.decode parsed_body[:id_token], nil, false
|
||||
decoded_token[0]
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
end
|
||||
|
||||
def account_id
|
||||
::Redis::Alfred.get(users_data['email'])
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= Account.find(account_id)
|
||||
end
|
||||
|
||||
def find_or_create_inbox
|
||||
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
|
||||
channel_email ||= create_microsoft_channel_with_inbox
|
||||
update_microsoft_channel(channel_email)
|
||||
channel_email.inbox
|
||||
end
|
||||
|
||||
def create_microsoft_channel_with_inbox
|
||||
ActiveRecord::Base.transaction do
|
||||
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
|
||||
account.inboxes.create!(
|
||||
account: account,
|
||||
channel: channel_email,
|
||||
name: users_data['name']
|
||||
)
|
||||
channel_email
|
||||
end
|
||||
end
|
||||
|
||||
def update_microsoft_channel(channel_email)
|
||||
channel_email.update!({
|
||||
imap_login: users_data['email'], imap_address: 'outlook.office365.com',
|
||||
imap_port: '993', imap_enabled: true,
|
||||
provider: 'microsoft',
|
||||
provider_config: {
|
||||
access_token: parsed_body['access_token'],
|
||||
refresh_token: parsed_body['refresh_token'],
|
||||
expires_on: (Time.current.utc + 1.hour).to_s
|
||||
}
|
||||
})
|
||||
end
|
||||
end
|
||||
@@ -53,6 +53,10 @@
|
||||
"ENABLE": "Create conversations from mentioned Tweets"
|
||||
}
|
||||
},
|
||||
"MICROSOFT": {
|
||||
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ",
|
||||
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
|
||||
},
|
||||
"WEBSITE_CHANNEL": {
|
||||
"TITLE": "Website channel",
|
||||
"DESC": "Create a channel for your website and start supporting your customers via our website widget.",
|
||||
@@ -548,6 +552,10 @@
|
||||
},
|
||||
"ENABLE_SSL": "Enable SSL"
|
||||
},
|
||||
"MICROSOFT": {
|
||||
"TITLE": "Microsoft",
|
||||
"SUBTITLE": "Reauthorize your MICROSOFT account"
|
||||
},
|
||||
"SMTP": {
|
||||
"TITLE": "SMTP",
|
||||
"SUBTITLE": "Set your SMTP details",
|
||||
|
||||
@@ -6,9 +6,7 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
def perform(channel)
|
||||
return unless should_fetch_email?(channel)
|
||||
|
||||
fetch_mail_for_channel(channel)
|
||||
# clearing old failures like timeouts since the mail is now successfully processed
|
||||
channel.reauthorized!
|
||||
process_email_for_channel(channel)
|
||||
rescue *ExceptionList::IMAP_EXCEPTIONS
|
||||
channel.authorization_error!
|
||||
rescue EOFError => e
|
||||
@@ -23,6 +21,17 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
channel.imap_enabled? && !channel.reauthorization_required?
|
||||
end
|
||||
|
||||
def process_email_for_channel(channel)
|
||||
# fetching email for microsoft provider
|
||||
if channel.microsoft?
|
||||
fetch_mail_for_ms_provider(channel)
|
||||
else
|
||||
fetch_mail_for_channel(channel)
|
||||
end
|
||||
# clearing old failures like timeouts since the mail is now successfully processed
|
||||
channel.reauthorized!
|
||||
end
|
||||
|
||||
def fetch_mail_for_channel(channel)
|
||||
# TODO: rather than setting this as default method for all mail objects, lets if can do new mail object
|
||||
# using Mail.retriever_method.new(params)
|
||||
@@ -41,9 +50,51 @@ class Inboxes::FetchImapEmailsJob < ApplicationJob
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_mail_for_ms_provider(channel)
|
||||
return if channel.provider_config['access_token'].blank?
|
||||
|
||||
access_token = valid_access_token channel
|
||||
|
||||
return unless access_token
|
||||
|
||||
imap = imap_authenticate(channel, access_token)
|
||||
|
||||
process_mails(imap, channel)
|
||||
end
|
||||
|
||||
def process_mails(imap, channel)
|
||||
imap.search(['BEFORE', tomorrow, 'SINCE', yesterday]).each do |message_id|
|
||||
inbound_mail = Mail.read_from_string imap.fetch(message_id, 'RFC822')[0].attr['RFC822']
|
||||
|
||||
next if channel.inbox.messages.find_by(source_id: inbound_mail.message_id).present?
|
||||
|
||||
process_mail(inbound_mail, channel)
|
||||
end
|
||||
end
|
||||
|
||||
def imap_authenticate(channel, access_token)
|
||||
imap = Net::IMAP.new(channel.imap_address, channel.imap_port, true)
|
||||
imap.authenticate('XOAUTH2', channel.imap_login, access_token)
|
||||
imap.select('INBOX')
|
||||
imap
|
||||
end
|
||||
|
||||
def yesterday
|
||||
(Time.zone.today - 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
|
||||
def tomorrow
|
||||
(Time.zone.today + 1).strftime('%d-%b-%Y')
|
||||
end
|
||||
|
||||
def process_mail(inbound_mail, channel)
|
||||
Imap::ImapMailbox.new.process(inbound_mail, channel)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
|
||||
# Making sure the access token is valid for microsoft provider
|
||||
def valid_access_token(channel)
|
||||
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,13 +13,33 @@ module ConversationReplyMailerHelper
|
||||
@options[:cc] = cc_bcc_emails[0]
|
||||
@options[:bcc] = cc_bcc_emails[1]
|
||||
end
|
||||
|
||||
ms_smtp_settings
|
||||
set_delivery_method
|
||||
|
||||
mail(@options)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ms_smtp_settings
|
||||
return unless @inbox.email? && @channel.imap_enabled && @inbox.channel.provider == 'microsoft'
|
||||
|
||||
smtp_settings = {
|
||||
address: 'smtp.office365.com',
|
||||
port: 587,
|
||||
user_name: @channel.imap_login,
|
||||
password: @channel.provider_config['access_token'],
|
||||
domain: 'smtp.office365.com',
|
||||
tls: false,
|
||||
enable_starttls_auto: true,
|
||||
openssl_verify_mode: 'none',
|
||||
authentication: 'xoauth2'
|
||||
}
|
||||
|
||||
@options[:delivery_method] = :smtp
|
||||
@options[:delivery_method_options] = smtp_settings
|
||||
end
|
||||
|
||||
def set_delivery_method
|
||||
return unless @inbox.inbox_type == 'Email' && @channel.smtp_enabled
|
||||
|
||||
@@ -47,8 +67,12 @@ module ConversationReplyMailerHelper
|
||||
@inbox.inbox_type == 'Email' && @channel.imap_enabled
|
||||
end
|
||||
|
||||
def email_microsoft_auth_enabled
|
||||
@inbox.inbox_type == 'Email' && @channel.provider == 'microsoft'
|
||||
end
|
||||
|
||||
def email_from
|
||||
email_smtp_enabled ? @channel.email : from_email_with_name
|
||||
email_microsoft_auth_enabled || email_smtp_enabled ? @channel.email : from_email_with_name
|
||||
end
|
||||
|
||||
def email_reply_to
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
# imap_login :string default("")
|
||||
# imap_password :string default("")
|
||||
# imap_port :integer default(0)
|
||||
# provider :string
|
||||
# provider_config :jsonb
|
||||
# smtp_address :string default("")
|
||||
# smtp_authentication :string default("login")
|
||||
# smtp_domain :string default("")
|
||||
@@ -41,7 +43,7 @@ class Channel::Email < ApplicationRecord
|
||||
self.table_name = 'channel_email'
|
||||
EDITABLE_ATTRS = [:email, :imap_enabled, :imap_login, :imap_password, :imap_address, :imap_port, :imap_enable_ssl, :imap_inbox_synced_at,
|
||||
:smtp_enabled, :smtp_login, :smtp_password, :smtp_address, :smtp_port, :smtp_domain, :smtp_enable_starttls_auto,
|
||||
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication].freeze
|
||||
:smtp_enable_ssl_tls, :smtp_openssl_verify_mode, :smtp_authentication, :provider].freeze
|
||||
|
||||
validates :email, uniqueness: true
|
||||
validates :forward_to_email, uniqueness: true
|
||||
@@ -52,6 +54,10 @@ class Channel::Email < ApplicationRecord
|
||||
'Email'
|
||||
end
|
||||
|
||||
def microsoft?
|
||||
provider == 'microsoft'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_forward_to_email
|
||||
|
||||
53
app/services/microsoft/refresh_oauth_token_service.rb
Normal file
53
app/services/microsoft/refresh_oauth_token_service.rb
Normal file
@@ -0,0 +1,53 @@
|
||||
# refer: https://gitlab.com/gitlab-org/ruby/gems/gitlab-mail_room/-/blob/master/lib/mail_room/microsoft_graph/connection.rb
|
||||
# refer: https://github.com/microsoftgraph/msgraph-sample-rubyrailsapp/tree/b4a6869fe4a438cde42b161196484a929f1bee46
|
||||
# https://learn.microsoft.com/en-us/azure/active-directory/develop/active-directory-configurable-token-lifetimes
|
||||
class Microsoft::RefreshOauthTokenService
|
||||
pattr_initialize [:channel!]
|
||||
|
||||
# if the token is not expired yet then skip the refresh token step
|
||||
def access_token
|
||||
provider_config = channel.provider_config.with_indifferent_access
|
||||
if Time.current.utc >= expires_on(provider_config['expires_on'])
|
||||
# Token expired, refresh
|
||||
new_hash = refresh_tokens
|
||||
new_hash[:access_token]
|
||||
else
|
||||
provider_config[:access_token]
|
||||
end
|
||||
end
|
||||
|
||||
def expires_on(expiry)
|
||||
# we will give it a 5 minute gap for safety
|
||||
expiry.presence ? DateTime.parse(expiry) - 5.minutes : Time.current.utc
|
||||
end
|
||||
|
||||
# <RefreshTokensSnippet>
|
||||
def refresh_tokens
|
||||
token_hash = channel.provider_config.with_indifferent_access
|
||||
oauth_strategy = ::MicrosoftGraphAuth.new(
|
||||
nil, ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil)
|
||||
)
|
||||
|
||||
token_service = OAuth2::AccessToken.new(
|
||||
oauth_strategy.client, token_hash['access_token'],
|
||||
refresh_token: token_hash['refresh_token']
|
||||
)
|
||||
|
||||
# Refresh the tokens
|
||||
new_tokens = token_service.refresh!.to_hash.slice(:access_token, :refresh_token, :expires_at)
|
||||
|
||||
update_channel_provider_config(new_tokens)
|
||||
channel.provider_config
|
||||
end
|
||||
# </RefreshTokensSnippet>
|
||||
|
||||
def update_channel_provider_config(new_tokens)
|
||||
new_tokens = new_tokens.with_indifferent_access
|
||||
channel.provider_config = {
|
||||
access_token: new_tokens[:access_token],
|
||||
refresh_token: new_tokens[:refresh_token],
|
||||
expires_on: Time.at(new_tokens[:expires_at]).utc.to_s
|
||||
}
|
||||
channel.save!
|
||||
end
|
||||
end
|
||||
@@ -62,6 +62,7 @@ if resource.email?
|
||||
json.imap_address resource.channel.try(:imap_address)
|
||||
json.imap_port resource.channel.try(:imap_port)
|
||||
json.imap_enabled resource.channel.try(:imap_enabled)
|
||||
json.microsoft_reauthorization resource.channel.try(:microsoft?) && resource.channel.try(:provider_config).empty?
|
||||
json.imap_enable_ssl resource.channel.try(:imap_enable_ssl)
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user