laravel-zeptomail maintained by bjthecod3r
Laravel ZeptoMail
A ZeptoMail mail driver for Laravel. Register zeptomail as a mailer and everything you already write — Mail::to()->send(), Mailables, notifications, queued mail — goes out over the Zoho ZeptoMail API.
Mail::to($user)->send(new OrderShipped($order));
Highlights
- A driver, not an API. Nothing in your application changes. Mailables, notifications,
Mail::raw(), and queued mail all work as they do on SMTP. - Templates supported. Point a Mailable at a ZeptoMail template with
->template($key)and passmerge_info; the package routes to the template endpoint and skips uploading a body the template would ignore. - Inline images render. Anything embedded with
$message->embed()is sent as aninline_imagesentry with itscid, instead of arriving as a file attachment. - Testable with
Http::fake(). Requests go through Laravel's HTTP client, so you assert on outgoing mail exactly like any other outbound call. - Diagnostics you can act on. Failures throw a
TransportExceptionInterfacecarrying ZeptoMail's status code, error code, request ID, and per-field errors — not a bare\RuntimeException. - Every region. All eight ZeptoMail data centres, selected with one env var.
Requirements
- PHP
^8.2 - Laravel
^12.0or^13.0
Installation
composer require bjthecod3r/laravel-zeptomail
The service provider is auto-discovered. Publishing the config is optional:
php artisan vendor:publish --tag=zeptomail-config
Add the mailer to config/mail.php:
'mailers' => [
'zeptomail' => [
'transport' => 'zeptomail',
],
],
Then set your credentials in .env:
MAIL_MAILER=zeptomail
ZEPTOMAIL_TOKEN="Zoho-enczapikey wSsVR61..."
ZEPTOMAIL_REGION=com
ZEPTOMAIL_TOKEN is the Send Mail Token from your ZeptoMail Mail Agent (Mail Agents → your agent → Setup Info → API). Paste it with or without the leading Zoho-enczapikey — the transport normalises either form.
Make sure the from address in config/mail.php is on a domain you have verified in ZeptoMail, or the API will reject the send.
Regions
ZeptoMail runs separate data centres, and your token only works against the one your account lives in. Set ZEPTOMAIL_REGION to match:
ZEPTOMAIL_REGION |
Data centre | API host |
|---|---|---|
com (default) |
United States / global | api.zeptomail.com |
eu |
Europe | api.zeptomail.eu |
in |
India | api.zeptomail.in |
com.au |
Australia | api.zeptomail.com.au |
com.cn |
China | api.zeptomail.com.cn |
jp |
Japan | api.zeptomail.jp |
ca |
Canada | api.zeptomail.ca |
sa |
Saudi Arabia | api.zeptomail.sa |
The matching Zoho domains (zoho.eu, zohocloud.ca, …) are accepted as aliases.
Configuration
Everything in config/zeptomail.php:
return [
'token' => env('ZEPTOMAIL_TOKEN'),
'region' => env('ZEPTOMAIL_REGION', 'com'),
'bounce_address' => env('ZEPTOMAIL_BOUNCE_ADDRESS'),
'track_opens' => env('ZEPTOMAIL_TRACK_OPENS'),
'track_clicks' => env('ZEPTOMAIL_TRACK_CLICKS'),
'http' => [
'timeout' => (float) env('ZEPTOMAIL_TIMEOUT', 30),
'connect_timeout' => (float) env('ZEPTOMAIL_CONNECT_TIMEOUT', 10),
'retry_times' => (int) env('ZEPTOMAIL_RETRY_TIMES', 1),
'retry_sleep' => (int) env('ZEPTOMAIL_RETRY_SLEEP', 200),
],
];
Leave track_opens and track_clicks unset to send no flag at all and let your Mail Agent's own settings decide.
Retries cover connection failures only, and retry_times of 1 means a single attempt with no retry. ZeptoMail's send endpoint is not idempotent, so retrying a request the server may already have accepted would deliver the message twice — raise it deliberately.
Sending mail
There is no ZeptoMail-specific API to learn for ordinary mail:
Mail::to('user@example.com')
->cc('team@example.com')
->send(new OrderShipped($order));
Mail::raw('Hello there', fn ($message) => $message->to('user@example.com')->subject('Hi'));
$user->notify(new InvoicePaid($invoice));
Attachments and embedded images work as they do elsewhere in Laravel. Attachments become ZeptoMail attachments; anything embedded in a Blade view with $message->embed(...) becomes an inline_images entry, so it renders in the body instead of arriving as a download.
Per-message options
ZeptoMail-specific options are set on the Symfony message, so they work anywhere Laravel hands it to you. In a Mailable, that is the using argument of the envelope:
use BjTheCod3r\ZeptoMail\ZeptoMail;
use Illuminate\Mail\Mailables\Envelope;
use Symfony\Component\Mime\Email;
public function envelope(): Envelope
{
return new Envelope(
subject: 'Your order has shipped',
using: [
fn (Email $message) => ZeptoMail::message($message)
->clientReference("order-{$this->order->id}")
->trackOpens()
->trackClicks(false),
],
);
}
| Method | ZeptoMail field |
|---|---|
template(string $key) |
template_key |
templateAlias(string $alias) |
template_alias |
mergeInfo(array $info) |
merge_info |
clientReference(string $reference) |
client_reference |
bounceAddress(string $address) |
bounce_address |
trackOpens(bool $track = true) |
track_opens |
trackClicks(bool $track = true) |
track_clicks |
These are carried as X-ZeptoMail-* headers and stripped before the request is built, so they never reach the recipient. Any other custom header you set is forwarded to ZeptoMail as mime_headers.
Sending with a ZeptoMail template
Point a Mailable at a template and ZeptoMail supplies the subject and body:
public function envelope(): Envelope
{
return new Envelope(
using: [
fn (Email $message) => ZeptoMail::message($message)
->template('2d6f.34a5b1c7d8e9f0a1.k1.abc...')
->mergeInfo([
'name' => $this->user->name,
'order_id' => $this->order->id,
]),
],
);
}
The package posts to ZeptoMail's template endpoint and omits the body, which the template would discard anyway. Your Mailable still needs a content() method to satisfy Laravel, but its output is not sent. templateAlias() works the same way if you address templates by alias.
Several Mail Agents or regions
Any option can be set per mailer, falling back to config/zeptomail.php:
'mailers' => [
'zeptomail' => [
'transport' => 'zeptomail',
],
'zeptomail-eu' => [
'transport' => 'zeptomail',
'region' => 'eu',
'token' => env('ZEPTOMAIL_EU_TOKEN'),
],
],
Mail::mailer('zeptomail-eu')->to($user)->send(new OrderShipped($order));
Handling failures
A rejected or unreachable send throws ZeptoMailTransportException, which extends Symfony's TransportException. Laravel treats it like any other mail failure — queued mail retries, and Mail::send() throws:
use BjTheCod3r\ZeptoMail\Exceptions\ZeptoMailTransportException;
try {
Mail::to($user)->send(new OrderShipped($order));
} catch (ZeptoMailTransportException $e) {
report($e);
$e->statusCode; // 400
$e->errorCode; // 'TM_3201'
$e->requestId; // 'ac41f2b0-...' — quote this to Zoho support
$e->details; // the decoded error body, including per-field errors
}
The message already folds in ZeptoMail's per-field errors, so a bad recipient reads as:
ZeptoMail rejected the message (HTTP 400, TM_3201): Invalid Parameter Value
— Invalid email address (to[0].email_address.address)
On success, ZeptoMail's request ID is recorded as the message ID, which is what you quote when tracing a send in the ZeptoMail dashboard:
$sent = Mail::to($user)->send(new OrderShipped($order));
$sent->getMessageId(); // 'ac41f2b0-8f3e-11f0-...'
Misconfiguration is caught when the mailer is built, not at send time: a missing token or an unrecognised region throws InvalidConfigurationException, naming the accepted values.
Testing
Mail::fake() works as always, intercepting before the transport runs:
Mail::fake();
Mail::to('user@example.com')->send(new OrderShipped($order));
Mail::assertSent(OrderShipped::class);
To assert on what actually reaches ZeptoMail, fake the HTTP layer instead:
use Illuminate\Http\Client\Request;
use Illuminate\Support\Facades\Http;
Http::fake([
'api.zeptomail.*' => Http::response(['request_id' => 'req-1'], 201),
]);
Mail::to('user@example.com')->send(new OrderShipped($order));
Http::assertSent(function (Request $request): bool {
return $request->url() === 'https://api.zeptomail.com/v1.1/email'
&& $request['subject'] === 'Your order has shipped'
&& $request['to'][0]['email_address']['address'] === 'user@example.com';
});
Contributing
See CONTRIBUTING.md.
composer install
composer check # pint --test, phpstan, pest
Credits
This package began as a fork of zohomail/laravel-zeptomail by Zoho Corporation, and has since been rewritten in full.
ZeptoMail and Zoho are trademarks of Zoho Corporation. This package is not affiliated with, endorsed by, or sponsored by Zoho.
License
MIT. See LICENSE.