feat(microsoft-shared): Phase 1 — OAuth + send via Graph API

Adds a new "microsoft_shared" Email Channel provider that uses Microsoft
Graph API end-to-end for sending. Bypasses xoauth2 SMTP, so it works on
tenants with Security Defaults enabled and survives the April 2026 SMTP
Basic Auth retirement.

Flow:
1. Admin picks "Microsoft Shared Inbox" in the Add Inbox wizard
2. Enters the shared mailbox UPN (e.g. sales@company.com)
3. Single OAuth round-trip as a delegate user; UPN is carried in a signed
   state parameter so the callback knows which mailbox to bind
4. Callback verifies the OAuth user has access to the UPN via
   GET /users/{upn}/mailFolders/inbox before creating the channel
5. Replies route through a custom :microsoft_shared ActionMailer delivery
   method that POSTs base64-encoded MIME to /users/{upn}/sendMail

Channel modifications use the custom/ overlay (Custom::Leadchat::*).
Direct OSS edits are wrapped in '# === LeadChat: microsoft_shared ===' markers.

Phase 2 (Graph webhook receive) and Phase 3 (re-auth, subscription
failure surfacing, channel-destroy cleanup) follow.

Azure app prerequisites (one-time, manual): add delegated permissions
Mail.Send.Shared, Mail.ReadWrite.Shared, User.Read, offline_access; grant
admin consent. No new app registration required.

See LEADCHAT.md for the full customization index.
This commit is contained in:
netlas
2026-04-27 12:17:01 +03:00
parent 800f2b2654
commit 78cfd07009
14 changed files with 413 additions and 0 deletions

View File

@@ -17,6 +17,7 @@ LeadChat is a white-label Chatwoot fork maintained on the `leadchat` branch. Cus
| Hook in OSS file | Overlay file | Adds |
|---|---|---|
| `app/models/channel/email.rb` (bottom) | `custom/app/models/custom/leadchat/channel/email_extension.rb` | `Channel::Email#microsoft_shared?` predicate |
| `app/mailers/conversation_reply_mailer_helper.rb` (bottom) | `custom/app/mailers/custom/leadchat/conversation_reply_mailer_helper_extension.rb` | Routes `microsoft_shared` channels to the `:microsoft_shared` ActionMailer delivery method (Graph API), bypassing xoauth2 SMTP |
### B. Direct edits to OSS files
@@ -24,6 +25,10 @@ LeadChat is a white-label Chatwoot fork maintained on the `leadchat` branch. Cus
|---|---|---|
| `config/application.rb` | Mirror enterprise/ autoload setup for `custom/` so `Custom::*` modules are autoloaded and `custom/config/initializers/**/*.rb` are required | `# === LeadChat: custom/ overlay autoload ===` |
| `app/models/channel/email.rb` (bottom) | One-line `include_mod_with` hook for the email_extension overlay | `# === LeadChat: microsoft_shared ===` |
| `app/mailers/conversation_reply_mailer_helper.rb` (bottom) | One-line `prepend_mod_with` hook for the helper extension overlay | `# === LeadChat: microsoft_shared ===` |
| `config/routes.rb` (3 separate blocks) | (1) `app_new_microsoft_shared_inbox` dashboard route; (2) account-namespaced `microsoft_shared/authorization` API endpoint; (3) `microsoft_shared/callback` OAuth callback | `# === LeadChat: microsoft_shared ===` |
| `app/javascript/dashboard/routes/dashboard/settings/inbox/channels/Email.vue` (3 separate blocks) | (1) import `MicrosoftShared.vue`; (2) `emailProviderList` entry for `microsoft_shared`; (3) router slot `<MicrosoftShared v-else-if="provider === 'microsoft_shared'"/>` | `<!-- === LeadChat: microsoft_shared === -->` and `// === LeadChat: microsoft_shared ===` |
| `app/javascript/dashboard/i18n/locale/en/inboxMgmt.json` | New keys: `INBOX_MGMT.ADD.MICROSOFT_SHARED.*` and `INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT_SHARED.*` | (no comment markers — JSON; flagged here in LEADCHAT.md instead) |
### C. New top-level files (no upstream conflict potential)
@@ -31,6 +36,14 @@ LeadChat is a white-label Chatwoot fork maintained on the `leadchat` branch. Cus
|---|---|
| `LEADCHAT.md` | This file |
| `custom/` | Root of LeadChat overlay tree, picked up by `ChatwootApp.custom?` and the Phase-0 autoload setup |
| `custom/config/initializers/microsoft_shared_delivery.rb` | Registers `:microsoft_shared` ActionMailer delivery method |
| `app/controllers/concerns/microsoft_shared_concern.rb` | OAuth client + Graph scopes + signed-state encoding/decoding for the new provider |
| `app/controllers/api/v1/accounts/microsoft_shared/authorizations_controller.rb` | Accepts shared mailbox UPN, returns Microsoft authorize URL with UPN embedded in signed state |
| `app/controllers/microsoft_shared/callbacks_controller.rb` | OAuth callback: decodes state, exchanges code, verifies UPN access via Graph, creates `Channel::Email` + `Inbox` |
| `app/services/microsoft/shared/send_mail_service.rb` | Builds RFC 822 MIME and POSTs base64-encoded to `/users/{upn}/sendMail` via Graph |
| `app/mailers/microsoft_shared_delivery.rb` | Custom ActionMailer delivery method that delegates to the send service |
| `app/javascript/dashboard/api/channel/microsoftSharedClient.js` | Frontend API client |
| `app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/MicrosoftShared.vue` | Single-step UPN entry form before the OAuth redirect |
### D. Brand script targets

View File

@@ -0,0 +1,20 @@
class Api::V1::Accounts::MicrosoftShared::AuthorizationsController < Api::V1::Accounts::OauthAuthorizationController
include MicrosoftSharedConcern
def create
upn = params[:shared_mailbox_upn].to_s.strip.downcase
return render(json: { success: false, error: 'shared_mailbox_upn is required' }, status: :unprocessable_entity) if upn.blank?
redirect_url = microsoft_shared_client.auth_code.authorize_url(
redirect_uri: "#{base_url}/microsoft_shared/callback",
scope: microsoft_shared_scope,
state: encode_microsoft_shared_state(Current.account, upn)
)
if redirect_url
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
end

View File

@@ -0,0 +1,48 @@
module MicrosoftSharedConcern
extend ActiveSupport::Concern
STATE_VERIFIER_PURPOSE = :microsoft_shared_oauth_state
STATE_TTL = 15.minutes
GRAPH_SCOPES = %w[
offline_access
openid
profile
email
User.Read
Mail.Send.Shared
Mail.ReadWrite.Shared
].freeze
def microsoft_shared_client
app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil)
::OAuth2::Client.new(
app_id,
app_secret,
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 microsoft_shared_scope
GRAPH_SCOPES.join(' ')
end
def encode_microsoft_shared_state(account, shared_mailbox_upn)
Rails.application.message_verifier(STATE_VERIFIER_PURPOSE).generate(
{ account_gid: account.to_global_id.to_s, upn: shared_mailbox_upn },
expires_in: STATE_TTL
)
end
def decode_microsoft_shared_state(raw_state)
Rails.application.message_verifier(STATE_VERIFIER_PURPOSE).verify(raw_state)
rescue ActiveSupport::MessageVerifier::InvalidSignature, ActiveSupport::MessageEncryptor::InvalidMessage
nil
end
end

View File

@@ -0,0 +1,106 @@
class MicrosoftShared::CallbacksController < ApplicationController
include MicrosoftSharedConcern
GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'.freeze
def show
state_payload = decode_microsoft_shared_state(params[:state])
return redirect_with_error('Invalid or expired state.') if state_payload.blank?
@account = GlobalID::Locator.locate(state_payload[:account_gid])
return redirect_with_error('Account not found.') if @account.blank?
@upn = state_payload[:upn].to_s.strip.downcase
return redirect_with_error('Shared mailbox address missing.') if @upn.blank?
@response = exchange_code_for_tokens
return redirect_with_error('Token exchange failed.') if @response.blank?
return redirect_with_error("You don't have access to #{@upn}.") unless mailbox_accessible?
channel, inbox = create_channel_and_inbox
redirect_to app_email_inbox_agents_url(account_id: @account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_with_error('OAuth callback failed.')
end
private
def exchange_code_for_tokens
microsoft_shared_client.auth_code.get_token(
params[:code],
redirect_uri: "#{base_url}/microsoft_shared/callback"
)
end
def mailbox_accessible?
response = Faraday.new(url: GRAPH_BASE_URL).get("/users/#{CGI.escape(@upn)}/mailFolders/inbox") do |req|
req.headers['Authorization'] = "Bearer #{access_token}"
end
response.status == 200
rescue StandardError => e
Rails.logger.warn("Microsoft Shared mailbox access check failed: #{e.message}")
false
end
def create_channel_and_inbox
ActiveRecord::Base.transaction do
channel = Channel::Email.create!(
account: @account,
email: @upn,
provider: 'microsoft_shared',
provider_config: provider_config_payload
)
inbox = @account.inboxes.create!(account: @account, channel: channel, name: @upn)
[channel, inbox]
end
end
def provider_config_payload
{
access_token: access_token,
refresh_token: refresh_token,
expires_on: expires_on,
oauth_user_email: oauth_user_email
}
end
def access_token
parsed_body['access_token']
end
def refresh_token
parsed_body['refresh_token']
end
def expires_on
expires_in = parsed_body['expires_in']
return (Time.current.utc + 1.hour).to_s if expires_in.blank?
(Time.current.utc + expires_in.to_i.seconds).to_s
end
def oauth_user_email
id_token = parsed_body['id_token']
return nil if id_token.blank?
decoded = JWT.decode(id_token, nil, false).first
decoded['email'].presence || decoded['preferred_username'].presence || decoded['upn'].presence
rescue StandardError
nil
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
def redirect_with_error(message)
Rails.logger.warn("MicrosoftShared callback rejected: #{message}")
redirect_to '/'
end
end

View File

@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class MicrosoftSharedClient extends ApiClient {
constructor() {
super('microsoft_shared', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new MicrosoftSharedClient();

View File

@@ -524,6 +524,16 @@
"SIGN_IN": "Sign in with Microsoft",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"MICROSOFT_SHARED": {
"TITLE": "Microsoft Shared Inbox",
"DESCRIPTION": "Connect a shared mailbox (e.g. sales@yourcompany.com) using a Microsoft 365 user account that has Send As and Full Access permissions on the mailbox. All sending and receiving uses Microsoft Graph API, so it works with Security Defaults and survives the SMTP Basic Auth retirement.",
"UPN_LABEL": "Shared mailbox address",
"UPN_PLACEHOLDER": "sales@yourcompany.com",
"UPN_HELP": "The address of the shared mailbox you want to add. You'll authenticate next as a delegate user.",
"SIGN_IN": "Continue with Microsoft",
"INVALID_UPN": "Enter a valid email address.",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"GOOGLE": {
"TITLE": "Google Email",
"DESCRIPTION": "Click on the Sign in with Google button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
@@ -1131,6 +1141,10 @@
"TITLE": "Microsoft",
"DESCRIPTION": "Connect with Microsoft"
},
"MICROSOFT_SHARED": {
"TITLE": "Microsoft Shared Inbox",
"DESCRIPTION": "Connect a Microsoft 365 shared mailbox via Graph API"
},
"GOOGLE": {
"TITLE": "Google",
"DESCRIPTION": "Connect with Google"

View File

@@ -2,6 +2,9 @@
import { ref, computed } from 'vue';
import ForwardToOption from './emailChannels/ForwardToOption.vue';
import Microsoft from './emailChannels/Microsoft.vue';
// === LeadChat: microsoft_shared (start) ===
import MicrosoftShared from './emailChannels/MicrosoftShared.vue';
// === LeadChat: microsoft_shared (end) ===
import Google from './emailChannels/Google.vue';
import ChannelSelector from 'dashboard/components/ChannelSelector.vue';
import PageHeader from '../../SettingsSubPageHeader.vue';
@@ -26,6 +29,15 @@ const emailProviderList = computed(() => {
key: 'microsoft',
icon: 'i-woot-outlook',
},
// === LeadChat: microsoft_shared (start) ===
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT_SHARED.TITLE'),
description: t('INBOX_MGMT.EMAIL_PROVIDERS.MICROSOFT_SHARED.DESCRIPTION'),
isEnabled: !!globalConfig.value.azureAppId,
key: 'microsoft_shared',
icon: 'i-woot-outlook',
},
// === LeadChat: microsoft_shared (end) ===
{
title: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE.TITLE'),
description: t('INBOX_MGMT.EMAIL_PROVIDERS.GOOGLE.DESCRIPTION'),
@@ -75,6 +87,9 @@ function onClick(emailProvider) {
</div>
</div>
<Microsoft v-else-if="provider === 'microsoft'" />
<!-- === LeadChat: microsoft_shared (start) === -->
<MicrosoftShared v-else-if="provider === 'microsoft_shared'" />
<!-- === LeadChat: microsoft_shared (end) === -->
<Google v-else-if="provider === 'google'" />
<ForwardToOption v-else-if="provider === 'other_provider'" />
</template>

View File

@@ -0,0 +1,83 @@
<script setup>
import { ref, computed } from 'vue';
import microsoftSharedClient from 'dashboard/api/channel/microsoftSharedClient';
import SettingsSubPageHeader from '../../../SettingsSubPageHeader.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
defineOptions({
name: 'MicrosoftSharedChannel',
});
const { t } = useI18n();
const sharedMailboxUpn = ref('');
const isRequestingAuthorization = ref(false);
const trimmedUpn = computed(() => sharedMailboxUpn.value.trim().toLowerCase());
const isValidUpn = computed(() => {
if (!trimmedUpn.value) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedUpn.value);
});
async function requestAuthorization() {
if (!isValidUpn.value) {
useAlert(t('INBOX_MGMT.ADD.MICROSOFT_SHARED.INVALID_UPN'));
return;
}
try {
isRequestingAuthorization.value = true;
const response = await microsoftSharedClient.generateAuthorization({
shared_mailbox_upn: trimmedUpn.value,
});
const {
data: { url },
} = response;
window.location.href = url;
} catch (error) {
useAlert(t('INBOX_MGMT.ADD.MICROSOFT_SHARED.ERROR_MESSAGE'));
} finally {
isRequestingAuthorization.value = false;
}
}
</script>
<template>
<div class="h-full w-full p-6 col-span-6">
<SettingsSubPageHeader
:header-title="t('INBOX_MGMT.ADD.MICROSOFT_SHARED.TITLE')"
:header-content="t('INBOX_MGMT.ADD.MICROSOFT_SHARED.DESCRIPTION')"
/>
<form class="mt-6 max-w-xl" @submit.prevent="requestAuthorization">
<label class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2">
{{ t('INBOX_MGMT.ADD.MICROSOFT_SHARED.UPN_LABEL') }}
</label>
<input
v-model="sharedMailboxUpn"
type="email"
required
autocomplete="off"
:placeholder="t('INBOX_MGMT.ADD.MICROSOFT_SHARED.UPN_PLACEHOLDER')"
class="w-full rounded border border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800 px-3 py-2 text-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-woot-500"
/>
<p class="mt-2 text-xs text-slate-500 dark:text-slate-400">
{{ t('INBOX_MGMT.ADD.MICROSOFT_SHARED.UPN_HELP') }}
</p>
<div class="mt-4">
<NextButton
:is-loading="isRequestingAuthorization"
:disabled="!isValidUpn"
type="submit"
solid
blue
:label="t('INBOX_MGMT.ADD.MICROSOFT_SHARED.SIGN_IN')"
/>
</div>
</form>
</div>
</template>

View File

@@ -111,3 +111,7 @@ module ConversationReplyMailerHelper
email.present? ? email.split('@').last : raise(StandardError, 'Channel email domain not present.')
end
end
# === LeadChat: microsoft_shared (start) ===
ConversationReplyMailerHelper.prepend_mod_with('Leadchat::ConversationReplyMailerHelperExtension')
# === LeadChat: microsoft_shared (end) ===

View File

@@ -0,0 +1,24 @@
class MicrosoftSharedDelivery
attr_reader :settings
def initialize(settings)
@settings = settings
end
def deliver!(mail_message)
channel = resolve_channel
Microsoft::Shared::SendMailService.new(channel: channel, mail_message: mail_message).perform
rescue StandardError => e
Rails.logger.error("MicrosoftSharedDelivery failed: channel_id=#{settings[:channel_id]} - #{e.class}: #{e.message}")
raise
end
private
def resolve_channel
channel_id = settings[:channel_id]
raise ArgumentError, 'MicrosoftSharedDelivery requires :channel_id in delivery_method_options' if channel_id.blank?
Channel::Email.find(channel_id)
end
end

View File

@@ -0,0 +1,41 @@
class Microsoft::Shared::SendMailService
pattr_initialize [:channel!, :mail_message!]
GRAPH_BASE_URL = 'https://graph.microsoft.com/v1.0'.freeze
def perform
response = http_client.post(send_mail_path) do |req|
req.headers['Authorization'] = "Bearer #{access_token}"
req.headers['Content-Type'] = 'text/plain'
req.body = encoded_mime
end
raise SendMailError, "Microsoft Graph send failed: HTTP #{response.status} - #{response.body}" unless response.status == 202
Rails.logger.info("Microsoft Graph mail sent: channel=#{channel.id} upn=#{channel.email} subject=#{mail_message.subject.inspect}")
response
end
private
def access_token
Microsoft::RefreshOauthTokenService.new(channel: channel).access_token
end
def send_mail_path
"/users/#{CGI.escape(channel.email)}/sendMail"
end
def encoded_mime
Base64.strict_encode64(mail_message.to_s)
end
def http_client
@http_client ||= Faraday.new(url: GRAPH_BASE_URL) do |f|
f.options.open_timeout = 15
f.options.timeout = 30
end
end
class SendMailError < StandardError; end
end

View File

@@ -20,6 +20,9 @@ Rails.application.routes.draw do
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'
# === LeadChat: microsoft_shared (start) ===
get '/app/accounts/:account_id/settings/inboxes/new/microsoft_shared', to: 'dashboard#index', as: 'app_new_microsoft_shared_inbox'
# === LeadChat: microsoft_shared (end) ===
get '/app/accounts/:account_id/settings/inboxes/new/instagram', to: 'dashboard#index', as: 'app_new_instagram_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/tiktok', to: 'dashboard#index', as: 'app_new_tiktok_inbox'
get '/app/accounts/:account_id/settings/inboxes/new/:inbox_id/agents', to: 'dashboard#index', as: 'app_twitter_inbox_agents'
@@ -283,6 +286,12 @@ Rails.application.routes.draw do
resource :authorization, only: [:create]
end
# === LeadChat: microsoft_shared (start) ===
namespace :microsoft_shared do
resource :authorization, only: [:create]
end
# === LeadChat: microsoft_shared (end) ===
namespace :google do
resource :authorization, only: [:create]
end
@@ -602,6 +611,9 @@ Rails.application.routes.draw do
end
get 'microsoft/callback', to: 'microsoft/callbacks#show'
# === LeadChat: microsoft_shared (start) ===
get 'microsoft_shared/callback', to: 'microsoft_shared/callbacks#show'
# === LeadChat: microsoft_shared (end) ===
get 'google/callback', to: 'google/callbacks#show'
get 'instagram/callback', to: 'instagram/callbacks#show'
get 'tiktok/callback', to: 'tiktok/callbacks#show'

View File

@@ -0,0 +1,15 @@
module Custom::Leadchat::ConversationReplyMailerHelperExtension
# Override the OAuth SMTP routing path so microsoft_shared channels are sent
# via the Microsoft Graph API (custom :microsoft_shared delivery method)
# instead of falling through to xoauth2 SMTP, which Microsoft Security
# Defaults block tenant-wide.
def oauth_smtp_settings
if @channel.respond_to?(:microsoft_shared?) && @channel&.microsoft_shared?
@options[:delivery_method] = :microsoft_shared
@options[:delivery_method_options] = { channel_id: @channel.id }
return
end
super
end
end

View File

@@ -0,0 +1,4 @@
Rails.application.configure do
config.action_mailer.delivery_methods ||= {}
ActionMailer::Base.add_delivery_method :microsoft_shared, MicrosoftSharedDelivery
end