Skip to content

Outbound mail engine: send log, safe test mode, send windows #1

Description

@jbagsik

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:

  1. 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.
  2. 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.
  3. 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

  1. 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.
  2. 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.
  3. As an operator, I want to see why a send failed, so that I can decide between retrying and
    correcting the data.
  4. 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.
  5. 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.
  6. 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.
  7. 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.
  8. As a developer, I want to send through a named mailer, so that different mails can leave from
    different mailboxes.
  9. As a developer, I want to switch mail provider by configuration, so that changing provider
    does not mean changing code.
  10. 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.
  11. 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.
  12. 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.
  13. As an operator, I want a warning when test mode is enabled in production, so that a
    misconfiguration is noticed before customers are affected.
  14. 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.
  15. As an operator, I want to define quiet periods during which no mail leaves, so that
    automated mail does not arrive at unwanted hours.
  16. 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.
  17. 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.
  18. 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.
  19. 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.
  20. 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.
  21. 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.
  22. 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.
  23. 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.

  1. 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.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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