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).
Durable full-history imports
See Durable IMAP full-history imports for the full architecture, lifecycle, HTTP/MCP/PHP contracts, recovery model and production runbook. Ingestion & Sync → Full mailbox import does not place an entire mailbox in one request or one queue payload. The backend snapshots each selected folder’sUIDVALIDITY and highest UID, divides its date range into durable windows, then
imports at most one bounded UID batch per job. A successful batch advances the
SQL checkpoint; completed windows are never scanned again. A terminal transport
or worker failure marks only its window failed; the pump skips that window and
continues through every remaining window. After all windows are terminal, any
failed window settles the campaign as failed and exposes Resume full import.
Resume reactivates the same campaign, preserves every last_uid, keeps completed
windows closed and requeues only incomplete windows. A new snapshot is created
only when UIDVALIDITY changed and the server invalidated the old UID namespace;
the UI labels that exceptional action Restart full import.
The same ImapBackfillManager core is exposed through the admin HTTP endpoints
and the write-capable MCP KbImapBackfillTool (action=status|start). Status is
tenant-scoped; an unknown installation is a 404/error, not a misleading empty
campaign. action=start starts, returns an active campaign, or resumes its latest
failed campaign according to the same recovery rules. The installation must be
active.
Production needs both a
connectors worker (or the configured queue name) and
kb-ingest workers: the former downloads/checkpoints mail, while the latter
performs conversion, chunking and embedding. The backfill client goes through
the same reconnect and Redis-backed per-mailbox lock decorators as folder
listing, test-fetch and incremental sync, so those surfaces cannot open a second
connection to the same account.
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.