Work with darvis/mailtrap. Use it to tag outgoing mail with a model, query mail logs, validate or block email addresses, handle blocked sends, and test the Mailtrap webhook.
php artisan boost:add-skill darvis/mailtrap
Save this content to: .claude/skills/mailtrap-development/SKILL.md
# darvis/mailtrap development
## When to use this skill
Use this skill when code sends mail in an application that has `darvis/mailtrap` installed, when you query `mail_logs` or `email_validations`, or when you work on Mailtrap webhook handling or its tests.
## How a send is processed
1. On `MessageSending`, every To, Cc and Bcc address runs through `EmailValidation::validateEmail()`: a format check, then the MX record and whether it resolves to an IP. A missing MX record or a Null MX blocks the address; a DNS lookup that fails (timeout, SERVFAIL) lets it through without storing a verdict. The DNS lookups are skipped when another address on the domain is already valid. Set `validation.enabled` to false to skip validation altogether.
2. All recipients are checked before any row is written. A `blocked` address aborts the send: every recipient of that mail gets a `MailLog` row with status `550` (the others with `Not sent: … is blocked` as error), and a `TransportException` is thrown.
3. Otherwise one `MailLog` row per recipient is created with `status_code = null`. Every row of the mail shares the `X-Message-ID` header.
4. On `MessageSent` those rows become `200`. Mailtrap webhook events later update the row for that recipient. The row is found through the `x_message_id` custom variable (the `X-MT-Custom-Variables` header), otherwise through Mailtrap's `message_id`.
A failure to write the log is reported to the exception handler and never stops the mail. Addresses are trimmed and compared in lower case in `email_validations` and in `MailLog::toRecipient()`.
## Tagging mail with a model
Set `X-Mail-Type`, `X-Mail-Model` (a class name or morph alias) and `X-Mail-Model-ID` as headers on the Mailable. They are copied to `type`, `model` and `model_id`, and `$log->related` resolves the model.
## Querying logs
Use the scopes instead of comparing status codes:
```php
use Darvis\Mailtrap\Models\MailLog;
MailLog::successful(); // status_code "200"
MailLog::pending(); // not yet confirmed as sent
MailLog::failed(); // any status other than 200
MailLog::blocked(); // stopped before sending (550)
MailLog::delivered(); // delivered_at is set by a delivery event
MailLog::opened(); // opened_at is set by an open event
MailLog::clicked(); // clicked_at is set by a click event
MailLog::forModel(Order::class, $order->id)->toRecipient($email)->latest()->get();
```
`delivered_at`, `opened_at` and `clicked_at` hold the first time Mailtrap reported that event; a later open does not move `opened_at`. Open and click events only arrive when tracking is switched on for the sending domain in Mailtrap, and an open is a hint, not proof: mail clients that load images on their own count as opened, and clients that block images never do. `status_code` stays `"200"` for these events, so use the scopes or the columns, not the status, to tell an opened mail from a sent one.
Logs older than `logging.cleanup_after_days` are pruned by `php artisan model:prune --model="Darvis\Mailtrap\Models\MailLog"`. Schedule that command, because Laravel only discovers models in `app/Models` by itself.
## Address verdicts
| Status | Set by | Stops sending |
| --- | --- | --- |
| `EmailValidation::VALID` | local checks, delivery/open/click events, `markAsValid()` | no |
| `EmailValidation::INVALID` | bounce/spam/reject events, `markAsInvalid()` | no |
| `EmailValidation::BLOCKED` | failed format or MX check, `markAsBlocked()` | yes |
- Use `markAsBlocked($email, $reason)` only to halt mail to that one address, for example after a complaint. It never affects other addresses on the domain.
- Blocks from a local check (reasons in `EmailValidation::LOCAL_CHECK_REASONS`) expire after `validation.cache_duration` seconds and are then checked again. Manual blocks never expire.
- To check many addresses without DNS lookups, use `EmailValidation::bulkValidationStatus($emails)`. To validate the addresses that have no verdict yet, use `bulkValidationWithCheck($emails, true)`.
## Reacting to events
```php
use Darvis\Mailtrap\Events\MailBlocked;
use Darvis\Mailtrap\Events\MailtrapEventReceived;
Event::listen(function (MailtrapEventReceived $event): void {
// $event->type, $event->email, $event->payload (raw Mailtrap event), $event->mailLog (null for ignored types)
if ($event->type === 'unsubscribe') {
// ...
}
});
Event::listen(function (MailBlocked $event): void {
// $event->email, $event->reason, $event->message (Symfony Email), $event->mailLog
});
```
`MailtrapEventReceived` also fires for `unsubscribe`, `soft bounce` and `suspension`, which the package does not act on. A listener that throws does not break the webhook batch.
## Inbox access
The inbox route ends with `Darvis\Mailtrap\Http\Middleware\AuthorizeInbox`, which aborts with 403 unless `Gate::allows('viewMailtrap')`. The package default allows the `local` environment only; a gate of the host application replaces it. The component repeats the check in `boot()`, `render()` and every action. In a host test that opens the inbox, define the gate first: `Gate::define('viewMailtrap', fn ($user = null) => true);`.
## Testing
- Use the `array` mailer and give recipients a verdict up front with `EmailValidation::markAsValid()`. Otherwise tests run real DNS lookups.
- Webhook requests must be signed. Sign the raw JSON body with HMAC-SHA256 and send it in the `Mailtrap-Signature` header:
```php
use Darvis\Mailtrap\Support\MailtrapConfig;
$body = json_encode(['events' => [[
'event' => 'bounce', 'email' => '[email protected]', 'message_id' => 'abc', 'response_code' => 550,
]]]);
// Set manta_mailtrap.webhook.secret in the test first; without a secret every call gets 403.
$this->call('POST', '/api/webhooks/mailtrap', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_MAILTRAP_SIGNATURE' => hash_hmac('sha256', $body, MailtrapConfig::webhookSecret()),
], content: $body)->assertOk();
```
The event names in the payload are `delivery`, `open`, `click`, `bounce`, `spam` and `reject`. `soft bounce`, `unsubscribe` and `suspension` are accepted but not acted on.
Use this skill when code sends mail in an application that has darvis/mailtrap installed, when you query mail_logs or email_validations, or when you work on Mailtrap webhook handling or its tests.
MessageSending, every To, Cc and Bcc address runs through EmailValidation::validateEmail(): a format check, then the MX record and whether it resolves to an IP. A missing MX record or a Null MX blocks the address; a DNS lookup that fails (timeout, SERVFAIL) lets it through without storing a verdict. The DNS lookups are skipped when another address on the domain is already valid. Set validation.enabled to false to skip validation altogether.blocked address aborts the send: every recipient of that mail gets a MailLog row with status 550 (the others with Not sent: … is blocked as error), and a TransportException is thrown.MailLog row per recipient is created with status_code = null. Every row of the mail shares the X-Message-ID header.MessageSent those rows become 200. Mailtrap webhook events later update the row for that recipient. The row is found through the x_message_id custom variable (the X-MT-Custom-Variables header), otherwise through Mailtrap's message_id.A failure to write the log is reported to the exception handler and never stops the mail. Addresses are trimmed and compared in lower case in email_validations and in MailLog::toRecipient().
Set X-Mail-Type, X-Mail-Model (a class name or morph alias) and X-Mail-Model-ID as headers on the Mailable. They are copied to type, model and model_id, and $log->related resolves the model.
Use the scopes instead of comparing status codes:
use Darvis\Mailtrap\Models\MailLog;
MailLog::successful(); // status_code "200"
MailLog::pending(); // not yet confirmed as sent
MailLog::failed(); // any status other than 200
MailLog::blocked(); // stopped before sending (550)
MailLog::delivered(); // delivered_at is set by a delivery event
MailLog::opened(); // opened_at is set by an open event
MailLog::clicked(); // clicked_at is set by a click event
MailLog::forModel(Order::class, $order->id)->toRecipient($email)->latest()->get();
delivered_at, opened_at and clicked_at hold the first time Mailtrap reported that event; a later open does not move opened_at. Open and click events only arrive when tracking is switched on for the sending domain in Mailtrap, and an open is a hint, not proof: mail clients that load images on their own count as opened, and clients that block images never do. status_code stays "200" for these events, so use the scopes or the columns, not the status, to tell an opened mail from a sent one.
Logs older than logging.cleanup_after_days are pruned by php artisan model:prune --model="Darvis\Mailtrap\Models\MailLog". Schedule that command, because Laravel only discovers models in app/Models by itself.
| Status | Set by | Stops sending |
|---|---|---|
EmailValidation::VALID |
local checks, delivery/open/click events, markAsValid() |
no |
EmailValidation::INVALID |
bounce/spam/reject events, markAsInvalid() |
no |
EmailValidation::BLOCKED |
failed format or MX check, markAsBlocked() |
yes |
markAsBlocked($email, $reason) only to halt mail to that one address, for example after a complaint. It never affects other addresses on the domain.EmailValidation::LOCAL_CHECK_REASONS) expire after validation.cache_duration seconds and are then checked again. Manual blocks never expire.EmailValidation::bulkValidationStatus($emails). To validate the addresses that have no verdict yet, use bulkValidationWithCheck($emails, true).use Darvis\Mailtrap\Events\MailBlocked;
use Darvis\Mailtrap\Events\MailtrapEventReceived;
Event::listen(function (MailtrapEventReceived $event): void {
// $event->type, $event->email, $event->payload (raw Mailtrap event), $event->mailLog (null for ignored types)
if ($event->type === 'unsubscribe') {
// ...
}
});
Event::listen(function (MailBlocked $event): void {
// $event->email, $event->reason, $event->message (Symfony Email), $event->mailLog
});
MailtrapEventReceived also fires for unsubscribe, soft bounce and suspension, which the package does not act on. A listener that throws does not break the webhook batch.
The inbox route ends with Darvis\Mailtrap\Http\Middleware\AuthorizeInbox, which aborts with 403 unless Gate::allows('viewMailtrap'). The package default allows the local environment only; a gate of the host application replaces it. The component repeats the check in boot(), render() and every action. In a host test that opens the inbox, define the gate first: Gate::define('viewMailtrap', fn ($user = null) => true);.
array mailer and give recipients a verdict up front with EmailValidation::markAsValid(). Otherwise tests run real DNS lookups.Mailtrap-Signature header:use Darvis\Mailtrap\Support\MailtrapConfig;
$body = json_encode(['events' => [[
'event' => 'bounce', 'email' => '[email protected]', 'message_id' => 'abc', 'response_code' => 550,
]]]);
// Set manta_mailtrap.webhook.secret in the test first; without a secret every call gets 403.
$this->call('POST', '/api/webhooks/mailtrap', server: [
'CONTENT_TYPE' => 'application/json',
'HTTP_ACCEPT' => 'application/json',
'HTTP_MAILTRAP_SIGNATURE' => hash_hmac('sha256', $body, MailtrapConfig::webhookSecret()),
], content: $body)->assertOk();
The event names in the payload are delivery, open, click, bounce, spam and reject. soft bounce, unsubscribe and suspension are accepted but not acted on.
How can I help you explore Laravel packages today?