Self-hosted, unmetered staging/test mail capture for Laravel, on Laravel Cloud.
Sink is a mail trap you fork and deploy to your own Laravel Cloud account. Drop the
client into any Laravel app, set MAIL_MAILER=sink, and every outbound message is
captured to a self-hosted inbox instead of being delivered. Humans can inspect the
mail in a web UI; coding agents can assert on metadata and body-match booleans over
MCP.
One mailer in, nothing out. No metering, no per-message bill, no third-party inbox holding your staging mail.
Sink is capture-only by design. It is not an SMTP server, not a forwarding relay,
and not a replacement for Mail::fake() in unit tests.
Local mail catchers like Mailpit are excellent for one developer's laptop, but they are ephemeral and local. Hosted sandboxes like Mailtrap are polished, but they add another vendor, metering, and another inbox holding your data.
Sink is a free, self-hosted floor: a persistent, shared, agent-queryable staging/test inbox running on infrastructure you control. Storage and Cloud resources are your only costs.
Positioning. Mailpit is the right tool for a single developer's local loop, and Mailtrap is the full hosted product with deliverability scoring and broad team features. Sink is the floor for Laravel teams that want a persistent shared staging trap in their own Cloud account, with a coding agent as a first-class consumer. Outgrow it -> Mailtrap is the upgrade. Sink is MIT licensed.
Sink is written for how Artisan Build uses it. Bugs get fixed. Feature requests are a fork away. Client-specific features are not backfilled into the OSS release. If you need the full hosted product, that is what Mailtrap is for.
Source app (MAIL_MAILER=sink)
└─ sink-client transport
serialize Symfony message → raw MIME
mint idempotency ULID + thin envelope
POST /ingest (Bearer SINK_TOKEN, retry + backoff, cold-start tolerant)
capture-only · production fuse · fail-loud on exhaustion
│
▼
Sink app (one isolated Laravel Cloud environment per client)
/ingest → token auth → envelope-version check (4xx if sender is ahead)
→ upsert by idempotency key → raw MIME to object storage → enqueue
│
▼
Redis managed queue (autoscale on depth, scale to zero)
│
▼
Parse worker → extract subject / from / to·cc·bcc / headers / links /
attachment metadata / sizes → Postgres (metadata)
raw MIME + attachment bytes → object storage
│
┌───────┴───────────────────────────────┐
▼ ▼
Human inbox UI MCP server (Bearer)
invitation-only body-blind assertion tools
sandboxed HTML render ◄── coding agent / CI
headers · links · raw · attachments
│
▼
Scheduled sink:maintain → sink:prune
(retention 7d default + max-messages + max-total-bytes; bucket lifecycle backstop)
- One isolated environment per client on Laravel Cloud: its own web instance, Postgres database, Redis, object-storage bucket, managed queue, and scheduler.
- Unlimited source apps per environment. Source apps are tagged by the bearer
token that sent them, giving Sink its
appdimension. - Cross-boundary transport is HTTP, not shared Redis. Source apps are separate
deployments;
POST /ingestis the boundary.
This is a monorepo. Three packages are developed here under packages/, each
split read-only to its own repository and published to Packagist:
| Package | Repo | Installed in | Role |
|---|---|---|---|
artisan-build/sink-contracts |
read-only split | both packages | The versioned wire envelope. The single place compatibility lives. |
artisan-build/sink-client |
read-only split | source Laravel apps | The send side: the sink mail transport, activation guards, retry/backoff, idempotency key, sink:install, and sink:update. |
artisan-build/sink-server |
read-only split | the Sink app | The receive side: ingest, parse, object storage, prune, the human inbox UI, and the MCP server. |
The Sink app at this repository's root is a slim Laravel shell: auth, token
handout, and wiring sink-server. It stays thin so Sink-specific business logic
does not drift out of the packages.
Issues and PRs are disabled on the three split repos, matching Laravel's own
read-only split model. All development happens here in the monorepo. See
SECURITY.md for private vulnerability disclosure; Sink sits on an
untrusted ingest path.
Across N independently-deployed senders and one self-hosted receiver, version skew is the normal state, not an error. The wire protocol is built to tolerate it:
- Versioned envelope. Every payload carries
envelope_version. The envelope evolves additively within a major: new optional fields only, never remove or repurpose an existing field. - Backward-compatible server. A newer
sink-serverparses every older envelope major it supports. - Loud failure on the dangerous case. Ingest returns a clear 4xx when a sender uses an envelope newer than the server understands: update the Sink app first.
- Opaque message payload. Only the thin Sink envelope is version-sensitive; the message is base64-encoded raw RFC-822 MIME and interpreted server-side.
- Canonical upgrade order: update the Sink server first, then bump clients. A backward-compatible server safely runs ahead of its senders; the hazard is a sender getting ahead of the server.
- Reserved stream field.
streamis present but nullable in v1. It is reserved for future per-run isolation; v1 usage is app and time-window scoped.
Releases are lockstep v* tags from this monorepo. A release tag drives
php artisan kibble:split, which splits sink-contracts, sink-client, and
sink-server to their read-only mirrors and publishes the packages. Keep
inter-package constraints on the same major.
Run one isolated Laravel Cloud environment per client: the Sink app, one Postgres
database, Redis, one object-storage bucket, a web instance, a managed queue, and
the scheduler. The scheduler runs sink:maintain, which runs sink:prune; Redis
workers drain parse jobs.
Using a coding agent? Let the skill do it. This repo ships a
provisioning-sink-on-cloudskill that provisions Postgres, Redis, object storage, web, queue, and scheduler; wires Sink configuration; deploys; migrates; runscreate-admin; and issues the first source-app token.
Required production environment:
DB_CONNECTION=pgsqlplus normalDB_*values.- Optional
SINK_DB_*values when the Sink metadata connection should differ from the default database; otherwise thesinkconnection mirrors the app default. - Object-storage credentials and
SINK_DISKfor raw MIME and attachment bytes. QUEUE_CONNECTION=redisandSINK_QUEUE_CONNECTION=redisfor parse jobs.SINK_RETENTION_DAYS, default7, plus optionalSINK_MAX_MESSAGESandSINK_MAX_TOTAL_BYTEScaps.SINK_MCP_PATH, default/mcp, andSINK_MCP_LOCAL_NAME, defaultsink.- App auth secrets and bearer tokens managed by
artisan-build/built-for-cloud.
Do not enable a Cloud-managed mail integration for the Sink app. Sink is the inbox.
On the Sink server, issue a source application token locally with the
artisan-build/built-for-cloud command:
php artisan token:create <label>The command prints the plaintext token once and stores only its hash in
api_tokens. Rotate, revoke, list, and inspect usage with token:rotate,
token:revoke, token:list, and token:usage.
In the source Laravel app:
composer require artisan-build/sink-client
php artisan sink:install --url=<SINK_URL> --token=<token>Then opt in explicitly:
MAIL_MAILER=sink
SINK_URL=<SINK_URL>
SINK_TOKEN=<token>Installing the package alone does not change your mailer. Sink only captures mail
when MAIL_MAILER=sink is selected. The production fuse refuses to construct the
transport in production unless SINK_ALLOW_PRODUCTION=true, preventing Sink from
silently swallowing production mail.
Using a coding agent in the source app?
sink-clientships aconfiguring-sink-clientskill undervendor/artisan-build/sink-client/skills/configuring-sink-client/once installed. Point your agent at it and ask it to configure the Sink client. It coverscomposer require,sink:install, settingMAIL_MAILER=sink, the production fuse, token creation by the Sink operator, verifying a test send arrives, and troubleshooting.
The HTTP MCP server is registered at SINK_MCP_PATH, default /mcp, and requires
Authorization: Bearer <token>. Requests without a resolving bearer token fail
closed with 401. For local Claude Code use, the same server is registered as a
stdio MCP server under SINK_MCP_LOCAL_NAME, defaulting to sink.
Sink's MCP server is body-blind: no tool returns rendered or raw body content. Agents get envelope metadata, recipients, headers, links, attachment metadata, counts, and boolean body assertions.
MCP tools:
list_apps— source apps that have sent, with counts and last-seen.list_recent— recent message metadata. No body.count_messages— count matching messages by app, subject, recipient, time window, and stream.recipients— recipient addresses matching a filter.assert_count— pass/fail count assertion with actual vs expected.stats— grouped counts by subject, app, or recipient domain.message_detail— full metadata for one message, including headers, links, attachment metadata, sizes, and timestamps. No body.links— normalized extracted URLs for one message.body_matches— returns only a boolean and match count for a pattern. Never the matched text.purge— the only mutating tool; deletes messages matching an explicit scope and refuses unscoped wipes.
Upgrade the Sink server first, run its migrations, and then update source app clients. Clients can run:
php artisan sink:updateThe command derives /capabilities from SINK_URL, stripping a trailing /ingest
when present, and reports whether the client's envelope major is inside the
server's supported range. If a source app sends an envelope newer than the server
understands, /ingest returns a 4xx upgrade message instead of accepting it.
- No SMTP server. HTTP ingest via the
sinktransport only. - No capture-and-forward / BCC archive. Capture-only; nothing is delivered.
- No per-run streams in v1. The field is reserved and nullable, but v1 scopes by app and time window.
- No agent body access. MCP assertion tools only; humans view bodies in the UI.
- No open registration / no ownership-handoff UI. Admins invite users; handoff is an operator task.
- No deliverability or spam scoring. That is Mailtrap's domain.
Sink is open-sourced software licensed under the MIT license.