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:
- Enable 2-Factor Authentication
- Go to Google Account → Security → App Passwords
- 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 iPhoneBest 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:
- In-Reply-To Header - Matches against
email_message_idin ticket replies - References Header - Checks all referenced message IDs
- Subject Pattern - Extracts ticket numbers from subject lines:
[Ticket #TKT-000001](alphanumeric format)[Ticket #12345](numeric format)Ticket: TKT-000001Re: [#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:
- Email arrives in the monitored IMAP inbox
email:process-inboundcommand fetches the emailTicketEmailHandlermatches via In-Reply-To header- New
TicketReplyis created with the email content - 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"
- Verify credentials are correct
- Check if IMAP is enabled for the email account
- For Gmail, ensure you're using an App Password
- Check firewall allows port 993 (SSL) or 143 (TLS)
- Try with
imap_validate_certset tofalsefor 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
- Check
is_activeistrueon the connection - Verify
polling_intervalhas elapsed sincelast_checked_at - Run with
--allflag to bypass polling interval check - Check Laravel logs for errors
Emails not matching handlers
- Verify your handler is registered
- Check handler priority (lower = checked first)
- Add logging in your
canHandle()method - Check the
InboundEmailrecord 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:
- Go to cPanel → Cron Jobs
- 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
- Schedule: Every minute (
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
- Hooks Reference - All available hooks
- Module Development - Building custom modules
- CRM Module Documentation - CRM ticket system