Looking to hire Laravel developers? Try LaraJobs

laravel-zeptomail maintained by bjthecod3r

Description
A ZeptoMail mail driver for Laravel.
Author
Last update
2026/08/23 01:13 (dev-main)
License
Links
Downloads
10

Comments
comments powered by Disqus

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 pass merge_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 an inline_images entry with its cid, 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 TransportExceptionInterface carrying 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.0 or ^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.