agavesoft/mailflow
MailFlow is a Laravel/PHP package for building expressive email workflows. Define message sequences, conditions, and delivery steps with a clean API, then integrate them into your app to automate transactional or scheduled mail flow.
The package is not on Packagist, so installation goes through a VCS repository. Add it to composer.json before requiring:
"repositories": [{ "type": "vcs", "url": "https://github.com/agavesoft/notificabot-lara-pkg-sdk" }]
composer require agavesoft/smartmailto:^2.5
Note that v2.2.0–v2.4.0 were never tagged separately; ^2.5 is the single entry point that contains F-008 (full Factura Facilita send), F-009 (variable catalog + provisioning), F-010 (transactional outbox) and F-011 (pull sync). You do not need to install intermediate versions.
First steps after composer require:
php artisan vendor:publish --tag=smartmailto-config
php artisan vendor:publish --tag=smartmailto-migrations
php artisan migrate
The migration is harmless if you never enable the outbox: smartmailto_outbox and smartmailto_outbox_alert_state just sit empty.config/smartmailto.php and the .env. The important switches are SMARTMAILTO_OUTBOX_ENABLED (default false) and SMARTMAILTO_PULL_ENABLED (default false). Both features are opt-in and both require a server that implements F-010 / F-011 respectively — leaving them off changes nothing versus 2.1.GET /api/send/{key}, POST /api/send/external, POST /api/outbox/heartbeat, POST /api/contacts/link, POST /api/provision, POST /api/validate.Smartmailto::send(...) with a template plus Attachment::fromPath(...). Do this without the outbox enabled first: you get the F-008 attachment API and the error mapping without adding a worker to your infra.SmartmailtoFake (it documents the whole public surface, including assertLinked(), assertExternalSendReported(), assertPackageProvisioned(), assertVariablePut(), assertActivated(), assertPullContract()), then Commands\OutboxWork, then Contracts\PullResolver.A good first week: enable the outbox in staging only, run php artisan smartmailto:outbox:status after a few sends, and confirm rows move pending → acked. Do not turn on pull until you have written a resolver.
Outbox as the default for anything that must not be lost. With outbox.enabled = true, track(), send(), identify(), link() and reportExternalSend() write a row inside your application transaction, on outbox.connection, with the payload encrypted using APP_KEY. That means the outbox insert commits or rolls back with your business write — which is the whole point. Pattern:
DB::transaction(function () use ($order) {
$order->save();
Smartmailto::send(
Identity::external($order->email),
'order-confirmation',
['order' => $order->id],
sendBefore: now()->addHour(), // mandatory when outbox is on
);
});
The row is only closed by the acknowledgement of its own key, so the worker can be as dumb as you like.
Run the worker as a supervised loop, not a one-shot. smartmailto:outbox:work supports --once, --sleep, --max-time. Two viable shapes:
smartmailto:outbox:work --max-time=3500 with restart-on-exit — preferred, since the process keeps the 5-minute POST /api/outbox/heartbeat cadence.--once — simpler, but you lose the heartbeat continuity and pay boot cost each tick.Either way, monitor throughput with smartmailto:outbox:status and drain the backlog with smartmailto:outbox:retry --failed (or --key=) instead of hand-editing rows.
Treat SmartmailtoOutboxFailed as your alerting seam, not a retry hook. It fires only on definitive outcomes: a hard rejection (reason = HTTP status), expired, or gave_up after 72 h. The payload carries outboxId, kind, key, reason, template, status, error and no personal data — so it is safe to forward straight into your own observability stack. Use needsEmergencySend() to decide whether the app should fall back to a direct channel.
Emergency sends must go through reportExternalSend(). When the outbox is enabled and you send outside the SDK, report it with the original key. The row flips to superseded and an external_report row is written in the same transaction, so the worker will not double-send. With the outbox off, the call is simply queued to POST /api/send/external. If the row is mid-flight you get OutboxRowInFlight — catch it, that is the SDK telling you the send already happened.
Attachments: prefer disk/URL over inline bytes once the outbox is on. Attachment::fromDisk($disk, $path, $filename) mints a fresh 24-hour signed URL on every attempt, which is exactly what you want with a 72-hour retry window. Attachment::fromUrl($url, $filename, $sha256) is for pre-hosted artifacts. Inline attachments above 1 MB are rejected before persisting — a deliberate guard against bloating the outbox table.
Provisioning belongs in CI, in validate mode. Author variables/*.yaml|yml|json (one list per file), then:
php artisan smartmailto:provision --validate # CI gate, writes nothing
php artisan smartmailto:provision --activate # deploy step
The command now sends a single atomic package. If any item is rejected it prints every failing item and applies nothing — compare that to the old behaviour of one PUT per resource stopping at the first error. Warnings print but do not fail the run.
Pull sync: implement Contracts\PullResolver, keep it dumb. The route POST {prefix}/smartmailto/pull (name smartmailto.pull) only exists when smartmailto.pull.enabled is true and a resolver is bound; otherwise 404. The resolver gets contact(), contacts(), PullContact, PullEvent, PullPage (with deleted) and PullCursor; the SDK builds and signs the (updatedAt, key) cursor for you. Filter attributes against the contact-variable catalog (F-009), which is cached 5 minutes or pinned via smartmailto.pull.catalog. Dates arrive in the app timezone — do not re-shift them.
Testing against the fake. Every new surface has a fake counterpart, so feature tests stay offline:
$fake = Smartmailto::fake();
$fake->assertExternalSendReported();
$fake->assertLinked();
$fake->assertPackageProvisioned();
$fake->assertActivated();
Smartmailto::fakePull($resolver, catalog: ['plan' => 'string'])->assertPullContract();
fakePull() exercises the resolver through the real route (signature middleware included), which is the only way to catch a subtly wrong cursor or an attribute filtered out by the catalog.
Contact hygiene. Use Identity::external($email) with send(..., origin: $identity) for recipients who are not contacts (R2), link($survivor, $absorbed, $reason, $linkId) for merges, and identify(..., consent: true) to re-capture consent (D7). Alerts are grouped per project: configure alerts.mail_to and alerts.teams_webhook_url (adaptive card) and you get the 15 min / 1 h / 4 h / 12 h / 24 h / 48 h ladder, a 72 h digest, and a "recovered" notice for free.
reportExternalSend() will not behave as documented against an older server, and smartmailto:provision fails with a bare 404 and changes nothing. Confirm the server version before enabling flags in staging.send() semantics. sendBefore becomes required, and a track() without occurredAt silently uses the commit time. Retrofitting the outbox into existing call sites without auditing every send() is the most likely way to break a deploy.identify() stamps updated_at with the call time by default. Combined with the R-11 rule (most recent write wins between push and pull), a late-arriving identify from a queue can clobber fresher data pulled from the app. Pass updatedAt: explicitly — identify(..., updatedAt: $model->updated_at) and batch()->identify(..., updatedAt) — whenever the change originated earlier than the call.APP_KEY. Rotating the key while rows are pending leaves them undecryptable. Drain the outbox (smartmailto:outbox:status until near-zero) before any key rotation, or accept the loss.gave_up. Missing configuration behaves the same. Alert on row age, not just on the event.sending rows self-heal after 10 minutes by returning to pending. That is a safety net, not a lease — do not run two workers against the same database unless you are comfortable with one of them picking up the other's in-flight rows after the timeout.expired is not the same as failed. Before closing a send that hit send_before (or got a 410), the worker calls GET /api/send/{key}; if the mail actually left, is queued, or was deliberately discarded, the row becomes acked. Trust reason=gave_up and HTTP-status reason values for real failures.validatePackage() returns HTTP 200 even when invalid. Always branch on the valid field. Reading the status code is the classic bug here, and it will pass in every happy-path test.smartmailto.provision_timeout defaults to 120 s (versus 10 s for ingest). On a timeout the command warns rather than fails — re-running is safe. Other warnings worth wiring into CI output: an active workflow that ended up inactive, and a paused workflow.smartmailto.pull.catalog.pull.max_response_bytes (5 MB) and the cursor resumes from whatever fit. If your resolver returns fat pages, sync will still converge, just over more round trips. Watch for a page whose single record exceeds the cap — it will never make progress.SMARTMAILTO_PULL_SECRET (pullsec_...), 300 s window, single-use X-Smartmailto-Request, throttle 120,1. The response is signed as "{timestamp}.{request_id}.{body}" — if you proxy it, do not re-encode the body.SmartmailtoException carries structured detail now. Use error(), items(), usages() and warnings() instead of regexing the message; the message already embeds the code (Smartmailto rejected provision (422 provision_failed).).health() as a send fallback. The README dropped that recommendation; the supported emergency path is reportExternalSend() plus the deduplication semantics described above.smartmailto:outbox:prune drops acked rows after 7 days and closed rows after 30, with --contact= for ARCO requests on a single person. If your own retention policy is longer, you are the one who has to justify keeping encrypted payloads around.illuminate/database, illuminate/encryption, illuminate/filesystem, illuminate/mail, and symfony/yaml ^7.2|^8.0. That YAML constraint effectively pins you to a recent Laravel; check it before upgrading a legacy app. branch-alias is dev-develop → 2.5.x-dev if you ever need to track the branch.How can I help you explore Laravel packages today?