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:
- 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.
- 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.
- 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- As an operator, I want stable mailbox item identifiers to keep working after the extraction,
so that idempotency does not silently regress.
- 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.
- 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.
- As a maintainer, I want
moox/msgraph namespaced by area, so that non-mail Graph
capabilities can be added later without a breaking rename.
- 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.
- 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.
Problem Statement
moox/mail-inboxis advertised as a mail inbox for Laravel, but it is really a Microsoft Graphpackage.
GraphMailServicespans 691 lines across 25 methods, all four jobs type-hint itdirectly, seven
Graph*Exceptionclasses live inside the package, and the service providerbinds a single
GraphServiceClientbuilt from one flat set of credentials.Three consequences follow, and all of them are felt by package consumers:
webhook-based source could be substituted. Anyone whose mail does not live in Exchange
Online cannot adopt the package at all.
moox/e-billingtype-hintsGraphMailServiceitself, in a job and in a service. A package about invoices therefore hasa hard dependency on a Microsoft SDK, and any consumer of
moox/e-billinginherits it.the client is a singleton. The package already carries a
scopeconcept through commands,jobs,
InboxMessageandmail_inbox_sync_states, butscopeonly partitions data — itselects no account. Multi-mailbox operation is half-wired and cannot be finished from
configuration.
There is also no test suite.
moox/mail-inboxships notests/directory and no PHPUnit orPest configuration, so none of the above can be changed with confidence today.
Solution
Extract everything Microsoft-specific into a new package,
moox/msgraph, and leavemoox/mail-inboxas a transport-neutral inbox package that talks to a driver contract it ownsitself.
From a consumer's point of view:
moox/mail-inboxno longer pulls in a Microsoft SDK.configuration change rather than a fork.
moox/e-billing(and any other consumer) stops referencing Graph types entirely.moox/msgraphis deliberately an adapter package: Graph authentication, a connectionregistry, an inbox driver and a mail transport. It ships no model, no migration and
no Filament surface.
User Stories
moox/msgraphalongside
moox/mail-inbox, so that the Graph dependency is explicit in my owncomposer.jsonrather than hidden inside an inbox package.moox/mail-inboxwithout any Microsoft dependency, so that I can adopt the package at all.moox/mail-inboxto declare a documented driver contract, sothat I can write my own driver for a provider nobody has implemented yet.
and connection, so that one application can poll more than one address.
connections, so that mailboxes are not forced to share one app registration.
in, so that I do not have to keep a redundant direction flag in sync.
a sending mailbox does not duplicate a client secret.
replies and non-delivery reports return to an address the application already polls.
a message rather than a destination folder, so that my code carries no assumptions about
mailbox mechanics.
Ignoredoutcome distinct fromFailed, so thatmessages my pipeline recognises and deliberately skips are not indistinguishable from
errors.
satisfiable, so that a folderless provider is not excluded by the interface itself.
can store whatever resumption token its protocol uses.
valid, so that the upgrade does not force a full mailbox re-scan.
moox/mail-inboxto have a test suite for the first time, so thatthe pipeline can be changed without manual verification against a live mailbox.
running the test suite needs no network access and no Microsoft 365 tenant.
moox/msgraph, I want the Graph request shaping tested against a mockedHTTP handler, so that header and request-body behaviour is verified without a tenant.
so that idempotency does not silently regress.
moox/e-billing, I want it to stop depending on a Graph client, so that myinvoice pipeline is not tied to one mail provider.
that knows nothing about mailboxes stops holding mailbox configuration.
moox/msgraphnamespaced by area, so that non-mail Graphcapabilities can be added later without a breaking rename.
moox/msgraphto ship no models, migrations or admin screens,so that installing an adapter does not add tables or menu entries to my application.
so that a working installation keeps working.
Implementation Decisions
Package split. Three packages, with dependencies pointing inward at contracts only:
moox/msgraphmoox/mail-inboxmoox/msgraph. It ships no Filament surface today — see Out of Scope.moox/mail-outboxContracts live in the domain package, not in a shared contracts package.
moox/mail-inboxdefines the driver interface and
moox/msgraphimplements it. A separatemoox/mail-contractspackage 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) sothat 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-inboxonly everhandles 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,FailedorIgnored.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
Ignoredcoversit. 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 pushesbranching 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
drivercolumn. The stored cursor value stays meaningless to
moox/mail-inbox; only the driverinterprets 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/msgraphtogether with the factory. Losing it would let stored external identifierschange 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-billingtype-hints theGraph 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-inboxcurrently has no tests at all, so this extraction is also the package's firsttestability. 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 forindividual services; the driver is the only substitution point.
moox/msgraphis tested one layer lower, against a mocked HTTP handler stack. The driver'sown 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, statustransitions, outcome settlement, command behaviour. Via the fake driver.
moox/msgraph: request construction, header middleware, pagination and cursor handling,error mapping. Via mocked HTTP.
swapping the type it depends on.
Prior art.
moox/e-billingis the reference for test layout in this package family: Pestwith a
TestCaseand a container-onlyContainerTestCase,FeatureandUnitdirectories, andshared fixture builders under a
Supportdirectory. Both packages here should follow thatstructure 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
moox/mail-outbox) and the Graph mail transport's send path, beyondmaking sure the connection registry can carry them. Specified separately.
not implement one.
package does not add them.
moox/msgraph. It has no data of its own to display.moox/mail-inbox. The package has messages, attachments and per-scopesync 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.
introduced and used to cover the pipeline paths this change touches; broader coverage is
welcome but is not a precondition.
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.
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.
in the inbox package. Smallest change, but the inbox package would remain provider-bound,
which is the problem being solved.
lifecycle, attachment extraction) and sending (template, recipients, delivery state) share
almost nothing; one model would distort both.
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.