Problem Statement
moox/mail-inbox gives a Laravel application a way to receive and process mail. There is no counterpart for sending it, and Laravel's own mailer stops short of what an application sending business documents needs:
- No record of what was sent.
Mail::send() succeeds or throws. Afterwards there is no queryable answer to "did this document go out, when, to whom, and did anything come back" — which is exactly the question an operator gets asked first.
- Test mode is unsafe for documents that matter. Laravel's global recipient override
silently rewrites recipients. An application can therefore record a document as sent to a
customer when it actually went to a test address, and nothing in the data shows it. For
invoices, order confirmations or dunning letters that is a wrong record, not a harmless
convenience.
- No send pacing. Providers rate-limit per mailbox. An application that queues several
hundred documents and flushes them at once will hit those limits, and at least one major
provider's documentation is internally inconsistent about what happens when you do — so the
server cannot be relied on to pace the sender.
Solution
A new package, moox/mail-outbox, that sits alongside Laravel's mailer rather than replacing
it, and adds the three things above:
- a send log with a Filament surface: recipients (intended and actual), subject,
timestamp, status, error, message id, and a reference to the business object the mail belongs
to, with actions to resend and to inspect the raw message;
- a safe test mode: recipient redirection with an allowlist, a subject prefix, a warning when
test mode is active in production, and — the important part — a redirected mail must not be
recorded as delivered;
- send windows and throttling: quiet periods, working-day windows and a client-side rate
limit.
The package defines no transport contract. Symfony Mailer already is the transport
abstraction — several of its official transports speak HTTP APIs rather than SMTP — so mailboxes
are named Laravel mailers and the provider is a configuration choice.
User Stories
- As an operator, I want a list of every mail the application sent, so that I can answer
"did it go out?" without reading logs.
- As an operator, I want to see the recipient a mail was actually delivered to as well as the
one it was addressed to, so that a redirected test mail cannot be mistaken for a real one.
- As an operator, I want to see why a send failed, so that I can decide between retrying and
correcting the data.
- As an operator, I want to resend a logged mail, so that a transient failure does not require
re-running the process that produced it.
- As an operator, I want to inspect the raw message of a logged mail, so that I can diagnose a
rendering or attachment problem after the fact.
- As an operator, I want each logged mail linked to the business object it concerns, so that I
can navigate from a document to its send history.
- As a developer, I want mails sent by other packages to appear in the log, so that the log
is a complete picture and those packages need no knowledge of this one.
- As a developer, I want to send through a named mailer, so that different mails can leave from
different mailboxes.
- As a developer, I want to switch mail provider by configuration, so that changing provider
does not mean changing code.
- As a developer testing locally, I want every outgoing mail redirected to one address, so that
I can see real rendering and real attachments in a real mail client.
- As a developer, I want internal recipients to be delivered for real while external ones are
redirected, so that an approval workflow can be rehearsed end to end without reaching a
customer.
- As a developer, I want a redirected mail to be visibly marked in its subject, so that nobody
mistakes a test mail for a live one.
- As an operator, I want a warning when test mode is enabled in production, so that a
misconfiguration is noticed before customers are affected.
- As a domain developer, I want a redirected mail to leave the business object not marked as
delivered, so that test environments do not produce records that claim delivery that never
happened.
- As an operator, I want to define quiet periods during which no mail leaves, so that
automated mail does not arrive at unwanted hours.
- As an operator, I want to define send windows by working day and time of day, so that the
schedule is expressible in terms the business understands.
- As an operator sending a large batch, I want the package to pace sends below the provider's
rate limit, so that a burst does not get throttled or dropped.
- As a developer, I want an oversized message to fail with a clear error before it reaches the
transport, so that I do not have to diagnose an opaque protocol-level rejection.
- As a developer, I want the send log to record the message id, so that later replies and
non-delivery reports can be correlated back to the original send.
- As a maintainer, I want the package to hold a send log and not an archive, so that
retention, immutability and deletion policy remain a separate, swappable concern.
- As a developer building an archive, I want a documented event fired on send, so that I can
dispatch my own job from it without modifying this package.
- As a maintainer, I want every unit of work to be a queued job reporting progress, so that
long batches are observable in the queue monitor and no work hides inside a listener.
- As a contributor, I want the package's tests to run with no network access, so that the suite
is fast and deterministic.
Implementation Decisions
No transport contract. The package uses Laravel's mailer; a mailbox is a named mailer.
Symmetry with the inbound package would be false symmetry — the framework provides a transport
abstraction for sending and none for receiving, so only the receiving side needs one.
What this buys, beyond provider independence: queueing, retry and backoff from the framework's
queued-mail support; and framework fakes for testing.
Work happens in queued jobs, never in listeners. This package family's convention is that
control flow is a job dispatching the next job, events only announce facts, and a listener is
allowed to do exactly one thing: dispatch a job. Sending is therefore a queued job that performs
the send and writes the log row.
The load-bearing benefit of the framework's mail events is that mail sent by other packages can
still be logged: a dispatch-only listener on the framework's post-send event dispatches a
recording job. Those packages therefore need no dependency on this one, and a future archive
package attaches to the same event with its own dispatch-only listener.
All queued jobs in this package report progress using the shared job-progress trait from
moox/jobs, so long-running batches are visible in the queue monitor. Failure handling uses each
job's failed() hook rather than a separate failure job.
Transport selection is a host concern. For Microsoft 365 there is a first-party Symfony
bridge that authenticates app-only via client credentials and takes the mailbox from the
envelope sender, which means several mailboxes across several app registrations are expressed as
several DSNs. A companion adapter package may generate those DSNs from its own connection
registry so credentials are defined once. This package requires none of it.
Structured payloads versus raw MIME is a transport question, not a package question. Because
there is no transport contract, swapping a structured-JSON transport for one that hands the
provider fully rendered MIME is a configuration change plus one class. Documented triggers for
wanting raw MIME: an archive that must hold the exact bytes sent; S/MIME signing (impossible when
the provider re-serialises the message); suppressing automatic replies via headers a structured
API will not accept; and correlating non-delivery reports by a self-assigned message id. The
trade is not one-directional — raw MIME is subject to a hard request-size ceiling with no
resumable-upload escape, whereas structured payloads can reach larger attachments by other
means.
Audit identity is the RFC 5322 message id. Symfony generates it while rendering, so the
application knows it before the request is made, on any transport. Unlike provider-side store
handles it survives archiving, export and re-import, because it is part of the message rather
than a pointer into a mailbox. Provider-side identifiers are recorded when a transport offers
one, but nothing depends on them.
Send log schema. One row per message: mailer, intended recipients, actual recipients,
subject, status, attempt count, error, message id, provider reference where
available, timestamps, and a polymorphic reference to the related business object. Status covers
queued, sent, failed and suppressed — the last for mail withheld by a send window or by policy.
Test mode. Built on the framework's global recipient override so there is one redirection
mechanism rather than two competing ones, with additions: both recipient sets recorded, a
subject prefix naming the original recipient, an allowlist of patterns delivered for real, a
boot-time warning when test mode coincides with a production environment, and a documented rule
that a redirected send must not be reported as delivered to the domain. That last rule is the
reason test mode is package behaviour and not merely configuration.
Send windows and throttling live here. Quiet periods, working-day windows and a per-mailer
rate limit are properties of sending. Configuration is expressed as times of day and weekdays
rather than intervals, because that is the vocabulary the people who set the policy use.
Throttling is client-side by necessity: at least one major provider's documentation is
internally inconsistent about the enforcement of its per-minute message rate, so the server's
behaviour on breach cannot be relied upon.
Size guard before the transport. The package checks rendered size against a configured
ceiling and fails with a domain error, rather than letting the provider reject the request and
surfacing an opaque transport exception.
Send log, not archive. A provider's sent-items copy proves the provider accepted the
message; it is deletable, mutable and in someone else's custody. An archive proves that this
application sent this document, unalterably, over a retention period. Those are different
claims and only the first is in scope. An archive package attaches to the post-send event.
Testing Decisions
What a good test looks like here. Tests assert what an operator or a consuming package can
observe: which mails were queued or sent, to which recipients, with which subject,
what the send log contains afterwards, and whether the domain was told delivery happened. Tests
must not assert that a particular internal method ran, nor depend on the concrete transport.
Zero new seams. Three existing ones, all provided by the framework.
- The mail fake. The framework's mail fake is the highest available seam and covers most
behaviour: which Mailable was sent, to whom, with what payload.
- The send and sending events, plus the queue fake. Because logging foreign mail is a
dispatch-only listener, its tests dispatch the framework event and assert that the recording
job was queued — then run that job and assert the resulting row. This tests the log without
sending anything and proves the "other packages get logged for free" property directly, while
keeping the assertion at the job boundary rather than inside a listener.
- An in-memory transport. For end-to-end rendering — including attachments and headers —
the array transport captures fully rendered messages with no network access.
Introducing a package-owned transport interface purely to enable testing was rejected: it would
add a seam the framework already provides, and every test written against it would be a test of
our abstraction rather than of sending.
Modules covered.
- Send log: row creation for queued, sent, failed and suppressed; both recipient sets recorded;
message id captured; business-object reference resolved.
- Test mode: redirection applied, allowlist honoured, subject prefixed, both recipient sets
recorded, and — asserted explicitly — the domain not marked as delivered.
- Send windows and throttling: mail outside a window is suppressed rather than dropped, and
released afterwards; a batch larger than the configured rate is paced.
- Size guard: an oversized message fails before the transport is invoked.
Prior art. moox/e-billing is the reference for structure 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. Follow that layout. Note that
moox/mail-inbox currently has no test suite, so it is not prior art — do not mirror it.
Out of Scope
- Archiving, retention and immutability. Deliberately a separate package. This one fires the
event it would consume.
- Inbound mail, including parsing non-delivery reports and correlating them to sent mail.
That needs an inbox and belongs with the inbound package; this spec only guarantees that the
message id needed for correlation is recorded.
- A raw-MIME transport. Documented as a future option with explicit triggers; not built here.
- Any provider adapter. Transports come from Symfony, from a companion adapter package, or
from the host.
- Concrete mail content. This package ships the engine. Actual mail wording, branding and
recipients belong to the host application, which is also where any organisation-specific
escalation or approval policy lives.
- A generic reminder or escalation engine. Deciding whether there is something to say is a
domain concern; this package decides only when a mail may leave.
- Bounce-driven status updates, for the same reason as inbound mail.
- Templating (registry, typed payloads, template text, placeholder syntax, visual shell /
inlining) — lives in moox/template and moox/mjml (see ADR-0017 in the host repo).
- The visual shell and its build. Header, footer, surrounding layout and client-safe HTML
inlining belong to moox/mjml, not here. This package consumes a finished Mailable; it owns
neither the styling nor a build step for it.
Further Notes
The boundary that keeps this package generic, stated once because it is easy to erode:
The outbox decides when a mail may leave. The domain decides whether there is anything to
say.
Send windows, quiet periods and throttling are therefore in scope. Escalation ladders, reminder
schedules tied to business state, and approval rules are not — they need knowledge of deadlines,
substitutes and document status that a mail package must not acquire.
Rejected alternatives, recorded so they are not re-proposed.
- A package-owned transport contract. Re-implements MIME construction, attachments, inline
images and header handling that Symfony already provides, and makes "works over SMTP too" cost
an adapter the framework ships.
- Depending on a community Microsoft Graph mailer package. The ones surveyed were pinned below
current framework versions, supported a single tenant and mailbox, and pulled in a second HTTP
stack. Worth reading, not worth depending on.
- Building archiving into this package. Makes retention policy unswappable and makes the
archive problem harder rather than easier.
- No send log, relying on a future archive for traceability. Leaves operators blind to
operational questions an archive does not answer.
- No test mode, using a log or catch-all mail transport instead. Neither shows rendered layout
and attachments in a real client, which is the point.
Problem Statement
moox/mail-inboxgives a Laravel application a way to receive and process mail. There is no counterpart for sending it, and Laravel's own mailer stops short of what an application sending business documents needs:Mail::send()succeeds or throws. Afterwards there is no queryable answer to "did this document go out, when, to whom, and did anything come back" — which is exactly the question an operator gets asked first.silently rewrites recipients. An application can therefore record a document as sent to a
customer when it actually went to a test address, and nothing in the data shows it. For
invoices, order confirmations or dunning letters that is a wrong record, not a harmless
convenience.
hundred documents and flushes them at once will hit those limits, and at least one major
provider's documentation is internally inconsistent about what happens when you do — so the
server cannot be relied on to pace the sender.
Solution
A new package,
moox/mail-outbox, that sits alongside Laravel's mailer rather than replacingit, and adds the three things above:
timestamp, status, error, message id, and a reference to the business object the mail belongs
to, with actions to resend and to inspect the raw message;
test mode is active in production, and — the important part — a redirected mail must not be
recorded as delivered;
limit.
The package defines no transport contract. Symfony Mailer already is the transport
abstraction — several of its official transports speak HTTP APIs rather than SMTP — so mailboxes
are named Laravel mailers and the provider is a configuration choice.
User Stories
"did it go out?" without reading logs.
one it was addressed to, so that a redirected test mail cannot be mistaken for a real one.
correcting the data.
re-running the process that produced it.
rendering or attachment problem after the fact.
can navigate from a document to its send history.
is a complete picture and those packages need no knowledge of this one.
different mailboxes.
does not mean changing code.
I can see real rendering and real attachments in a real mail client.
redirected, so that an approval workflow can be rehearsed end to end without reaching a
customer.
mistakes a test mail for a live one.
misconfiguration is noticed before customers are affected.
delivered, so that test environments do not produce records that claim delivery that never
happened.
automated mail does not arrive at unwanted hours.
schedule is expressible in terms the business understands.
rate limit, so that a burst does not get throttled or dropped.
transport, so that I do not have to diagnose an opaque protocol-level rejection.
non-delivery reports can be correlated back to the original send.
retention, immutability and deletion policy remain a separate, swappable concern.
dispatch my own job from it without modifying this package.
long batches are observable in the queue monitor and no work hides inside a listener.
is fast and deterministic.
Implementation Decisions
No transport contract. The package uses Laravel's mailer; a mailbox is a named mailer.
Symmetry with the inbound package would be false symmetry — the framework provides a transport
abstraction for sending and none for receiving, so only the receiving side needs one.
What this buys, beyond provider independence: queueing, retry and backoff from the framework's
queued-mail support; and framework fakes for testing.
Work happens in queued jobs, never in listeners. This package family's convention is that
control flow is a job dispatching the next job, events only announce facts, and a listener is
allowed to do exactly one thing: dispatch a job. Sending is therefore a queued job that performs
the send and writes the log row.
The load-bearing benefit of the framework's mail events is that mail sent by other packages can
still be logged: a dispatch-only listener on the framework's post-send event dispatches a
recording job. Those packages therefore need no dependency on this one, and a future archive
package attaches to the same event with its own dispatch-only listener.
All queued jobs in this package report progress using the shared job-progress trait from
moox/jobs, so long-running batches are visible in the queue monitor. Failure handling uses eachjob's
failed()hook rather than a separate failure job.Transport selection is a host concern. For Microsoft 365 there is a first-party Symfony
bridge that authenticates app-only via client credentials and takes the mailbox from the
envelope sender, which means several mailboxes across several app registrations are expressed as
several DSNs. A companion adapter package may generate those DSNs from its own connection
registry so credentials are defined once. This package requires none of it.
Structured payloads versus raw MIME is a transport question, not a package question. Because
there is no transport contract, swapping a structured-JSON transport for one that hands the
provider fully rendered MIME is a configuration change plus one class. Documented triggers for
wanting raw MIME: an archive that must hold the exact bytes sent; S/MIME signing (impossible when
the provider re-serialises the message); suppressing automatic replies via headers a structured
API will not accept; and correlating non-delivery reports by a self-assigned message id. The
trade is not one-directional — raw MIME is subject to a hard request-size ceiling with no
resumable-upload escape, whereas structured payloads can reach larger attachments by other
means.
Audit identity is the RFC 5322 message id. Symfony generates it while rendering, so the
application knows it before the request is made, on any transport. Unlike provider-side store
handles it survives archiving, export and re-import, because it is part of the message rather
than a pointer into a mailbox. Provider-side identifiers are recorded when a transport offers
one, but nothing depends on them.
Send log schema. One row per message: mailer, intended recipients, actual recipients,
subject, status, attempt count, error, message id, provider reference where
available, timestamps, and a polymorphic reference to the related business object. Status covers
queued, sent, failed and suppressed — the last for mail withheld by a send window or by policy.
Test mode. Built on the framework's global recipient override so there is one redirection
mechanism rather than two competing ones, with additions: both recipient sets recorded, a
subject prefix naming the original recipient, an allowlist of patterns delivered for real, a
boot-time warning when test mode coincides with a production environment, and a documented rule
that a redirected send must not be reported as delivered to the domain. That last rule is the
reason test mode is package behaviour and not merely configuration.
Send windows and throttling live here. Quiet periods, working-day windows and a per-mailer
rate limit are properties of sending. Configuration is expressed as times of day and weekdays
rather than intervals, because that is the vocabulary the people who set the policy use.
Throttling is client-side by necessity: at least one major provider's documentation is
internally inconsistent about the enforcement of its per-minute message rate, so the server's
behaviour on breach cannot be relied upon.
Size guard before the transport. The package checks rendered size against a configured
ceiling and fails with a domain error, rather than letting the provider reject the request and
surfacing an opaque transport exception.
Send log, not archive. A provider's sent-items copy proves the provider accepted the
message; it is deletable, mutable and in someone else's custody. An archive proves that this
application sent this document, unalterably, over a retention period. Those are different
claims and only the first is in scope. An archive package attaches to the post-send event.
Testing Decisions
What a good test looks like here. Tests assert what an operator or a consuming package can
observe: which mails were queued or sent, to which recipients, with which subject,
what the send log contains afterwards, and whether the domain was told delivery happened. Tests
must not assert that a particular internal method ran, nor depend on the concrete transport.
Zero new seams. Three existing ones, all provided by the framework.
behaviour: which Mailable was sent, to whom, with what payload.
dispatch-only listener, its tests dispatch the framework event and assert that the recording
job was queued — then run that job and assert the resulting row. This tests the log without
sending anything and proves the "other packages get logged for free" property directly, while
keeping the assertion at the job boundary rather than inside a listener.
the array transport captures fully rendered messages with no network access.
Introducing a package-owned transport interface purely to enable testing was rejected: it would
add a seam the framework already provides, and every test written against it would be a test of
our abstraction rather than of sending.
Modules covered.
message id captured; business-object reference resolved.
recorded, and — asserted explicitly — the domain not marked as delivered.
released afterwards; a batch larger than the configured rate is paced.
Prior art.
moox/e-billingis the reference for structure in this package family: Pest witha
TestCaseand a container-onlyContainerTestCase,FeatureandUnitdirectories, andshared fixture builders under a
Supportdirectory. Follow that layout. Note thatmoox/mail-inboxcurrently has no test suite, so it is not prior art — do not mirror it.Out of Scope
event it would consume.
That needs an inbox and belongs with the inbound package; this spec only guarantees that the
message id needed for correlation is recorded.
from the host.
recipients belong to the host application, which is also where any organisation-specific
escalation or approval policy lives.
domain concern; this package decides only when a mail may leave.
inlining) — lives in
moox/templateandmoox/mjml(see ADR-0017 in the host repo).inlining belong to
moox/mjml, not here. This package consumes a finished Mailable; it ownsneither the styling nor a build step for it.
Further Notes
The boundary that keeps this package generic, stated once because it is easy to erode:
images and header handling that Symfony already provides, and makes "works over SMTP too" cost
an adapter the framework ships.
current framework versions, supported a single tenant and mailbox, and pulled in a second HTTP
stack. Worth reading, not worth depending on.
archive problem harder rather than easier.
operational questions an archive does not answer.
and attachments in a real client, which is the point.