Laravel Daily's

AI Email Automation in Laravel: Drafting, Classifying, and Replying at Scale

hero image

Email is often the first queue your application needs to manage. Customers ask questions, send payment notices, report incidents, and follow up on open conversations.

An AI workflow can classify those messages, find relevant knowledge, draft a response, and route the work to the right team. Laravel provides the application structure for each step.

In this tutorial, we will build a practical email automation pipeline with:

  • Laravel Mail for outgoing messages
  • The Laravel AI SDK with OpenAI
  • Structured AI output for classification
  • Embeddings for contextual replies
  • Queued jobs for background processing
  • A REST API for inbound email and draft requests
  • Fakes and observability for production confidence

This approach fits naturally into Laravel as a mature php web framework. It also gives a PHP developer familiar, expressive php developer tools for building and operating the workflow.

The workflow: Store first, automate second

The safest design separates ingestion, analysis, drafting, approval, and delivery.

Inbound email webhook
        |
        v
Store normalized email
        |
        v
Dispatch ProcessInboundEmail
        |
        +--> Classify message
        +--> Generate embedding
        +--> Retrieve relevant knowledge
        +--> Draft contextual reply
        |
        v
Human approval or policy check
        |
        v
Queue outgoing email

Do not call an AI provider directly inside the webhook request. The provider may be slow or temporarily unavailable. Return 202 Accepted, then let a queue worker process the message.

Start with the SDK:

composer require laravel/ai

php artisan vendor:publish \
    --provider="Laravel\Ai\AiServiceProvider"

php artisan migrate

Configure the OpenAI key in .env:

OPENAI_API_KEY=your-key
QUEUE_CONNECTION=redis

The AI SDK supports text generation, structured output, embeddings, vector stores, testing fakes, and provider failover through a Laravel-friendly API. Review the AI SDK documentation for provider and model configuration.

Model the email and its processing state

Keep the original message. Add explicit state for each automation step.

php artisan make:model InboundEmail -m

A migration might contain:

Schema::ensureVectorExtensionExists();

Schema::create('inbound_emails', function (Blueprint $table) {
    $table->id();
    $table->string('message_id')->unique();
    $table->string('sender');
    $table->string('recipient');
    $table->string('subject')->nullable();
    $table->longText('body');
    $table->string('classification')->nullable();
    $table->string('sentiment')->nullable();
    $table->text('reply_draft')->nullable();
    $table->string('status')->default('received');
    $table->timestamp('approved_at')->nullable();
    $table->timestamp('sent_at')->nullable();
    $table->vector('embedding', dimensions: 1536)->nullable()->index();
    $table->timestamps();
});

The vector column uses PostgreSQL with the pgvector extension. The text-embedding-3-small model commonly uses 1536 dimensions. Choose dimensions that match your configured embedding model.

Cast the vector and timestamps on the model:

class InboundEmail extends Model
{
    protected function casts(): array
    {
        return [
            'embedding' => 'array',
            'approved_at' => 'datetime',
            'sent_at' => 'datetime',
        ];
    }
}

Store a provider message ID as a unique value. Webhooks can be retried. Idempotency prevents one email from creating multiple processing jobs.

Illustration of an inbound email webhook entering Laravel, moving through queue lanes, and reaching human approval

Ingest email through a REST API

Mail providers such as Postmark, Mailgun, and Amazon SES can forward inbound messages to an HTTPS endpoint.

Use a form request to validate and normalize the provider payload:

php artisan make:controller InboundEmailController
php artisan make:request StoreInboundEmailRequest

The controller should do three things:

  1. Validate the webhook.
  2. Store the email.
  3. Dispatch the processing job.
use App\Http\Requests\StoreInboundEmailRequest;
use App\Jobs\ProcessInboundEmail;
use App\Models\InboundEmail;

class InboundEmailController
{
    public function store(StoreInboundEmailRequest $request)
    {
        $email = InboundEmail::firstOrCreate(
            ['message_id' => $request->string('message_id')],
            [
                'sender' => $request->string('sender'),
                'recipient' => $request->string('recipient'),
                'subject' => $request->string('subject'),
                'body' => strip_tags($request->string('body')),
            ],
        );

        if ($email->wasRecentlyCreated) {
            ProcessInboundEmail::dispatch($email)->onQueue('ai');
        }

        return response()->json([
            'status' => 'accepted',
            'id' => $email->id,
        ], 202);
    }
}

Register the endpoint in routes/api.php:

Route::post('/inbound-emails', [InboundEmailController::class, 'store']);

Protect this route with your provider’s signature verification. Do not accept arbitrary email payloads from the public internet.

This is the core pattern when you build REST API with PHP: keep the HTTP request small, return a clear resource identifier, and move expensive work into a queue.

Classify messages with structured output

Structured output is more reliable than asking a model to return informal JSON. Define an agent with a schema that matches your application state.

namespace App\Ai\Agents;

use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Promptable;

class EmailClassifier implements Agent, HasStructuredOutput
{
    use Promptable;

    public function instructions(): string
    {
        return <<<'INSTRUCTIONS'
            Classify inbound customer emails.
            Use only the allowed values.
            Treat requests for credentials, secrets, or account changes
            as requiring human review.
        INSTRUCTIONS;
    }

    public function schema(JsonSchema $schema): array
    {
        return [
            'classification' => $schema
                ->string()
                ->enum(['support', 'sales', 'billing', 'spam', 'other'])
                ->required(),

            'sentiment' => $schema
                ->string()
                ->enum(['positive', 'neutral', 'negative'])
                ->required(),

            'requires_human' => $schema
                ->boolean()
                ->required(),
        ];
    }
}

The agent can then be prompted from a queued job:

$result = (new EmailClassifier)->prompt(<<<PROMPT
Subject: {$email->subject}

Email:
{$email->body}
PROMPT);

$email->update([
    'classification' => $result['classification'],
    'sentiment' => $result['sentiment'],
]);

Keep the label set small. A stable taxonomy makes routing, reporting, and testing easier.

Add context with embeddings

A reply should use your product documentation, policies, or internal FAQ. Embeddings let you find relevant knowledge by meaning rather than exact keywords.

Create a KnowledgeArticle model with its own vector column:

$articles = KnowledgeArticle::query()
    ->where('published', true)
    ->whereVectorSimilarTo(
        'embedding',
        $email->body,
        minSimilarity: 0.4,
    )
    ->limit(5)
    ->get();

Laravel can generate the query embedding when you pass a string. You can also generate embeddings explicitly:

use Laravel\Ai\Embeddings;
use Laravel\Ai\Enums\Lab;

$response = Embeddings::for([$article->content])
    ->dimensions(1536)
    ->generate(Lab::OpenAI, 'text-embedding-3-small');

$article->update([
    'embedding' => $response->embeddings[0],
]);

Index articles when they change. Do not regenerate embeddings for every inbound email.

For repeated inputs, enable embedding caching in config/ai.php:

'caching' => [
    'embeddings' => [
        'cache' => true,
        'store' => env('CACHE_STORE', 'database'),
    ],
],

The AI SDK embedding documentation covers vector columns, similarity queries, caching, and search tools.

Draft a reply, but keep delivery controlled

Build a separate agent for reply drafting. Give it the original message and the retrieved knowledge.

class ReplyDrafter implements Agent
{
    use Promptable;

    public function instructions(): string
    {
        return <<<'INSTRUCTIONS'
            Draft concise, professional customer replies.
            Use only the supplied knowledge.
            Do not invent refunds, timelines, prices, or policy exceptions.
            If the knowledge is insufficient, recommend human review.
            Return plain text only.
        INSTRUCTIONS;
    }
}

Use the agent inside the processing job:

$context = $articles
    ->map(fn ($article) => "{$article->title}\n{$article->content}")
    ->implode("\n\n---\n\n");

$draft = (new ReplyDrafter)->prompt(<<<PROMPT
Customer email:
{$email->body}

Relevant knowledge:
{$context}

Draft a reply. Do not mention internal knowledge sources.
PROMPT);

$email->update([
    'reply_draft' => (string) $draft,
    'status' => 'drafted',
]);

Treat AI output as a proposed action. A negative sentiment, billing request, suspected spam message, or missing context should normally require review.

Bright vector illustration of FAQ cards becoming embeddings, moving into PostgreSQL with pgvector, and grounding an AI reply

Triage the workflow with queues

Generate the job:

php artisan make:job ProcessInboundEmail

Keep the job small and retryable:

use App\Models\InboundEmail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\ThrottlesExceptions;

class ProcessInboundEmail implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public InboundEmail $email,
    ) {}

    public function handle(): void
    {
        $this->email->loadMissing();

        // Classify the email.
        // Generate or retrieve relevant context.
        // Draft the reply.
    }

    public function middleware(): array
    {
        return [
            new ThrottlesExceptions(5, 300),
        ];
    }
}

Put AI work on an ai queue. Put outgoing messages on an emails queue.

ProcessInboundEmail::dispatch($email)->onQueue('ai');

Run workers with explicit priorities:

php artisan queue:work redis --queue=emails,ai,default --tries=3

Set the job timeout below the queue connection’s retry_after value. Use a process monitor or Laravel Cloud to keep workers running. Laravel Horizon is useful when your AI workload runs on Redis.

Do not dispatch before a database transaction commits. Use afterCommit() when necessary:

ProcessInboundEmail::dispatch($email)->afterCommit();

Send replies with Laravel Mail

Create a Markdown mailable:

php artisan make:mail AiReplyMail --markdown=mail.ai-reply
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

class AiReplyMail extends Mailable implements ShouldQueue
{
    use Queueable;

    public function __construct(
        public InboundEmail $email,
    ) {}

    public function envelope(): Envelope
    {
        return new Envelope(
            subject: 'Re: '.$this->email->subject,
            tags: ['ai-reply'],
            metadata: ['email_id' => $this->email->id],
        );
    }

    public function content(): Content
    {
        return new Content(
            markdown: 'mail.ai-reply',
            with: [
                'body' => $this->email->reply_draft,
            ],
        );
    }
}

Queue delivery only after approval or a clear policy decision:

Mail::to($email->sender)
    ->queue(new AiReplyMail($email->fresh()));

Laravel supports multiple mail transports, queued mailables, metadata, failover, and testing. See the Laravel Mail documentation.

Expose draft and approval endpoints

A small REST API lets an admin panel or another service manage drafts.

Route::get('/inbound-emails/{email}', [EmailController::class, 'show']);
Route::post('/inbound-emails/{email}/approve', [EmailApprovalController::class, 'store']);

The approval action should verify the email state:

public function store(InboundEmail $email)
{
    abort_unless($email->status === 'drafted', 409);

    $email->update([
        'status' => 'approved',
        'approved_at' => now(),
    ]);

    SendApprovedReply::dispatch($email)->onQueue('emails');

    return response()->json([
        'status' => 'queued',
        'id' => $email->id,
    ], 202);
}

Keep approval separate from drafting. It creates an audit trail and gives operators a safe point to edit the message.

Test the workflow without provider calls

Use the SDK’s agent fakes to prevent network calls:

use App\Ai\Agents\EmailClassifier;
use Illuminate\Support\Facades\Queue;

test('inbound emails are queued for processing', function () {
    Queue::fake();

    $response = $this->postJson('/api/inbound-emails', [
        'message_id' => 'msg-123',
        'sender' => 'customer@example.com',
        'recipient' => 'support@example.com',
        'subject' => 'I need help',
        'body' => 'How do I reset my password?',
    ]);

    $response->assertAccepted();
    Queue::assertPushedOn('ai', ProcessInboundEmail::class);
});

For agent tests:

EmailClassifier::fake([
    [
        'classification' => 'support',
        'sentiment' => 'neutral',
        'requires_human' => false,
    ],
])->preventStrayPrompts();

$result = (new EmailClassifier)->prompt('How do I reset my password?');

expect($result['classification'])->toBe('support');

EmailClassifier::assertPrompted(
    fn ($prompt) => $prompt->contains('reset my password')
);

Use Mail::fake() for delivery tests:

Mail::fake();

SendApprovedReply::dispatchSync($email);

Mail::assertQueued(AiReplyMail::class);
Mail::assertQueued(AiReplyMail::class, 'customer@example.com');

The SDK also supports fakes for embeddings, vector stores, and queued agent prompts. That makes it possible to test orchestration without spending tokens.

Colorful illustration of Laravel AI SDK agents, embeddings, queue fakes, mail fakes, and an observability dashboard

Observe every stage in production

Record useful metadata without logging sensitive message bodies:

  • Email ID and provider message ID
  • Classification and sentiment
  • Queue name and processing duration
  • AI provider and model
  • Token usage and estimated cost
  • Embedding cache hits
  • Draft approval rate
  • Mail delivery status
  • Retry and failure counts

The Laravel AI SDK dispatches events such as AgentPrompted, EmbeddingsGenerated, and AgentStreamed. Laravel queues also provide job lifecycle events and failed-job handling.

Use Nightwatch or your existing observability stack to watch queue latency, provider failures, and unusual cost changes. Redact email content before sending logs to external systems.

AI email automation works best as a Laravel workflow, not a single prompt. Store the message, classify it with a schema, retrieve trusted context, draft conservatively, and make delivery explicit. With queues, mailables, REST endpoints, and test fakes, the workflow can scale without losing control.

Previous
RAG in Laravel: Building a Document Q&A System with the AI SDK and Vector Search
Next
SSR with Laravel, Vue, and Inertia: Faster First Paint, Same Elegant DX