Skip to content

Extract the Microsoft Graph adapter out of moox/mail-inbox #1

Description

@jbagsik

Problem Statement

moox/mail-inbox is advertised as a mail inbox for Laravel, but it is really a Microsoft Graph
package. GraphMailService spans 691 lines across 25 methods, all four jobs type-hint it
directly, seven Graph*Exception classes live inside the package, and the service provider
binds a single GraphServiceClient built from one flat set of credentials.

Three consequences follow, and all of them are felt by package consumers:

  1. You cannot use it without Microsoft 365. There is no seam at which an IMAP, POP3 or
    webhook-based source could be substituted. Anyone whose mail does not live in Exchange
    Online cannot adopt the package at all.
  2. The coupling leaks into consuming packages. moox/e-billing type-hints
    GraphMailService itself, in a job and in a service. A package about invoices therefore has
    a hard dependency on a Microsoft SDK, and any consumer of moox/e-billing inherits it.
  3. Only one mailbox, only one app registration. Credentials come from flat config keys and
    the client is a singleton. The package already carries a scope concept through commands,
    jobs, InboxMessage and mail_inbox_sync_states, but scope only partitions data — it
    selects no account. Multi-mailbox operation is half-wired and cannot be finished from
    configuration.

There is also no test suite. moox/mail-inbox ships no tests/ directory and no PHPUnit or
Pest configuration, so none of the above can be changed with confidence today.

Solution

Extract everything Microsoft-specific into a new package, moox/msgraph, and leave
moox/mail-inbox as a transport-neutral inbox package that talks to a driver contract it owns
itself.

From a consumer's point of view:

  • Requiring moox/mail-inbox no longer pulls in a Microsoft SDK.
  • A mailbox is configured by naming a driver and a connection, so a second provider is a
    configuration change rather than a fork.
  • Several mailboxes can be served from several app registrations.
  • moox/e-billing (and any other consumer) stops referencing Graph types entirely.

moox/msgraph is deliberately an adapter package: Graph authentication, a connection
registry, an inbox driver and a mail transport. It ships no model, no migration and
no Filament surface.

User Stories

  1. As a Laravel developer whose mail lives in Exchange Online, I want to require moox/msgraph
    alongside moox/mail-inbox, so that the Graph dependency is explicit in my own
    composer.json rather than hidden inside an inbox package.
  2. As a Laravel developer whose mail lives on an IMAP server, I want to require
    moox/mail-inbox without any Microsoft dependency, so that I can adopt the package at all.
  3. As a package consumer, I want moox/mail-inbox to declare a documented driver contract, so
    that I can write my own driver for a provider nobody has implemented yet.
  4. As a developer running several mailboxes, I want to define each mailbox with its own driver
    and connection, so that one application can poll more than one address.
  5. As a developer whose mailboxes live under different tenants, I want to define several
    connections, so that mailboxes are not forced to share one app registration.
  6. As a developer, I want a mailbox's role to follow from which configuration file it appears
    in, so that I do not have to keep a redundant direction flag in sync.
  7. As a developer, I want credentials to live in exactly one place per provider, so that adding
    a sending mailbox does not duplicate a client secret.
  8. As an operator, I want the same mailbox to be usable for both receiving and sending, so that
    replies and non-delivery reports return to an address the application already polls.
  9. As a developer integrating a pipeline on top of the inbox, I want to report the outcome of
    a message rather than a destination folder, so that my code carries no assumptions about
    mailbox mechanics.
  10. As a developer, I want an explicit Ignored outcome distinct from Failed, so that
    messages my pipeline recognises and deliberately skips are not indistinguishable from
    errors.
  11. As a developer writing a driver for a provider without folders, I want the contract to be
    satisfiable, so that a folderless provider is not excluded by the interface itself.
  12. As a maintainer, I want the sync cursor to be opaque to the inbox package, so that a driver
    can store whatever resumption token its protocol uses.
  13. As an operator upgrading an existing installation, I want existing sync state rows to stay
    valid, so that the upgrade does not force a full mailbox re-scan.
  14. As a maintainer, I want moox/mail-inbox to have a test suite for the first time, so that
    the pipeline can be changed without manual verification against a live mailbox.
  15. As a contributor, I want to test the whole inbox pipeline with an in-memory driver, so that
    running the test suite needs no network access and no Microsoft 365 tenant.
  16. As a maintainer of moox/msgraph, I want the Graph request shaping tested against a mocked
    HTTP handler, so that header and request-body behaviour is verified without a tenant.
  17. As an operator, I want stable mailbox item identifiers to keep working after the extraction,
    so that idempotency does not silently regress.
  18. As a consumer of moox/e-billing, I want it to stop depending on a Graph client, so that my
    invoice pipeline is not tied to one mail provider.
  19. As a developer, I want folder names to be configured where folders exist, so that a package
    that knows nothing about mailboxes stops holding mailbox configuration.
  20. As a maintainer, I want moox/msgraph namespaced by area, so that non-mail Graph
    capabilities can be added later without a breaking rename.
  21. As a package consumer, I want moox/msgraph to ship no models, migrations or admin screens,
    so that installing an adapter does not add tables or menu entries to my application.
  22. As an operator, I want the extraction to produce byte-for-byte identical mailbox behaviour,
    so that a working installation keeps working.

Implementation Decisions

Package split. Three packages, with dependencies pointing inward at contracts only:

Package Owns
moox/msgraph Graph authentication, connection registry, inbox driver, mail transport. No model, no migration, no Filament surface.
moox/mail-inbox Models, migrations, jobs, commands. Defines the inbox driver contract. Has no knowledge of moox/msgraph. It ships no Filament surface today — see Out of Scope.
moox/mail-outbox Outbound counterpart, specified separately.

Contracts live in the domain package, not in a shared contracts package. moox/mail-inbox
defines the driver interface and moox/msgraph implements it. A separate moox/mail-contracts
package was rejected: it would add a package to version for no gain.

Namespace Moox\MsGraph, grouped internally by area (mail under its own sub-namespace) so
that calendar, users or file capabilities can be added later without renaming existing classes.

Two-tier configuration. The msgraph config owns connections — one entry per app
registration, holding tenant, client id and secret. The mail-inbox config owns mailboxes
each naming a driver, referencing a connection by name, and giving the address. Resolution
goes through a driver manager registered in the container, so moox/mail-inbox only ever
handles strings and an interface. This deliberately mirrors how Laravel's filesystem disks and
mailers are configured.

There is no direction field on a mailbox. A mailbox's role follows from which configuration
file lists it; a mailbox listed in both the inbox and the outbox configuration both receives
and sends.

Driver contract: semantic outcomes, not folder operations. The contract exposes claiming a
message for processing and settling it with an outcome of Processed, Failed or Ignored.
What settling means is the driver's business — the Graph driver moves messages between
folders, an IMAP driver could flag or move, a webhook-based driver would do nothing.

This shape follows the existing call sites rather than inventing one. Six of the seven current
call sites already pass an external id and a success boolean, i.e. they express an outcome, not
a destination. Only the foreign-document filter names a folder literally, and Ignored covers
it. A folder-based contract was rejected because it would make folders a mandatory concept for
every driver; a capability-flag approach (supports('folders')) was rejected because it pushes
branching out to every caller, which is exactly the complexity a driver should absorb.

Folder names move to the msgraph configuration. Consumers that today configure a folder
name to route irrelevant documents into instead express relevance as an outcome. Folder naming
is a mailbox concern.

Opaque sync cursor. The sync-state table keeps its rows and gains an additive driver
column. The stored cursor value stays meaningless to moox/mail-inbox; only the driver
interprets it. Existing rows remain valid with a backfill of the new column, so no re-scan is
required. Rejected alternatives: a per-driver state table (the inbox would have to know who
holds state, and every provider would need a table for the same purpose), and no persistent
cursor at all (Graph requires a durable delta link across process boundaries regardless, so it
would only become less visible).

Client construction moves with its middleware. The Graph client factory currently installs
a Guzzle middleware that requests immutable identifiers on every call. This middleware must move
into moox/msgraph together with the factory. Losing it would let stored external identifiers
change when messages move between folders, silently breaking the package's two-key idempotency.
This is the single highest-risk detail in the extraction.

Consumer adaptation is part of this change, not a follow-up. moox/e-billing type-hints the
Graph service in two places; both become the driver contract in the same change. Without it the
extraction merely relocates the coupling.

Migration strategy: no compatibility layer. Config keys and class names change without
aliases or deprecation shims. The packages have no external consumers on the affected surface,
and the only live state — the sync cursor — survives the additive migration.

Testing Decisions

What a good test looks like here. Tests assert observable behaviour at the package boundary:
given messages available from a driver, what rows exist, what statuses they hold, which outcome
was settled, and which jobs were dispatched. Tests must not assert that a particular service
method was called, nor reach into Graph request internals from the inbox package's tests.

One new seam, at the highest available point: the inbox driver contract.

moox/mail-inbox currently has no tests at all, so this extraction is also the package's first
testability. An in-memory fake driver — returning a scripted set of messages and recording the
outcomes settled against it — allows the entire pipeline (persistence, deduplication, attachment
handling, status transitions, job orchestration) to be tested with no network access and no
Microsoft 365 tenant. Every inbox behaviour under test goes through this one seam.

No other seam is introduced into moox/mail-inbox. In particular there is no test double for
individual services; the driver is the only substitution point.

moox/msgraph is tested one layer lower, against a mocked HTTP handler stack. The driver's
own tests assert request shaping and response handling — including that the immutable-identifier
header is present on every request, which is the regression this extraction most needs guarded.
This is the natural seam because the package's entire job is turning contract calls into HTTP
requests.

Modules covered.

  • moox/mail-inbox: message persistence and deduplication, attachment storage, status
    transitions, outcome settlement, command behaviour. Via the fake driver.
  • moox/msgraph: request construction, header middleware, pagination and cursor handling,
    error mapping. Via mocked HTTP.
  • Consumer adaptation: the affected consumer's existing suite must pass unchanged apart from
    swapping the type it depends on.

Prior art. moox/e-billing is the reference for test layout in this package family: Pest
with a TestCase and a container-only ContainerTestCase, Feature and Unit directories, and
shared fixture builders under a Support directory. Both packages here should follow that
structure rather than inventing a new one.

Equivalence check beyond the unit suite. Because the extraction must not change behaviour,
the acceptance bar is that an existing installation produces identical delta synchronisation,
identical folder movements and identical status transitions before and after. This is verified
against a real mailbox as a release step, not in CI.

Out of Scope

  • The outbound counterpart (moox/mail-outbox) and the Graph mail transport's send path, beyond
    making sure the connection registry can carry them. Specified separately.
  • Any second inbox driver. This change makes IMAP, POP3 or webhook drivers possible; it does
    not implement one.
  • Non-mail Graph capabilities (calendar, users, files). The namespace leaves room for them; the
    package does not add them.
  • Filament surfaces in moox/msgraph. It has no data of its own to display.
  • A Filament surface for moox/mail-inbox. The package has messages, attachments and per-scope
    sync state in the database and no way to look at any of it — an operator today reads log files.
    That is a genuine gap, but it is a feature rather than part of this extraction, and it is
    tracked separately. Nothing in this spec should be read as delivering it.
  • Retrofitting a full test suite for behaviour unrelated to the extraction. The fake driver is
    introduced and used to cover the pipeline paths this change touches; broader coverage is
    welcome but is not a precondition.
  • Reworking how attachments are stored or parsed.

Further Notes

Why the extraction comes before any new feature. A running installation is the only test
oracle for behavioural equivalence, and it exists today. Building the outbound side first would
mean verifying the riskier half (delta cursor, folder lifecycle, live sync state) against a
suite that does not exist yet.

Rejected package shapes, recorded so they are not re-proposed.

  • One package containing everything Graph-related plus the inbox domain. Cheaper today, but a
    second provider package would need the domain again — duplicate it or depend on the Graph
    package. The test is whether a consuming package can require the domain package without the
    provider; with one package it cannot.
  • A thin Graph package holding only authentication and the client, with mailbox logic staying
    in the inbox package. Smallest change, but the inbox package would remain provider-bound,
    which is the problem being solved.
  • Merging inbound and outbound into a single mail package. Receiving (polling, cursor, folder
    lifecycle, attachment extraction) and sending (template, recipients, delivery state) share
    almost nothing; one model would distort both.
  • Deleting the inbox package and implementing everything in the provider package. Costs a
    consumer migration across roughly nineteen files including a foreign key, and gives up exactly
    the provider independence that motivates the work.

Scale context for the driver contract. Installations polling a busy mailbox process
hundreds of messages per day, and the initial catch-up spans multiple polls. The contract must
therefore keep the existing page-limited, resumable fetch behaviour rather than assuming a
single pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    ready-for-agentSpec is ready for an agent to implement

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions