Motivation / problem
The universal connector framework was born OAuth-shaped: click Connect → redirect to the provider → callback → ACTIVE. That covers Google Drive, Notion, OneDrive, Confluence and Jira, but it leaves out a huge class of sources that have no OAuth at all — an IMAP mailbox behind a host + port + username + password, an internal API behind a static key, an appliance behind basic auth. Before v8.17 the only way to wire such a source was a hand-written DB insert intoconnector_installations.config_json (the Fabric connector worked exactly this
way) — invisible in the panel, undocumented, and impossible for a non-engineer to
operate. v8.17 closes that gap with the first credential-based connector
(IMAP) and, more importantly, a generic mechanism any future
credential connector reuses unchanged.
The design rule (skills derive-from-db-not-literal, pluggable-pipeline-registry):
no if ($name === 'imap') anywhere in the host. The connector describes its
own form; the host renders it, validates it, splits it, and routes the secret to
the vault — all driven by that description.
Theory & background
OAuth and credential auth differ in where the secret comes from, not in what the installation lifecycle looks like. Both end at the sameconnector_installations
row flipping to ACTIVE; both reuse the connector’s existing
initiateOAuth() / handleOAuthCallback() contract. v8.17 therefore invents no
new connector method — it adds one optional capability interface that lets a
connector advertise a form, and one host endpoint that fills in the gap the OAuth
redirect used to fill.
The capability interface (shipped in padosoft/askmydocs-connector-base v1.2):
target that tells the host where its value belongs —
the load-bearing concept of the whole design:
A field also carries
type (text/number/password/select/checkbox), required,
options, a default, and a showIf conditional (“show only when another
field equals X”) so one schema describes both a basic-auth form and an
XOAUTH2 form.
Design
The host descriptor (ConnectorAdminController::index) adds two additive keys per
connector: auth_kind (oauth | credential) and, for credential connectors,
credential_form_schema. OAuth connectors keep auth_kind: 'oauth' and a null
schema — fully backward compatible.
ConfigureConnectorService is the generic core:
- Split the payload by
target. The secret is pulled out (never written toconfig_json);connectionfields nest underconfig_json['connection']; everything else is a top-levelconfig_jsonkey. Fields hidden by an unmetshowIfare skipped so they never pollute the row. - Upsert the single
(tenant_id, connector_name)row PENDING (R30 — every query is tenant-scoped). - basic-auth → mint the connector’s single-use OAuth state via
initiateOAuth(), then immediately replay it throughhandleOAuthCallback()with the secret (posted under its schema field name). The connector pings the server and, on success, vaults the secret → row flips ACTIVE. AConnectorAuthException(bad login) leaves the row PENDING witherror_jsonand surfaces as HTTP 422 — never a 200-with-empty-body (skillsurface-failures-loudly). - xoauth2 → persist PENDING and return the provider authorize URL; the browser
redirects and the unchanged
oauth/callbackroute finishes the flow.
Data model / contract
No schema change — credential connectors reuseconnector_installations
(config_json JSON + the connector_credentials vault row). The HTTP contract:
GET /api/admin/connectors + the PATCH response +
ConnectorInstallationResource) is additively extended with
folders.include + date_window_days (R27) — and only those two keys of
config_json; host/username/encryption stay private and the secret never leaves
the vault.
The IMAP connector’s schema spans three groups
(Authentication: auth_mode, xoauth2_provider, and the app-only trio
ms_tenant_id / ms_client_id / ms_client_secret; Server: host, port (993),
encryption (ssl/tls/starttls/none), validate_cert; Credentials: username,
password). Both password and ms_client_secret carry target: 'secret', but
their showIf conditions are mutually exclusive — only one is ever visible for
a given auth_mode. The host enforces a single secret per submission (a second
visible target: 'secret' field is rejected), so it always sees exactly one secret
and routes it to the vault, never config_json.
auth_mode now has three values:
Provider env (delegated XOAUTH2 only — basic-auth and app-only need none, since
app-only credentials are per-installation):
CONNECTOR_IMAP_GOOGLE_CLIENT_ID,
CONNECTOR_IMAP_GOOGLE_CLIENT_SECRET, CONNECTOR_IMAP_GOOGLE_REDIRECT_URI,
CONNECTOR_IMAP_MS_CLIENT_ID, CONNECTOR_IMAP_MS_CLIENT_SECRET,
CONNECTOR_IMAP_MS_REDIRECT_URI.
Microsoft 365 app-only (client credentials) — auth_mode: xoauth2_client_credentials
connector-imap v1.5 adds an unattended, service-principal flow for Exchange
Online behind the IMAP.AccessAsApp application permission — the modern
replacement for M365 IMAP basic-auth, with no interactive user sign-in. It is
the right choice when a customer’s IT team provisions an Entra app registration and
hands over static technical credentials rather than a person signing into the
mailbox.
Because it needs no provider redirect, it rides the same credential (replay)
path as basic-auth — the host’s ConfigureConnectorService routes every
non-xoauth2 auth mode there, so no host code changed to support it. Each
installation supplies its own Entra tenant + app: ms_tenant_id and ms_client_id
land in config_json; ms_client_secret is vaulted. The connector mints an
app-only token (grant_type=client_credentials, scope
https://outlook.office365.com/.default, tenant-specific endpoint), verifies it
with a live SASL-XOAUTH2 ping(), and re-mints from the stored secret on expiry
(the flow returns no refresh token).
Sysadmin runbook for the mailbox owner’s IT (the New-ServicePrincipal step is
mandatory and the one most often forgotten): enable IMAP on the mailbox → register
an Entra app → add the IMAP.AccessAsApp application permission + grant admin
consent → create a client secret → register the app’s service principal in Exchange
Online (New-ServicePrincipal -AppId <clientId> -ObjectId <enterpriseAppObjectId>)
→ scope it to the mailbox (Add-MailboxPermission … -AccessRights FullAccess, or
New-ApplicationAccessPolicy) → hand over Tenant ID, Client ID, Client Secret and
the mailbox email. Full step-by-step lives in the
connector-imap README.
Decision rationale (ADR-style)
- Reuse
initiateOAuth/handleOAuthCallback, invent no new connector method. The installation lifecycle is identical; only the secret source differs. A bespokehandleCredentials()method would fork every connector’s contract and the host’s flow for no behavioural gain. The basic-auth path simply replays the connector’s own single-use state synthetically. - Schema lives in the connector, not the host (Option A). With
connector-basev1.2 +connector-imapv1.2 on Packagist, the field schema is the connector’s responsibility — one source of truth, no host/package drift (skilldocs-match-code). The earlier “host-side schema map” fallback (Option B) was dropped. target-driven routing, not field-name magic. Routing by an explicit per-fieldtarget(rather than guessing from names) is what keeps the host generic: a future connector with anapi_tokensecret and abase_urlconnection field works with zero host changes.- The secret never touches
config_json. It is routed throughhandleOAuthCallbackstraight to the encrypted vault.config_json(which can surface host/username metadata) carries no credential, and theConnectorInstallationResourceomits it from the API entirely.
Worked example — activate an IMAP mailbox
composer require padosoft/askmydocs-connector-imap(auto-discovered).- As a super-admin, open
/app/admin/connectors→ the Email (IMAP) tile in Available sources carries the Add (+) button. - Click Add (+) → a modal renders from the schema. For app-password auth:
host = imap.example.com,port = 993,encryption = SSL/TLS,username = you@example.com,password = <app password>. - Connect → the BE logs in (a real IMAP ping), vaults the password, and the account appears Active in the Connections list. Bad credentials → an inline error, no connection is created, nothing is vaulted.
- For Gmail / Microsoft 365 choose OAuth2 in the form → the browser redirects to the provider and returns ACTIVE through the standard callback.
Connection settings — the folder picker (v8.24)
Superseded in v8.25. The folder picker below is the v8.24 design — host-side
discovery via
ImapFolderListingService + a folder-only FolderSettingsForm.
v8.25 generalises it: folder discovery moved into the connector via
SupportsFolderDiscovery (a generic ConnectorFolderListingService over the
registry — which also fixes XOAUTH2), and the modal became a schema-driven
ConnectionSettingsForm editing the connector’s full sync surface (include
and exclude lists, date window, filters, attachments, …). The GET …/folders endpoint and its 404/503/200 [] contract are unchanged. See
Connector sync settings & folder discovery and
ADR 0022. The description below is kept as the v8.24
historical record.config_json.folders.include — historically only settable by a
hand DB edit (or the test harness’s CLI re-merge). v8.24 promotes it to a
first-class “Folders” action on each credential account: a post-install
“connection settings” modal that lists the mailbox’s real folders and lets the
operator pick the sync whitelist plus the look-back window (date_window_days).
The picker is post-install by necessity — the live folder list only exists
after credentials verify, so it is not a credential-form field but a separate
read against the live account:
ImapFolderListingService is host-side by design: rather than bump the
connector package for a public lister, it reuses the connector’s existing public
seams — the bound ImapClientFactoryInterface, the OAuthCredentialVault secret
and the stored config_json.connection — to open a client and listMailboxes().
The paths it returns are exactly what folders.include whitelists, so a picked
value round-trips 1:1 (skill route-contracts-match-fe-shape).
Semantics surfaced in the UI:
- Empty selection = sync ALL non-excluded folders (the connector default —
Trash/Spam/Junk/
[Gmail]/Spam/[Gmail]/Trash). This is the both-states (R43) default every fresh account ships with. - A non-empty selection is a whitelist that BYPASSES those exclusions — the modal warns about this so an operator doesn’t silently start ingesting spam.
- A previously-saved folder that has since vanished from the server stays visible (checked, flagged “not found”) so a save never silently drops it.
config_json inside the existing
tenant-scoped lockForUpdate transaction (R21/R30): only folders.include and
date_window_days are overwritten, so connection / auth_mode / the default
folders.exclude all survive. Unreachable mailbox → 503, never an empty 200
(skill surface-failures-loudly).
Tri-surface (R44): the same config_json.folders.include is also writable
programmatically — the connector:imap:install CLI seeds it directly — and is
read back through the MCP ConnectorInstallationsTool; the HTTP picker is just the
human surface over the one core.
Gotchas & operations
can:manageConnectors(admin + super-admin) gatesconfigure— and the folder picker — like every other connector route; it touches credential vaults. Widened from super-admin-only in v8.24 so an admin can run the picker. Regression-locked in the R32 authorization matrix.- One installation = one mailbox. Multi-mailbox per installation is out of scope (a second installation row covers a second mailbox).
- Testing seam. The IMAP server is a backend TCP dependency, so Playwright
can’t stub it; E2E runs with
CONNECTOR_IMAP_FAKE_PING=true, an input-driven offline fake (host containinginvalid/fail→ login failure). Default-OFF — production always talks to the real server (skill on both-state flags, R43). - The mechanism is generic. Any future credential connector implements
SupportsCredentialForm; the same form, endpoint, validation and vaulting work unchanged — no host edit.
Universal connectors
The OAuth-based connector framework this builds on.
Multi-tenant isolation
The tenant scope every installation + credential is bound to.