graystackit/laravel-ahasend-api

Laravel package for the Ahasend email API, powered by Saloon v4

Maintainers

Package info

github.com/GraystackIT/laravel-ahasend-api

pkg:composer/graystackit/laravel-ahasend-api

Transparency log

Statistics

Installs: 4

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v0.1.1 2026-06-19 16:15 UTC

This package is auto-updated.

Last update: 2026-07-17 05:46:29 UTC


README

A production-ready Laravel package for the Ahasend transactional email API, powered by Saloon v4.

Requirements

  • PHP 8.3+
  • Laravel 11, 12, or 13
  • Saloon 4.x
  • Symfony Mailer 6.4 / 7.x (bundled with Laravel)

Installation

composer require graystackit/laravel-ahasend-api

The service provider is auto-discovered via Laravel's package discovery.

Publish config

php artisan vendor:publish --tag=ahasend-config

Publish & run migrations (optional — only needed for database storage driver)

php artisan vendor:publish --tag=ahasend-migrations
php artisan migrate

Configuration

Set the following variables in your .env file:

AHASEND_API_KEY=your-api-key
AHASEND_ACCOUNT_ID=your-account-id
AHASEND_FROM_ADDRESS=hello@yourdomain.com
AHASEND_FROM_NAME="Your App"

# Optional
AHASEND_BASE_URL=https://api.ahasend.com/v2
AHASEND_WEBHOOK_SECRET=your-webhook-secret
AHASEND_STORE_LOGS=true
AHASEND_STORAGE_DRIVER=database   # "log" or "database"
AHASEND_RETRY_TIMES=3
AHASEND_RETRY_DELAY_MS=500

Note: AHASEND_ACCOUNT_ID is required. You can find your account ID in the Ahasend dashboard.

Usage

Dependency injection

use GraystackIT\Ahasend\AhasendService;

class OrderController
{
    public function __construct(private readonly AhasendService $mailer) {}

    public function confirm(): void
    {
        $this->mailer->sendHtml(
            to:          [['email' => 'customer@example.com', 'name' => 'Jane']],
            subject:     'Order confirmed',
            htmlContent: '<p>Your order is confirmed!</p>',
            textContent: 'Your order is confirmed!',
        );
    }
}

Plain-text email

$mailer->sendText(
    to:          [['email' => 'user@example.com']],
    subject:     'Hello',
    textContent: 'Hello from Ahasend!',
);

HTML email

$mailer->sendHtml(
    to:          [['email' => 'user@example.com']],
    subject:     'Hello',
    htmlContent: '<h1>Hello!</h1>',
    textContent: 'Hello!',   // optional plain-text fallback
);

Email with attachments

$mailer->sendWithAttachments(
    to:          [['email' => 'user@example.com']],
    subject:     'Your invoice',
    attachments: [
        ['path' => storage_path('invoices/inv-001.pdf')],           // file path
        ['name' => 'data.csv', 'content' => $csvBase64, 'mime_type' => 'text/csv'], // raw
    ],
    htmlContent: '<p>Please find your invoice attached.</p>',
);

CC / BCC

Pass cc and bcc arrays to any convenience method. When CC or BCC recipients are present the package automatically routes the request to the Ahasend conversational endpoint (POST /messages/conversation), which is the only endpoint that supports those fields:

$mailer->sendHtml(
    to:          [['email' => 'a@example.com']],
    subject:     'Test',
    htmlContent: '<p>Hi</p>',
    cc:          [['email' => 'b@example.com']],
    bcc:         [['email' => 'c@example.com']],
);

Low-level EmailMessage

use GraystackIT\Ahasend\Data\EmailMessage;

$message = new EmailMessage(
    fromEmail:   'from@example.com',
    fromName:    'Sender',
    to:          [['email' => 'to@example.com']],
    subject:     'Custom',
    htmlContent: '<p>Hello</p>',
);

$ahasendMessageId = $mailer->send($message);

EmailMessage also accepts these optional fields, all passed straight through to the Ahasend API: tags (string[]), tracking (['open' => bool, 'click' => bool]), schedule (['first_attempt' => ..., 'expires' => ...], RFC3339), retention (['metadata' => int, 'data' => int], days), substitutions (template variables — not supported on the conversational/CC-BCC endpoint), sandboxResult (deliver/bounce/defer/fail/suppress), sandbox (bool — route through AhaSend's sandbox without sending real mail), replyTo (['email' => ..., 'name' => ...]), headers (custom header map), and ampContent (AMP4EMAIL body).

Webhook handling

Register your endpoint URL in the Ahasend dashboard:

https://yourdomain.com/ahasend/webhook

The path is configurable via AHASEND_WEBHOOK_PATH. Incoming events fire Laravel events you can listen to:

Ahasend event Laravel event
message.delivered MailDelivered
message.opened MailOpened
message.failed MailFailed
message.bounced MailBounced

Listening to events

// In EventServiceProvider or a listener class:
Event::listen(MailDelivered::class, function (MailDelivered $event): void {
    // $event->messageId, $event->recipient, $event->payload
});

Laravel Mail driver

The package registers a native Laravel mail transport driver so you can send any standard Laravel Mailable through AhaSend without touching your existing Mailable code.

1. Configure the mailer

Add an ahasend entry to the mailers array in config/mail.php:

// config/mail.php
'mailers' => [
    // ... other mailers ...

    'ahasend' => [
        'transport' => 'ahasend',
    ],
],

The transport reads API credentials and sender defaults from the ahasend config (i.e. the same AHASEND_* variables you already set).

To make AhaSend the default mailer, update your .env:

MAIL_MAILER=ahasend

2. Send a Mailable

use App\Mail\OrderShipped;
use Illuminate\Support\Facades\Mail;

// Uses the default mailer if MAIL_MAILER=ahasend
Mail::to('customer@example.com')->send(new OrderShipped($order));

// Or target the driver explicitly
Mail::mailer('ahasend')
    ->to('customer@example.com')
    ->cc('manager@example.com')
    ->send(new OrderShipped($order));

3. Example Mailable

<?php

namespace App\Mail;

use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class OrderShipped extends Mailable
{
    public function __construct(public readonly Order $order) {}

    public function envelope(): Envelope
    {
        return new Envelope(subject: 'Your order has shipped');
    }

    public function content(): Content
    {
        return new Content(
            html: 'emails.order-shipped',   // resources/views/emails/order-shipped.blade.php
            text: 'emails.order-shipped-text',
        );
    }

    public function attachments(): array
    {
        return [
            Attachment::fromPath(storage_path("invoices/{$this->order->id}.pdf"))
                ->as('invoice.pdf')
                ->withMime('application/pdf'),
        ];
    }
}

4. Required .env variables

AHASEND_API_KEY=your-api-key
AHASEND_ACCOUNT_ID=your-account-id
AHASEND_FROM_ADDRESS=hello@yourdomain.com
AHASEND_FROM_NAME="Your App"

# Make AhaSend the default mailer
MAIL_MAILER=ahasend

Supported features

Feature Supported
HTML body Yes
Plain-text body Yes
Multiple To recipients Yes
CC Yes
BCC Yes
File attachments Yes (auto base64 encoded)
From address / name Yes (from Mailable or config fallback)

Transport internals

The driver is implemented as GraystackIT\Ahasend\Mail\AhaSendTransport, which extends Symfony's AbstractTransport. It converts the Symfony Email object into the EmailMessage DTO used by AhasendService::send(), preserving all recipients, headers, and attachments. Errors thrown by AhasendService are re-wrapped as Symfony TransportException so Laravel's mail system handles them consistently.

Mailable tracking

Use the TracksAhasendMail trait in any Mailable to attach a UUID X-Ahasend-Message-Id header and (optionally) store the outgoing record in the database:

use GraystackIT\Ahasend\Traits\TracksAhasendMail;
use Illuminate\Mail\Mailable;

class OrderShipped extends Mailable
{
    use TracksAhasendMail;

    public function build(): self
    {
        $this->initAhasendTracking(recipient: $this->order->email);

        return $this->subject('Your order has shipped')
                    ->view('emails.order-shipped');
    }
}

Messages

Retrieve and manage sent or scheduled messages via MessageService.

use GraystackIT\Ahasend\Services\MessageService;
use GraystackIT\Ahasend\Enums\MessageStatus;

class MyController
{
    public function __construct(private readonly MessageService $messages) {}
}

Get a single message

$message = $messages->get('msg-abc123');

echo $message->id;            // 'msg-abc123'
echo $message->subject;       // 'Hello World'
echo $message->sender;        // 'sender@example.com'
echo $message->recipient;     // 'recipient@example.com'
echo $message->status->value; // 'delivered'
echo $message->status->isTerminal(); // true

List messages

Uses cursor-based pagination, plus optional filters:

$result = $messages->list(
    limit:     25,                       // optional — max results to return
    after:     'cursor-xyz',             // optional — cursor for the next page
    before:    'cursor-abc',             // optional — cursor for the previous page
    status:    'delivered',              // optional
    sender:    'sender@example.com',     // optional
    recipient: 'recipient@example.com',  // optional
    tags:      'welcome',                // optional — comma-separated
    fromTime:  '2026-01-01T00:00:00Z',   // optional — RFC3339
    toTime:    '2026-01-31T23:59:59Z',   // optional — RFC3339
);

foreach ($result['data'] as $message) {
    echo $message->id . ': ' . $message->subject;
}

// $result['pagination'] contains has_more / next_cursor / previous_cursor

Cancel a scheduled message

$cancelled = $messages->cancel('msg-scheduled-001'); // true on success

SMTP Credentials

Manage programmatic SMTP credentials via SmtpCredentialService.

use GraystackIT\Ahasend\Services\SmtpCredentialService;

class MyController
{
    public function __construct(private readonly SmtpCredentialService $smtp) {}
}

Create an SMTP credential

// Global credential (can send from any domain)
$credential = $smtp->create('My Application');

// Scoped credential (restricted to specific domains)
$credential = $smtp->create(
    name:    'My Application',
    scope:   'scoped',
    domains: ['yourdomain.com', 'anotherdomain.com'],
);

// Sandbox credential (no real emails sent)
$credential = $smtp->create('Test App', sandbox: true);

// Save the password — the API will not return it again.
echo $credential->id;       // 'cred-xyz'
echo $credential->username; // 'smtp_my_application'
echo $credential->password; // 'generated-secret' (only available on create)
echo $credential->host;     // 'send.ahasend.com' (EU) or 'send-us.ahasend.com' (US)
echo $credential->port;     // 587 — also available: 25, 2525 (STARTTLS required; port 465 is not supported)

List all SMTP credentials

Uses cursor-based pagination:

$credentials = $smtp->list(
    limit:  10,           // optional
    after:  'cursor-xyz', // optional
    before: 'cursor-abc', // optional
);

foreach ($credentials as $cred) {
    echo $cred->id . ': ' . $cred->name;
}

Get a single SMTP credential

$credential = $smtp->get('cred-xyz');

Delete an SMTP credential

$smtp->delete('cred-xyz'); // true on success

Suppressions

Manage the suppression list via SuppressionService.

use GraystackIT\Ahasend\Services\SuppressionService;

class MyController
{
    public function __construct(private readonly SuppressionService $suppressions) {}
}

Add a suppression

$suppression = $suppressions->create(
    email:     'user@example.com',
    expiresAt: '2026-12-31T00:00:00Z', // RFC3339 datetime — required
    reason:    'User unknown',          // optional
    domain:    'example.com',           // optional — restrict to a sending domain
);

echo $suppression->id;     // 'sup-xyz'
echo $suppression->email;  // 'user@example.com'

List suppressions

Uses cursor-based pagination, plus optional filters:

$result = $suppressions->list(
    limit:    50,                    // optional
    after:    'cursor-xyz',          // optional
    before:   'cursor-abc',          // optional
    domain:   'example.com',         // optional — filter by sending domain
    email:    'user@example.com',    // optional — filter by recipient email
    fromTime: '2026-01-01T00:00:00Z', // optional — RFC3339, created after
    toTime:   '2026-01-31T23:59:59Z', // optional — RFC3339, created before
);

foreach ($result['data'] as $suppression) {
    echo $suppression->email;
}

// $result['meta'] contains cursor pagination info

Delete a specific suppression

Deletes by email (optionally scoped to a sending domain):

$suppressions->delete('user@example.com'); // true on success
$suppressions->delete('user@example.com', domain: 'example.com'); // scoped to one domain

Delete all suppressions

Optionally scoped to a single sending domain:

$suppressions->deleteAll(); // true on success — wipes the entire account list
$suppressions->deleteAll(domain: 'example.com'); // wipes only suppressions for this domain

Reports

Retrieve analytics data via ReportService.

use GraystackIT\Ahasend\Services\ReportService;

class MyController
{
    public function __construct(private readonly ReportService $reports) {}
}

All date/time parameters use RFC3339 format (e.g. 2024-01-01T00:00:00Z). Every report method returns a list of time-bucketed entries (one per group_by interval — hour, day, week, or month; defaults to day), matching AhaSend's statistics response shape. Note: AhaSend rate-limits these three statistics endpoints to 1 request/second with no burst allowance, versus 100 req/sec elsewhere — avoid tight polling loops.

Bounce statistics

$buckets = $reports->bounceStatistics(
    fromTime:         '2024-01-01T00:00:00Z', // optional
    toTime:           '2024-01-31T23:59:59Z', // optional
    senderDomain:     'gmail.com',             // optional — filter by sending domain
    recipientDomains: 'gmail.com,outlook.com', // optional — comma-separated
    tags:             'transactional',         // optional — comma-separated
    groupBy:          'day',                   // optional — hour, day, week, month
);

foreach ($buckets as $bucket) {
    echo $bucket->fromTimestamp . '' . $bucket->toTimestamp . "\n";

    foreach ($bucket->bounces as $bounce) {
        echo "  {$bounce['classification']}: {$bounce['count']}\n";
    }
}

Deliverability breakdown

$buckets = $reports->deliverabilityBreakdown(
    fromTime:         '2024-01-01T00:00:00Z', // optional
    toTime:           '2024-01-31T23:59:59Z', // optional
    senderDomain:     'yourdomain.com',        // optional
    recipientDomains: 'gmail.com,outlook.com', // optional — comma-separated
    tags:             'transactional,welcome', // optional — comma-separated
    groupBy:          'day',                   // optional — hour, day, week, month
);

foreach ($buckets as $bucket) {
    echo $bucket->fromTimestamp . ': ' . $bucket->deliveredCount . ' delivered, ' . $bucket->bouncedCount . ' bounced';
}

Delivery time analytics

$buckets = $reports->deliveryTimeAnalytics(
    fromTime:         '2024-01-01T00:00:00Z', // optional
    toTime:           '2024-01-31T23:59:59Z', // optional
    senderDomain:     'yahoo.com',             // optional
    recipientDomains: 'yahoo.com',             // optional — comma-separated
    tags:             'transactional',         // optional — comma-separated
    groupBy:          'day',                   // optional — hour, day, week, month
);

foreach ($buckets as $bucket) {
    echo $bucket->fromTimestamp . ': avg ' . $bucket->avgDeliveryTime . 's across ' . $bucket->deliveredCount . ' messages';

    foreach ($bucket->deliveryTimes as $byDomain) {
        echo "  {$byDomain['recipient_domain']}: {$byDomain['delivery_time']}s ({$byDomain['count']} messages)\n";
    }
}

Error handling

All service methods throw AhasendException on API errors. The exception wraps the Saloon RequestException and exposes the HTTP status code.

use GraystackIT\Ahasend\Exceptions\AhasendException;

try {
    $messages->get('nonexistent-id');
} catch (AhasendException $e) {
    echo $e->getCode();    // 404
    echo $e->getMessage(); // "Ahasend API error [404]: ..."
}

Testing

composer test

License

MIT