laravel maintained by duva-mail
duva-mail/laravel
Laravel mail transport for Duva, the transactional email API hosted in Canada. Use Mail, Mailables and
Notifications as usual; the messages go out through Duva.
Requires PHP 8.2+ and Laravel 12 or 13 (Laravel 11 no longer receives security fixes). Built on duva-mail/duva.
Install
composer require duva-mail/laravel
The package registers itself (Laravel package discovery). You also need a Duva API key and a verified domain (documentation).
Configure
.env:
MAIL_MAILER=duva
DUVA_API_KEY=dv_...
DUVA_DOMAIN=example.com
MAIL_FROM_ADDRESS=notifications@example.com
MAIL_FROM_NAME="Example"
config/mail.php, in mailers:
'duva' => [
'transport' => 'duva',
'key' => env('DUVA_API_KEY'),
'domain' => env('DUVA_DOMAIN'),
],
Optional keys: base_url (default https://api.duva.ca), timeout (seconds, default 10), max_retries (default 2). The From
address must belong to the configured domain.
Use it in a fail-over chain like any other transport:
'failover' => ['transport' => 'failover', 'mailers' => ['duva', 'smtp']],
What is sent
| Laravel / Symfony | Duva |
|---|---|
| From, To, Cc, Bcc, Reply-To | from, to, cc, bcc, reply_to (a Bcc only in the envelope is sent as Bcc) |
| HTML and text bodies | html, text |
Attachments, inline images (embed) |
attachments (inline ones keep their content_id) |
Mailable::tag() |
tags |
Mailable::metadata() |
metadata |
List-Unsubscribe, List-Unsubscribe-Post, List-Id, In-Reply-To, References, Auto-Submitted, Precedence, Importance, Feedback-ID, and X-* headers |
headers |
Notes:
- Other headers are not forwarded (Duva rejects them), nor are
X-Duva*,X-Kumo*,X-Tenant*andX-Campaign*. - A display name Duva refuses (it contains
@, quotes,<>, control characters or an encoded word=?…?=) is dropped; the address is kept. - The message ID Laravel reports is Duva's.
- Delivery is asynchronous: a successful
send()means Duva accepted the message, not that it was delivered. Use Duva's webhooks or events for the outcome. - A Duva error is thrown as
Symfony\Component\Mailer\Exception\TransportException(the originalDuva\Exceptions\*exception is itsgetPrevious()), so Laravel's fail-over and your owncatchblocks behave as with any transport.
Idempotency
A retried queue job renders a new message, so it would be sent twice. Set a stable key for it (an order ID, for example):
public function build()
{
return $this->withSymfonyMessage(function ($message) {
$message->getHeaders()->addTextHeader('X-Idempotency-Key', "order-{$this->order->id}");
});
}
The header is read by the transport and never sent. Duva ignores a second message with the same key.
Testing your application
Use Laravel's Mail::fake() as usual. To exercise the transport itself without a network, bind a fake transport of the
client library:
use Duva\Http\TestTransport;
$http = new TestTransport(fn () => TestTransport::response(['id' => 'msg_1', 'status' => 'queued']));
app()->instance('duva.transport', $http);
// ... send mail ...
$http->requests[0]->body; // what would have been sent
License
MIT.