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.
6.9 KiB
6.9 KiB
LeadChat customizations
This file is the index of every LeadChat-specific customization on top of upstream Chatwoot. Consult it during upstream merges to know what's customized and where conflicts may surface.
LeadChat is a white-label Chatwoot fork maintained on the leadchat branch. Customizations are layered using Chatwoot's built-in custom/ extension mechanism (see lib/chatwoot_app.rb — ChatwootApp.extensions includes 'custom' automatically when a custom/ directory exists at repo root).
Layering strategy
- Overlay modules under
custom/— preferred for Ruby modifications. New code lives incustom/app/...under theCustom::Leadchat::*namespace; OSS files only get a one-lineinclude_mod_with/prepend_mod_withhook that Chatwoot's extension loader resolves automatically. - Direct edits to OSS — only where no overlay mechanism exists (routes, schedule, Vue components). Each block is wrapped in
# === LeadChat: <feature> (start/end) ===markers so future merge conflicts are mechanical. - Brand script — separate process that overwrites brand strings in upstream files even after they're updated. Listed in "Brand script targets" below so we can extend its rules as new strings surface.
Customization categories
A. Overlay modules (Custom::Leadchat::*)
| 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 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
| File | Change | Marker |
|---|---|---|
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) |
C. New top-level files (no upstream conflict potential)
| Path | Purpose |
|---|---|
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 (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 |
Wizard form: UPN entry + transport radio (3 presets: imap_smtp, imap_leadmail, graph_full) |
D. Brand script targets
Strings/files that the brand script rewrites away from "Chatwoot". Extend this list whenever a new "Chatwoot" mention surfaces in product UI.
app/javascript/dashboard/routes/dashboard/settings/inbox/components/SenderNameExamplePreview.vuelines 33, 43 — hardcodedbusinessName: 'Chatwoot'in sender-name preview tiles. (Discovered 2026-04-27 while building the Microsoft Shared Inbox feature; not yet handled by the brand script.)
Active feature work
- Microsoft Shared Inbox provider (Phase 1b in progress) — new
microsoft_sharedEmail 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
- Conventional Commits (
type(scope): subject) - No Claude attribution in commits or PR bodies
- Update this file as each customization lands, not as a final pass