feat(microsoft-shared): Phase 1b — add imap_smtp + imap_leadmail transports

Microsoft 365 Graph API requires the shared mailbox itself to be licensed
(€5/mo each), which doesn't scale across clients. Phase 1's graph_full
transport stays for licensed mailboxes; this commit adds two free-of-extra-
licensing transports alongside it. Admin picks per channel.

Three transports now:
- graph_full     -> Graph send + (Phase 2) Graph webhook receive
- imap_smtp      -> M365 xoauth2 SMTP send + xoauth2 IMAP receive
- imap_leadmail  -> LeadMail API send + xoauth2 IMAP receive

Backend:
- MicrosoftSharedConcern: SCOPES_BY_TRANSPORT map (Graph and Outlook scopes
  are different audiences — separate scope set per transport).
- AuthorizationsController accepts and validates a transport param,
  uses transport-specific scopes, encodes transport into signed state.
- CallbacksController reads transport from state, dispatches to the right
  access verifier (Graph for graph_full, new ImapAccessVerifier for imap_*),
  stores transport in provider_config, sets imap_login=upn for IMAP transports.
- New Microsoft::Shared::ImapAccessVerifier opens a Net::IMAP connection,
  authenticates XOAUTH2 with the SHARED mailbox UPN as username and the
  delegate's access token, runs CAPABILITY, closes. Confirms the SASL pattern
  works for this delegate before creating the channel.

Send routing (overlay on ConversationReplyMailerHelper):
- graph_full     -> :microsoft_shared delivery (existing Graph send service)
- imap_smtp      -> :smtp with xoauth2, delegate UPN as SASL user, channel.email
                    as From: header (Microsoft routes via Send As permission)
- imap_leadmail  -> :leadmail delivery (existing LeadmailDelivery + the small
                    extension below to forward threading headers)

Receive routing:
- New Custom::Leadchat::Inboxes::FetchImapEmailsJobExtension overlay routes
  microsoft_shared channels with imap_* transport to the existing
  Imap::MicrosoftFetchEmailService. That service already does xoauth2 IMAP
  with channel.imap_login as the SASL user; we set imap_login=upn at channel
  creation so the existing fetch logic reads the shared mailbox's INBOX with
  no service-level changes.

LeadmailDelivery: build_payload now forwards in_reply_to, references, and
message_id so conversation reply threading works on the imap_leadmail path.
LeadMail server should preserve these as RFC822 headers on the outbound MIME.

Frontend:
- MicrosoftShared.vue gets a transport radio with 3 options + per-option help.
- microsoftSharedClient already passes payload through (no change).
- en inboxMgmt.json: TRANSPORT.{IMAP_SMTP,IMAP_LEADMAIL,GRAPH}.{TITLE,HELP}.

Token management: existing Microsoft::RefreshOauthTokenService works for
all three transports — refresh tokens come back with the same scopes the
original grant had.

Phase 2 (Graph webhook receive for graph_full) remains pending; only needed
if you ship licensed-mailbox channels.
This commit is contained in:
netlas
2026-04-27 15:26:09 +03:00
parent 39820137c1
commit 3c93714f27
11 changed files with 311 additions and 43 deletions

View File

@@ -17,7 +17,8 @@ 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 |
| `app/mailers/conversation_reply_mailer_helper.rb` (bottom) | `custom/app/mailers/custom/leadchat/conversation_reply_mailer_helper_extension.rb` | Routes `microsoft_shared` channels by `provider_config['transport']`: `graph_full``:microsoft_shared` delivery (Graph), `imap_smtp``:smtp` with xoauth2 (delegate UPN as SASL user, channel.email as From: for Send As), `imap_leadmail``:leadmail` |
| `app/jobs/inboxes/fetch_imap_emails_job.rb` (bottom) | `custom/app/jobs/custom/leadchat/inboxes/fetch_imap_emails_job_extension.rb` | Routes `microsoft_shared` channels with `transport ∈ {imap_smtp, imap_leadmail}` to `Imap::MicrosoftFetchEmailService` (which already handles xoauth2 token refresh) |
### B. Direct edits to OSS files
@@ -26,6 +27,8 @@ 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 ===` |
| `app/jobs/inboxes/fetch_imap_emails_job.rb` (bottom) | One-line `prepend_mod_with` hook for the fetch dispatcher overlay | `# === LeadChat: microsoft_shared ===` |
| `app/mailers/leadmail_delivery.rb` (`build_payload`) | Forward `In-Reply-To`, `References`, and `Message-ID` headers in the LeadMail API payload so conversation replies thread correctly when sent via the `imap_leadmail` transport | `# === 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) |
@@ -40,10 +43,11 @@ LeadChat is a white-label Chatwoot fork maintained on the `leadchat` branch. Cus
| `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/services/microsoft/shared/send_mail_service.rb` | Builds RFC 822 MIME and POSTs base64-encoded to `/users/{upn}/sendMail` via Graph (used by `graph_full` transport) |
| `app/services/microsoft/shared/imap_access_verifier.rb` | Verifies a delegate's access token can scope IMAP to a shared mailbox (used by callback for `imap_*` transports) |
| `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 |
| `app/javascript/dashboard/routes/dashboard/settings/inbox/channels/emailChannels/MicrosoftShared.vue` | Wizard form: UPN entry + transport radio (3 presets: imap_smtp, imap_leadmail, graph_full) |
### D. Brand script targets
@@ -53,7 +57,11 @@ Strings/files that the brand script rewrites away from "Chatwoot". Extend this l
## Active feature work
- **Microsoft Shared Inbox provider** (in progress) — new `microsoft_shared` Email Channel type backed entirely by Microsoft Graph API, supports multiple shared mailboxes per OAuth via separate channel-add flows, bypasses Security Defaults / SMTP AUTH retirement. Plan: `~/.claude/plans/starry-mapping-snowflake.md`.
- **Microsoft Shared Inbox provider** (Phase 1b in progress) — new `microsoft_shared` Email Channel type with three transport presets selected per channel:
- `graph_full` — Graph send + Graph webhook receive (requires shared mailbox license; Graph send shipped, webhook receive is Phase 2)
- `imap_smtp` — M365 xoauth2 SMTP send + xoauth2 IMAP receive (no license needed; admin enables SMTP AUTH + IMAP per delegate user)
- `imap_leadmail` — LeadMail API send + xoauth2 IMAP receive (no license needed, no M365 SMTP AUTH needed)
Plan: `~/.claude/plans/starry-mapping-snowflake.md`.
## Conventions

View File

@@ -5,10 +5,13 @@ class Api::V1::Accounts::MicrosoftShared::AuthorizationsController < Api::V1::Ac
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?
transport = params[:transport].to_s
return render(json: { success: false, error: "transport must be one of #{TRANSPORTS.join(', ')}" }, status: :unprocessable_entity) unless TRANSPORTS.include?(transport)
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),
scope: microsoft_shared_scope_for(transport),
state: encode_microsoft_shared_state(Current.account, upn, transport),
prompt: 'consent'
)

View File

@@ -4,15 +4,36 @@ module MicrosoftSharedConcern
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
TRANSPORTS = %w[graph_full imap_smtp imap_leadmail].freeze
SCOPES_BY_TRANSPORT = {
'graph_full' => %w[
offline_access
openid
profile
email
User.Read
Mail.Send.Shared
Mail.ReadWrite.Shared
].freeze,
'imap_smtp' => %w[
offline_access
openid
profile
email
User.Read
https://outlook.office.com/IMAP.AccessAsUser.All
https://outlook.office.com/SMTP.Send
].freeze,
'imap_leadmail' => %w[
offline_access
openid
profile
email
User.Read
https://outlook.office.com/IMAP.AccessAsUser.All
].freeze
}.freeze
def microsoft_shared_client
app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
@@ -29,13 +50,13 @@ module MicrosoftSharedConcern
private
def microsoft_shared_scope
GRAPH_SCOPES.join(' ')
def microsoft_shared_scope_for(transport)
SCOPES_BY_TRANSPORT.fetch(transport).join(' ')
end
def encode_microsoft_shared_state(account, shared_mailbox_upn)
def encode_microsoft_shared_state(account, shared_mailbox_upn, transport)
Rails.application.message_verifier(STATE_VERIFIER_PURPOSE).generate(
{ account_gid: account.to_global_id.to_s, upn: shared_mailbox_upn },
{ account_gid: account.to_global_id.to_s, upn: shared_mailbox_upn, transport: transport },
expires_in: STATE_TTL
)
end

View File

@@ -13,6 +13,9 @@ class MicrosoftShared::CallbacksController < ApplicationController
@upn = state_payload[:upn].to_s.strip.downcase
return redirect_with_error('Shared mailbox address missing.') if @upn.blank?
@transport = state_payload[:transport].to_s
return redirect_with_error("Unsupported transport #{@transport.inspect}.") unless TRANSPORTS.include?(@transport)
@response = exchange_code_for_tokens
return redirect_with_error('Token exchange failed.') if @response.blank?
@@ -35,25 +38,36 @@ class MicrosoftShared::CallbacksController < ApplicationController
end
def mailbox_accessible?
case @transport
when 'graph_full' then graph_mailbox_accessible?
when 'imap_smtp', 'imap_leadmail' then imap_mailbox_accessible?
end
end
def graph_mailbox_accessible?
response = Faraday.new(url: GRAPH_BASE_URL).get("/users/#{@upn}/mailFolders/inbox") do |req|
req.headers['Authorization'] = "Bearer #{access_token}"
end
if response.status == 200
Rails.logger.info("Microsoft Shared mailbox access verified: upn=#{@upn} granted_scopes=#{token_scopes.inspect}")
Rails.logger.info("Microsoft Shared mailbox access verified (graph): upn=#{@upn} granted_scopes=#{token_scopes.inspect}")
return true
end
Rails.logger.warn(
"Microsoft Shared mailbox access denied: upn=#{@upn} status=#{response.status} " \
"Microsoft Shared mailbox access denied (graph): upn=#{@upn} status=#{response.status} " \
"granted_scopes=#{token_scopes.inspect} body=#{response.body.to_s[0, 500]}"
)
false
rescue StandardError => e
Rails.logger.warn("Microsoft Shared mailbox access check raised: upn=#{@upn} #{e.class}: #{e.message}")
Rails.logger.warn("Microsoft Shared mailbox access check raised (graph): upn=#{@upn} #{e.class}: #{e.message}")
false
end
def imap_mailbox_accessible?
Microsoft::Shared::ImapAccessVerifier.new(upn: @upn, access_token: access_token).verify
end
# Decode the JWT payload (without verifying signature — just inspecting our own token's scopes
# for debugging purposes). Microsoft Graph access tokens are JWTs with scope info in `scp`.
def token_scopes
@@ -64,23 +78,38 @@ class MicrosoftShared::CallbacksController < ApplicationController
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
)
channel = Channel::Email.create!(channel_attributes)
inbox = @account.inboxes.create!(account: @account, channel: channel, name: @upn)
[channel, inbox]
end
end
def channel_attributes
base = {
account: @account,
email: @upn,
provider: 'microsoft_shared',
provider_config: provider_config_payload
}
# IMAP-based transports need imap_login = shared mailbox UPN so the existing
# Imap::MicrosoftFetchEmailService uses it as the SASL XOAUTH2 username.
base.merge!(imap_login: @upn, imap_address: 'outlook.office365.com', imap_port: 993, imap_enabled: true) if imap_transport?
base
end
def imap_transport?
%w[imap_smtp imap_leadmail].include?(@transport)
end
def provider_config_payload
{
access_token: access_token,
refresh_token: refresh_token,
expires_on: expires_on,
oauth_user_email: oauth_user_email
oauth_user_email: oauth_user_email,
transport: @transport
}
end

View File

@@ -526,13 +526,28 @@
},
"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.",
"DESCRIPTION": "Connect a Microsoft 365 shared mailbox using a delegate user account that has Send As and Full Access permissions on it. Pick a transport below depending on whether the shared mailbox is licensed and whether you can enable SMTP AUTH for the delegate.",
"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"
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again",
"TRANSPORT": {
"LABEL": "How should this inbox send and receive?",
"IMAP_SMTP": {
"TITLE": "SMTP + IMAP (Microsoft 365, no license needed)",
"HELP": "Sends via Microsoft 365 SMTP and receives via Microsoft 365 IMAP. Requires the delegate user to have SMTP AUTH and IMAP enabled in Microsoft 365 admin. The shared mailbox does not need its own license."
},
"IMAP_LEADMAIL": {
"TITLE": "LeadMail send + Microsoft IMAP receive",
"HELP": "Sends via the LeadMail API (no Microsoft SMTP needed) and receives via Microsoft 365 IMAP. Works on tenants that block SMTP AUTH. Requires the delegate user to have IMAP enabled in Microsoft 365 admin."
},
"GRAPH": {
"TITLE": "Microsoft Graph API (shared mailbox must be licensed)",
"HELP": "Sends and receives via Microsoft Graph. Cleanest path but Microsoft requires a Microsoft 365 license assigned to the shared mailbox itself."
}
}
},
"GOOGLE": {
"TITLE": "Google Email",

View File

@@ -15,6 +15,7 @@ defineOptions({
const { t } = useI18n();
const sharedMailboxUpn = ref('');
const transport = ref('imap_smtp');
const isRequestingAuthorization = ref(false);
const trimmedUpn = computed(() => sharedMailboxUpn.value.trim().toLowerCase());
@@ -24,6 +25,24 @@ const isValidUpn = computed(() => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedUpn.value);
});
const transportOptions = computed(() => [
{
key: 'imap_smtp',
label: t('INBOX_MGMT.ADD.MICROSOFT_SHARED.TRANSPORT.IMAP_SMTP.TITLE'),
help: t('INBOX_MGMT.ADD.MICROSOFT_SHARED.TRANSPORT.IMAP_SMTP.HELP'),
},
{
key: 'imap_leadmail',
label: t('INBOX_MGMT.ADD.MICROSOFT_SHARED.TRANSPORT.IMAP_LEADMAIL.TITLE'),
help: t('INBOX_MGMT.ADD.MICROSOFT_SHARED.TRANSPORT.IMAP_LEADMAIL.HELP'),
},
{
key: 'graph_full',
label: t('INBOX_MGMT.ADD.MICROSOFT_SHARED.TRANSPORT.GRAPH.TITLE'),
help: t('INBOX_MGMT.ADD.MICROSOFT_SHARED.TRANSPORT.GRAPH.HELP'),
},
]);
async function requestAuthorization() {
if (!isValidUpn.value) {
useAlert(t('INBOX_MGMT.ADD.MICROSOFT_SHARED.INVALID_UPN'));
@@ -34,6 +53,7 @@ async function requestAuthorization() {
isRequestingAuthorization.value = true;
const response = await microsoftSharedClient.generateAuthorization({
shared_mailbox_upn: trimmedUpn.value,
transport: transport.value,
});
const {
data: { url },
@@ -54,7 +74,9 @@ async function requestAuthorization() {
: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">
<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
@@ -68,7 +90,46 @@ async function requestAuthorization() {
<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">
<fieldset class="mt-6">
<legend
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
>
{{ t('INBOX_MGMT.ADD.MICROSOFT_SHARED.TRANSPORT.LABEL') }}
</legend>
<div class="space-y-2">
<label
v-for="option in transportOptions"
:key="option.key"
class="flex items-start gap-3 rounded border border-slate-200 dark:border-slate-600 p-3 cursor-pointer hover:border-woot-400"
:class="{
'border-woot-500 bg-woot-50 dark:bg-slate-700':
transport === option.key,
}"
>
<input
v-model="transport"
type="radio"
:value="option.key"
class="mt-1"
/>
<span>
<span
class="block text-sm font-medium text-slate-800 dark:text-slate-100"
>
{{ option.label }}
</span>
<span
class="block text-xs text-slate-500 dark:text-slate-400 mt-1"
>
{{ option.help }}
</span>
</span>
</label>
</div>
</fieldset>
<div class="mt-6">
<NextButton
:is-loading="isRequestingAuthorization"
:disabled="!isValidUpn"

View File

@@ -52,3 +52,7 @@ class Inboxes::FetchImapEmailsJob < MutexApplicationJob
#{channel.provider} Email dropped: #{inbound_mail.from} and message_source_id: #{inbound_mail.message_id}")
end
end
# === LeadChat: microsoft_shared (start) ===
Inboxes::FetchImapEmailsJob.prepend_mod_with('Leadchat::Inboxes::FetchImapEmailsJobExtension')
# === LeadChat: microsoft_shared (end) ===

View File

@@ -35,6 +35,14 @@ class LeadmailDelivery
html_body: html_body(message),
text_body: text_body(message),
attachments: extract_attachments(message),
# === LeadChat: microsoft_shared (start) ===
# Forward threading headers so conversation replies sent via LeadMail thread
# correctly in the recipient's mail client. The LeadMail API should preserve
# these as RFC822 headers on the outbound MIME.
in_reply_to: message.in_reply_to.presence,
references: message.references.presence,
message_id: message.message_id.presence,
# === LeadChat: microsoft_shared (end) ===
options: {
on_verification_failure: 'strip',
allow_disposable: false

View File

@@ -0,0 +1,42 @@
require 'net/imap'
# Verifies that a delegate user's access token can scope an IMAP session to a
# shared mailbox. Microsoft 365 supports this when:
# - The delegate has Full Access permission on the shared mailbox in Exchange
# - The delegate's mailbox has IMAP enabled in M365 admin
# - The SASL XOAUTH2 username is set to the SHARED mailbox UPN (not the delegate's)
# - The access token is the delegate's token with IMAP.AccessAsUser.All scope
#
# Returns true if a CAPABILITY round-trip succeeds. Logs the failure reason and
# returns false otherwise. Connection is closed in all cases.
class Microsoft::Shared::ImapAccessVerifier
pattr_initialize [:upn!, :access_token!]
IMAP_HOST = 'outlook.office365.com'.freeze
IMAP_PORT = 993
def verify
imap = Net::IMAP.new(IMAP_HOST, port: IMAP_PORT, ssl: true)
imap.authenticate('XOAUTH2', upn, access_token)
imap.capability
Rails.logger.info("Microsoft Shared IMAP access verified: upn=#{upn}")
true
rescue Net::IMAP::NoResponseError, Net::IMAP::BadResponseError, Net::IMAP::ResponseError => e
Rails.logger.warn("Microsoft Shared IMAP access denied: upn=#{upn} #{e.class}: #{e.message}")
false
rescue StandardError => e
Rails.logger.warn("Microsoft Shared IMAP access check raised: upn=#{upn} #{e.class}: #{e.message}")
false
ensure
begin
imap&.logout
rescue StandardError
# ignore
end
begin
imap&.disconnect
rescue StandardError
# ignore
end
end
end

View File

@@ -0,0 +1,26 @@
module Custom::Leadchat::Inboxes::FetchImapEmailsJobExtension
IMAP_TRANSPORTS = %w[imap_smtp imap_leadmail].freeze
private
# Route microsoft_shared channels with an imap_* transport through the existing
# Imap::MicrosoftFetchEmailService. The OSS dispatcher only checks for
# channel.microsoft? / google? / generic; without this overlay, our shared channels
# would fall into the generic IMAP path which doesn't refresh OAuth tokens.
def process_email_for_channel(channel, interval)
return super unless route_to_microsoft_fetch?(channel)
inbound_emails = ::Imap::MicrosoftFetchEmailService.new(channel: channel, interval: interval).perform
inbound_emails.each { |inbound_mail| process_mail(inbound_mail, channel) }
rescue OAuth2::Error => e
Rails.logger.error("Error for email channel - #{channel.inbox.id} : #{e.message}")
channel.authorization_error!
end
def route_to_microsoft_fetch?(channel)
return false unless channel.respond_to?(:microsoft_shared?) && channel.microsoft_shared?
return false unless channel.provider_config.is_a?(Hash)
IMAP_TRANSPORTS.include?(channel.provider_config['transport'])
end
end

View File

@@ -1,15 +1,66 @@
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.
# Override the OAuth SMTP routing path for microsoft_shared channels. Each
# channel's provider_config['transport'] decides which delivery method runs:
#
# graph_full -> custom :microsoft_shared delivery (Graph API)
# imap_smtp -> standard :smtp with xoauth2 (delegate UPN as SASL user,
# channel.email as From: header for Send As)
# imap_leadmail -> existing :leadmail delivery (LeadMail API)
#
# Without these overrides shared channels would fall through to the upstream
# xoauth2 SMTP path (or the global default delivery method) which doesn't
# have the right SASL pattern for shared mailboxes.
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
return super unless @channel.respond_to?(:microsoft_shared?) && @channel&.microsoft_shared?
super
case microsoft_shared_transport
when 'graph_full'
apply_microsoft_shared_graph_delivery
when 'imap_smtp'
apply_microsoft_shared_smtp_delivery
when 'imap_leadmail'
apply_microsoft_shared_leadmail_delivery
else
Rails.logger.warn("microsoft_shared channel #{@channel.id} has unsupported transport=#{microsoft_shared_transport.inspect}")
super
end
end
private
def microsoft_shared_transport
@channel.provider_config.is_a?(Hash) ? @channel.provider_config['transport'] : nil
end
def apply_microsoft_shared_graph_delivery
@options[:delivery_method] = :microsoft_shared
@options[:delivery_method_options] = { channel_id: @channel.id }
end
def apply_microsoft_shared_smtp_delivery
delegate_upn = @channel.provider_config['oauth_user_email']
access_token = ::Microsoft::RefreshOauthTokenService.new(channel: @channel).access_token
@options[:delivery_method] = :smtp
@options[:delivery_method_options] = {
address: 'smtp.office365.com',
port: 587,
user_name: delegate_upn,
password: access_token,
domain: 'smtp.office365.com',
tls: false,
enable_starttls_auto: true,
openssl_verify_mode: 'none',
open_timeout: 15,
read_timeout: 15,
authentication: 'xoauth2'
}
end
def apply_microsoft_shared_leadmail_delivery
# delivery_method_options omitted on purpose — :leadmail uses the global
# config.action_mailer.leadmail_settings (api_url + token from ENV) set up
# in config/initializers/mailer.rb.
@options[:delivery_method] = :leadmail
end
end