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:
16
LEADCHAT.md
16
LEADCHAT.md
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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) ===
|
||||
|
||||
@@ -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
|
||||
|
||||
42
app/services/microsoft/shared/imap_access_verifier.rb
Normal file
42
app/services/microsoft/shared/imap_access_verifier.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user