Inbound Email System

Complete guide to LaraDashboard's IMAP-based inbound email processing system for handling incoming emails, ticket replies, and custom email handlers.

Inbound Email System

LaraDashboard includes a powerful IMAP-based inbound email processing system that allows you to receive and process incoming emails. This is particularly useful for helpdesk tickets, customer support, and automated email workflows.

Overview

Key Features

  • IMAP Connectivity - Connect to any IMAP-compatible email server
  • Multiple Connections - Manage multiple inbound email sources
  • Handler System - Modular handlers for different email types
  • Hook Integration - Extend processing with action hooks
  • Email Parsing - Automatic quote and signature stripping
  • Threading Support - Email threading via Message-ID headers

Architecture

┌─────────────────┐     ┌──────────────────┐     ┌─────────────────┐
│   IMAP Server   │────▶│  ImapService     │────▶│  InboundEmail   │
└─────────────────┘     └──────────────────┘     └─────────────────┘
                                                          │
                                                          ▼
                        ┌──────────────────┐     ┌─────────────────┐
                        │  EmailParser     │◀────│ InboundEmail    │
                        │  Service         │     │ Processor       │
                        └──────────────────┘     └─────────────────┘
                                                          │
                                    ┌─────────────────────┼─────────────────────┐
                                    ▼                     ▼                     ▼
                            ┌──────────────┐     ┌──────────────┐     ┌──────────────┐
                            │   Handler 1  │     │   Handler 2  │     │   Handler N  │
                            │ (CRM Ticket) │     │  (Custom)    │     │   (Module)   │
                            └──────────────┘     └──────────────┘     └──────────────┘

Quick Start

1. Create an IMAP Connection

use App\Models\InboundEmailConnection;

$connection = InboundEmailConnection::create([
    'name' => 'Support Inbox',
    'imap_host' => 'imap.gmail.com',
    'imap_port' => 993,
    'imap_encryption' => 'ssl',
    'imap_username' => 'support@yourcompany.com',
    'imap_password' => 'your-app-password',
    'imap_folder' => 'INBOX',
    'is_active' => true,
    'mark_as_read' => true,
    'delete_after_processing' => false,
    'fetch_limit' => 50,
    'polling_interval' => 5,
    'created_by' => auth()->id(),
]);

2. Process Emails

# Process connections due for polling
php artisan email:process-inbound

# Process all active connections
php artisan email:process-inbound --all

# Test IMAP connection
php artisan email:process-inbound --test

3. Automatic Processing (Pre-configured)

LaraDashboard automatically processes inbound emails every 5 minutes via Laravel's scheduler. This is already configured in app/Console/Kernel.php:

$schedule->command('email:process-inbound')
    ->everyFiveMinutes()
    ->withoutOverlapping()
    ->runInBackground()
    ->appendOutputTo(storage_path('logs/inbound-email.log'));

Configuration

Connection Settings

Field Type Description
name string Display name for the connection
imap_host string IMAP server hostname
imap_port integer IMAP port (default: 993)
imap_encryption string ssl, tls, or none
imap_username string IMAP login username
imap_password string IMAP password (encrypted)
imap_folder string Folder to monitor (default: INBOX)
imap_validate_cert boolean Validate SSL certificate
is_active boolean Enable/disable connection
mark_as_read boolean Mark processed emails as read
delete_after_processing boolean Delete emails after processing
fetch_limit integer Max emails per batch
polling_interval integer Minutes between polls
email_connection_id integer Link to outbound email connection

Gmail Configuration

For Gmail, use an App Password:

  1. Enable 2-Factor Authentication
  2. Go to Google Account → Security → App Passwords
  3. Generate a password for "Mail"
$connection = InboundEmailConnection::create([
    'name' => 'Gmail Support',
    'imap_host' => 'imap.gmail.com',
    'imap_port' => 993,
    'imap_encryption' => 'ssl',
    'imap_username' => 'support@gmail.com',
    'imap_password' => 'xxxx-xxxx-xxxx-xxxx', // App Password
    'imap_folder' => 'INBOX',
    'created_by' => auth()->id(),
]);

Microsoft 365 / Outlook

$connection = InboundEmailConnection::create([
    'name' => 'Outlook Support',
    'imap_host' => 'outlook.office365.com',
    'imap_port' => 993,
    'imap_encryption' => 'ssl',
    'imap_username' => 'support@yourcompany.com',
    'imap_password' => 'your-password',
    'imap_folder' => 'Inbox',
    'created_by' => auth()->id(),
]);

Creating Custom Handlers

Handlers determine how incoming emails are processed. The CRM module includes a TicketEmailHandler for ticket replies, but you can create handlers for any purpose.

Handler Interface

<?php

declare(strict_types=1);

namespace Modules\YourModule\Services\InboundEmail;

use App\Contracts\InboundEmailHandlerInterface;
use App\Contracts\InboundEmailHandlerResult;
use App\Models\InboundEmail;

class YourEmailHandler implements InboundEmailHandlerInterface
{
    /**
     * Unique identifier for this handler.
     */
    public function getHandlerType(): string
    {
        return 'yourmodule.handler';
    }

    /**
     * Display name for this handler.
     */
    public function getName(): string
    {
        return 'Your Module Email Handler';
    }

    /**
     * Priority (lower = higher priority).
     * Handlers are tried in priority order.
     */
    public function getPriority(): int
    {
        return 20;
    }

    /**
     * Determine if this handler can process the email.
     * Return true to claim this email.
     */
    public function canHandle(InboundEmail $email): bool
    {
        // Example: Check for specific subject pattern
        return str_contains($email->subject ?? '', '[YourModule]');
    }

    /**
     * Process the email and return result.
     */
    public function handle(InboundEmail $email): InboundEmailHandlerResult
    {
        try {
            // Get the parsed reply content (quotes stripped)
            $content = $email->getBodyContent();

            // Create your model/record
            $record = YourModel::create([
                'from_email' => $email->from_email,
                'from_name' => $email->from_name,
                'subject' => $email->subject,
                'content' => $content,
            ]);

            return InboundEmailHandlerResult::success(
                message: 'Email processed successfully',
                modelType: YourModel::class,
                modelId: $record->id,
            );
        } catch (\Throwable $e) {
            return InboundEmailHandlerResult::failure($e->getMessage());
        }
    }
}

Registering Handlers

Register your handler in your module's service provider:

<?php

declare(strict_types=1);

namespace Modules\YourModule\Providers;

use App\Services\InboundEmailProcessor;
use Illuminate\Support\ServiceProvider;
use Modules\YourModule\Services\InboundEmail\YourEmailHandler;

class YourModuleServiceProvider extends ServiceProvider
{
    public function boot(): void
    {
        $this->app->booted(function () {
            $this->registerInboundEmailHandlers();
        });
    }

    protected function registerInboundEmailHandlers(): void
    {
        // Check if InboundEmailProcessor is available
        if (! $this->app->bound(InboundEmailProcessor::class)) {
            return;
        }

        $processor = $this->app->make(InboundEmailProcessor::class);
        $processor->registerHandler($this->app->make(YourEmailHandler::class));
    }
}

Handler Priority

Handlers are processed in priority order (lowest first). When an email matches multiple handlers, only the first matching handler processes it.

Priority Use Case
1-9 Critical system handlers
10-19 Core feature handlers (e.g., CRM tickets)
20 Default priority
21-30 Module handlers
31+ Fallback/catch-all handlers

Email Matching Strategies

Method 1: In-Reply-To Header

Match emails by their In-Reply-To header against known message IDs:

public function canHandle(InboundEmail $email): bool
{
    if (empty($email->in_reply_to)) {
        return false;
    }

    // Check if we have a record with this message ID
    return YourModel::where('email_message_id', $email->in_reply_to)->exists();
}

Method 2: References Header

Check the References header for threading:

public function canHandle(InboundEmail $email): bool
{
    $references = $email->getReferencesArray();

    foreach ($references as $reference) {
        if (YourModel::where('email_message_id', $reference)->exists()) {
            return true;
        }
    }

    return false;
}

Method 3: Subject Pattern

Match by subject line patterns:

public function canHandle(InboundEmail $email): bool
{
    $subject = $email->subject ?? '';

    // Match "[Ticket #TKT-000001]" or "[Ticket #123]" patterns
    $patterns = [
        '/\[Ticket\s*#?(TKT-\d+)\]/i',  // Alphanumeric: TKT-000001
        '/\[Ticket\s*#?(\d+)\]/i',       // Numeric only: 12345
    ];

    foreach ($patterns as $pattern) {
        if (preg_match($pattern, $subject, $matches)) {
            return Ticket::where('ticket_number', $matches[1])->exists();
        }
    }

    return false;
}

Method 4: Sender Email

Match by sender email address:

public function canHandle(InboundEmail $email): bool
{
    return Customer::where('email', $email->from_email)->exists();
}

Email Parsing

The EmailParserService automatically strips quoted content and signatures from email replies.

How It Works

use App\Services\EmailParserService;

$parser = app(EmailParserService::class);

// Parse plain text reply
$cleanContent = $parser->parseReply($email->body_plain);

// Parse HTML reply (converts to text first)
$cleanContent = $parser->parseHtmlReply($email->body_html);

Patterns Detected

Quote Patterns:

  • On [date], [name] wrote:
  • > quoted text
  • --- Original Message ---
  • Gmail quote blocks
  • Outlook reply headers

Signature Patterns:

  • -- (standard signature delimiter)
  • Sent from my iPhone
  • Best regards,
  • Thanks,

Using Parsed Content

The InboundEmail model provides convenient methods:

// Get best available content (parsed > plain > stripped HTML)
$content = $email->getBodyContent();

// Access individual fields
$email->body_plain;   // Original plain text
$email->body_html;    // Original HTML
$email->body_parsed;  // Parsed reply content (quotes stripped)

Hooks

The inbound email system provides hooks for extensibility.

Available Hooks

Hook Type Description
BEFORE_PROCESS Action Before processing starts
PROCESS Action Main processing hook
AFTER_PROCESS Action After successful processing
PROCESS_FAILED Action When processing fails
UNMATCHED Action When no handler matches

Hook Examples

use App\Enums\InboundEmailHook;
use App\Support\Facades\Hook;

// Log all incoming emails
Hook::addAction(InboundEmailHook::BEFORE_PROCESS, function ($email) {
    Log::info('Processing inbound email', [
        'from' => $email->from_email,
        'subject' => $email->subject,
    ]);
});

// Send notification after successful processing
Hook::addAction(InboundEmailHook::AFTER_PROCESS, function ($email, $handler, $result) {
    // Notify admin of new email processed
    Admin::notify(new InboundEmailProcessedNotification($email, $result));
});

// Handle unmatched emails (create new tickets)
Hook::addAction(InboundEmailHook::UNMATCHED, function ($email) {
    // Create a new ticket from unmatched email
    Ticket::create([
        'title' => $email->subject ?? 'Email inquiry',
        'description' => $email->getBodyContent(),
        'customer_email' => $email->from_email,
        'customer_name' => $email->from_name,
        'source' => 'email',
    ]);
});

// Alert on failures
Hook::addAction(InboundEmailHook::PROCESS_FAILED, function ($email, $errorMessage) {
    Log::error('Inbound email processing failed', [
        'email_id' => $email->id,
        'error' => $errorMessage,
    ]);

    // Send alert to admin
    AdminNotification::send('Inbound email failed: ' . $errorMessage);
});

Artisan Command Reference

email:process-inbound

Process inbound emails from IMAP connections.

# Process connections due for polling
php artisan email:process-inbound

# Process all active connections
php artisan email:process-inbound --all

# Process specific connection by ID
php artisan email:process-inbound --connection=1

# Process specific connection by UUID
php artisan email:process-inbound --connection=550e8400-e29b-41d4-a716-446655440000

# Test connection without processing
php artisan email:process-inbound --test

# Test specific connection
php artisan email:process-inbound --connection=1 --test

Output Example

Processing inbound email connections due for polling...

+----+----------------+---------+-----------+--------+--------+
| ID | Name           | Fetched | Processed | Failed | Errors |
+----+----------------+---------+-----------+--------+--------+
| 1  | Support Inbox  | 5       | 4         | 1      | -      |
| 2  | Sales Inbox    | 3       | 3         | 0      | -      |
+----+----------------+---------+-----------+--------+--------+

Summary: Fetched 8, Processed 7, Failed 1

CRM Integration

The CRM module includes built-in support for processing ticket replies via email.

How Ticket Matching Works

The TicketEmailHandler matches emails to tickets using:

  1. In-Reply-To Header - Matches against email_message_id in ticket replies
  2. References Header - Checks all referenced message IDs
  3. Subject Pattern - Extracts ticket numbers from subject lines:
    • [Ticket #TKT-000001] (alphanumeric format)
    • [Ticket #12345] (numeric format)
    • Ticket: TKT-000001
    • Re: [#TKT-000001]

Note: The handler supports both alphanumeric ticket formats (e.g., TKT-000001) and numeric-only formats (e.g., 12345).

Sender Verification

For subject-based matching, the handler also verifies the sender:

  • Sender email must match the ticket's customer_email, OR
  • Sender email must match the ticket's associated contact email

This prevents unauthorized users from adding replies to tickets they don't own.

Setting Up Ticket Email Threading

When sending ticket notifications, include the message ID for threading:

// In your ticket notification mailable
public function build()
{
    $messageId = '<ticket-' . $this->ticket->id . '-reply-' . $this->reply->id . '@' . config('app.domain') . '>';

    return $this->subject("[Ticket #{$this->ticket->ticket_number}] {$this->ticket->title}")
        ->view('emails.ticket-reply')
        ->withSymfonyMessage(function ($message) use ($messageId) {
            $message->getHeaders()->addTextHeader('Message-ID', $messageId);
        });
}

// Save the message ID for future matching
$reply->update(['email_message_id' => $messageId]);

Customer Replies

When a customer replies to a ticket email:

  1. Email arrives in the monitored IMAP inbox
  2. email:process-inbound command fetches the email
  3. TicketEmailHandler matches via In-Reply-To header
  4. New TicketReply is created with the email content
  5. Ticket status is updated if it was closed/resolved

Database Schema

inbound_email_connections

CREATE TABLE inbound_email_connections (
    id BIGINT PRIMARY KEY,
    uuid VARCHAR(36) UNIQUE,
    name VARCHAR(255),
    imap_host VARCHAR(255),
    imap_port INT DEFAULT 993,
    imap_encryption VARCHAR(10) DEFAULT 'ssl',
    imap_username VARCHAR(255),
    imap_password TEXT,  -- encrypted
    imap_folder VARCHAR(255) DEFAULT 'INBOX',
    imap_validate_cert BOOLEAN DEFAULT TRUE,
    delete_after_processing BOOLEAN DEFAULT FALSE,
    mark_as_read BOOLEAN DEFAULT TRUE,
    fetch_limit INT DEFAULT 50,
    polling_interval INT DEFAULT 5,
    email_connection_id BIGINT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    last_checked_at TIMESTAMP NULL,
    last_check_status VARCHAR(20) NULL,
    last_check_message TEXT NULL,
    emails_processed_count INT DEFAULT 0,
    created_by BIGINT,
    updated_by BIGINT NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

inbound_emails

CREATE TABLE inbound_emails (
    id BIGINT PRIMARY KEY,
    uuid VARCHAR(36) UNIQUE,
    inbound_email_connection_id BIGINT,
    message_id VARCHAR(255) NULL,
    in_reply_to VARCHAR(255) NULL,
    `references` TEXT NULL,
    from_email VARCHAR(255),
    from_name VARCHAR(255) NULL,
    to_email VARCHAR(255) NULL,
    to_name VARCHAR(255) NULL,
    cc TEXT NULL,  -- JSON
    subject VARCHAR(255) NULL,
    email_date TIMESTAMP NULL,
    body_plain LONGTEXT NULL,
    body_html LONGTEXT NULL,
    body_parsed LONGTEXT NULL,
    attachments JSON NULL,
    status VARCHAR(20) DEFAULT 'pending',
    processing_error TEXT NULL,
    processed_at TIMESTAMP NULL,
    handler_type VARCHAR(100) NULL,
    handler_model_type VARCHAR(255) NULL,
    handler_model_id BIGINT NULL,
    raw_headers LONGTEXT NULL,
    imap_uid VARCHAR(50) NULL,
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

Local Development & Testing

Testing with Mailpit

Mailpit is commonly used for local email testing but does not provide IMAP by default. Here's how to test the inbound email system locally:

Option 1: Manual Handler Testing (Recommended)

Test your handlers directly without IMAP by creating fake InboundEmail records:

php artisan tinker

use App\Models\InboundEmail;
use Illuminate\Support\Str;

// Create a test connection first
$connection = \App\Models\InboundEmailConnection::firstOrCreate(
    ['name' => 'Local Test'],
    [
        'imap_host' => 'localhost',
        'imap_port' => 993,
        'imap_encryption' => 'ssl',
        'imap_username' => 'test@example.com',
        'imap_password' => 'test',
        'is_active' => true,
        'created_by' => 1,
    ]
);

// Create a fake inbound email (simulating a ticket reply)
$ticket = \Modules\Crm\Models\Ticket::first();

$email = InboundEmail::create([
    'inbound_email_connection_id' => $connection->id,
    'message_id' => '<test-' . Str::random(10) . '@example.com>',
    'from_email' => $ticket->customer_email, // Must match for sender verification
    'from_name' => 'Test Customer',
    'to_email' => 'support@yourapp.com',
    'subject' => 'Re: [Ticket #' . $ticket->ticket_number . '] ' . $ticket->title,
    'body_text' => 'This is my test email reply to the ticket.',
    'body_html' => '<p>This is my test email reply to the ticket.</p>',
    'status' => 'pending',
    'received_at' => now(),
]);

// Test the handler
$handler = app(\Modules\Crm\Services\InboundEmail\TicketEmailHandler::class);

if ($handler->canHandle($email)) {
    $result = $handler->handle($email);
    dump('Success: ' . $result->message);
    dump('Created model ID: ' . $result->modelId);
} else {
    dump('Handler cannot handle this email');
}

Option 2: Use a Real IMAP Provider

For full end-to-end testing, use an IMAP-capable service:

Provider Free Tier Notes
Gmail Yes Enable IMAP, use App Password
Mailtrap Yes Good for staging environments
MailSlurp Yes Free tier with IMAP support
Zoho Mail Yes Free IMAP for personal use

Option 3: Docker Mail Server

Run a complete mail server locally:

# docker-compose.yml
version: '3'
services:
  mailserver:
    image: mailserver/docker-mailserver:latest
    hostname: mail.local
    ports:
      - "143:143"   # IMAP
      - "993:993"   # IMAPS
      - "25:25"     # SMTP
      - "587:587"   # Submission
    volumes:
      - ./docker-data/mailserver:/var/mail
    environment:
      - ENABLE_IMAP=1

Verifying Handler Registration

Check which handlers are registered:

$processor = app(\App\Services\InboundEmailProcessor::class);
dd($processor->getHandlerStats());

// Output:
// [
//   'crm.ticket' => [
//     'name' => 'CRM Ticket Reply Handler',
//     'priority' => 10
//   ]
// ]

Testing the Complete Flow

// 1. Create test email
$email = InboundEmail::create([...]);

// 2. Process with the full processor (runs all handlers)
$processor = app(\App\Services\InboundEmailProcessor::class);
$processed = $processor->processEmail($email);

// 3. Check results
$email->refresh();
dump([
    'status' => $email->status,
    'handler_type' => $email->handler_type,
    'handler_model_id' => $email->handler_model_id,
    'processing_error' => $email->processing_error,
]);

Troubleshooting

Connection Issues

"Failed to connect to IMAP server"

  1. Verify credentials are correct
  2. Check if IMAP is enabled for the email account
  3. For Gmail, ensure you're using an App Password
  4. Check firewall allows port 993 (SSL) or 143 (TLS)
  5. Try with imap_validate_cert set to false for self-signed certs

"PHP IMAP extension is not installed"

Install the PHP IMAP extension:

# Ubuntu/Debian
sudo apt-get install php-imap
sudo systemctl restart php-fpm

# macOS with Homebrew
brew install php-imap

Processing Issues

Emails not being processed

  1. Check is_active is true on the connection
  2. Verify polling_interval has elapsed since last_checked_at
  3. Run with --all flag to bypass polling interval check
  4. Check Laravel logs for errors

Emails not matching handlers

  1. Verify your handler is registered
  2. Check handler priority (lower = checked first)
  3. Add logging in your canHandle() method
  4. Check the InboundEmail record for proper headers

Debugging

// Check registered handlers
$processor = app(\App\Services\InboundEmailProcessor::class);
dd($processor->getHandlerStats());

// Manually test email matching
$email = \App\Models\InboundEmail::find(1);
foreach ($processor->getHandlers() as $handler) {
    dump($handler->getHandlerType() . ': ' . ($handler->canHandle($email) ? 'YES' : 'NO'));
}

Production Deployment

Setting Up Cron (Required)

For inbound emails to work automatically, you must set up a cron job. This is how the system knows to check for new emails periodically.

Standard Server / VPS

Add this single cron entry to your server's crontab:

# Edit crontab
crontab -e

# Add this line (runs Laravel scheduler every minute)
* * * * * cd /path/to/your/project && php artisan schedule:run >> /dev/null 2>&1

The Laravel scheduler will then run email:process-inbound every 5 minutes automatically.

Shared Hosting (cPanel, Plesk, etc.)

Most shared hosts have a "Cron Jobs" section in their control panel:

  1. Go to cPanelCron Jobs
  2. Add a new cron job:
    • Schedule: Every minute (* * * * *) or every 5 minutes (*/5 * * * *)
    • Command: cd /home/username/public_html && /usr/local/bin/php artisan schedule:run >> /dev/null 2>&1

Note: The PHP path may vary. Common paths:

  • /usr/local/bin/php
  • /usr/bin/php
  • /opt/cpanel/ea-php83/root/usr/bin/php

If your host doesn't allow every-minute crons, use:

# Every 5 minutes
*/5 * * * * cd /home/username/public_html && php artisan email:process-inbound >> /dev/null 2>&1

Verify Cron is Working

Check the log file after a few minutes:

tail -f storage/logs/inbound-email.log

Command Options for Production

# Standard: Process connections due for polling (respects polling_interval)
php artisan email:process-inbound

# Force process all active connections
php artisan email:process-inbound --all

# For Gmail "All Mail" folder (fetches recent, not just unread)
php artisan email:process-inbound --recent --days=7

# Process specific connection
php artisan email:process-inbound --connection=1

Gmail All Mail Folder

If using Gmail's "All Mail" folder instead of INBOX (useful when emails get auto-archived), use the --recent flag:

// In app/Console/Kernel.php, modify the schedule:
$schedule->command('email:process-inbound --recent --days=1')
    ->everyFiveMinutes()
    ->withoutOverlapping()
    ->runInBackground();

This fetches the most recent emails from the last N days, regardless of read status.

Performance Considerations

Setting Recommendation
fetch_limit 20-50 per batch (avoid timeouts)
polling_interval 5-15 minutes (balance responsiveness vs load)
delete_after_processing false (keep emails for debugging)
Cron frequency Every 5 minutes is usually sufficient

Monitoring

Check inbound email processing status:

// Recent inbound emails
InboundEmail::latest()->take(10)->get(['id', 'subject', 'status', 'handler_type']);

// Failed emails
InboundEmail::where('status', 'failed')->get();

// Connection health
InboundEmailConnection::get(['name', 'last_checked_at', 'last_check_status', 'last_check_message']);

Requirements

  • PHP 8.2+
  • PHP IMAP extension
  • Laravel 12+
  • Cron for scheduled processing (required for production)

Next Steps

/