Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Mailflow Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Publish config and migrations. The package now ships its first migration (outbox tables), so the standard Laravel flow applies:
    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.
  2. Check 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.
  3. Verify the server contract. Before flipping anything on, confirm the Smartmailto instance you point at exposes the endpoints you intend to use: GET /api/send/{key}, POST /api/send/external, POST /api/outbox/heartbeat, POST /api/contacts/link, POST /api/provision, POST /api/validate.
  4. First use case — fire-and-forget transactional mail. The lowest-friction path is still 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.
  5. Where to look first in the source: 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.

Implementation Patterns

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:

  • Supervisor/systemd running smartmailto:outbox:work --max-time=3500 with restart-on-exit — preferred, since the process keeps the 5-minute POST /api/outbox/heartbeat cadence.
  • Cron every minute with --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.

Gotchas and Tips

  • "Requires a server with F-010/F-011" is a hard prerequisite, not a suggestion. 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.
  • Enabling the outbox changes 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.
  • The outbox payload is encrypted with 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.
  • 401/403 are retried, never failed. A permanently wrong API key will not surface as a failed row — it will sit there retrying for 72 h and then emit gave_up. Missing configuration behaves the same. Alert on row age, not just on the event.
  • Backoff is fixed: 30 s, 2 min, 10 min, then hourly to 72 h. There is no queue-level jitter knob exposed; if you need backpressure, throttle at the producer.
  • Stuck 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.
  • Provision timeouts are ambiguous but idempotent. 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.
  • Pull returns 503 when the catalog cannot be read, which is intentional — Smartmailto retries. Returning a partial catalog from a cached fallback would silently drop attributes from the payload. If you need determinism, pin smartmailto.pull.catalog.
  • Pull responses are capped at 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.
  • The pull signature is separate from the webhook secret. 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).).
  • Do not use health() as a send fallback. The README dropped that recommendation; the supported emergency path is reportExternalSend() plus the deduplication semantics described above.
  • Pruning has a compliance intent. 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.
  • New hard dependencies: 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource
noith/settings
kda/laravel-package
kda/filament-dynamic-navigation
bypersoft/signbox
pairus/product-data
fayne/laravel-filament-cms-core
nodisrupt/laravel-agent
sierratecnologia/media-manager