Why Build a Custom Email System in CI4
Most email tutorials only show you how to fire off a basic message. A production-ready email system requires structured templates, queue-based delivery for bulk sends, automatic retries for transient failures, and logging for debugging. In this tutorial we build all of it:
- SMTP configuration for Gmail, Mailtrap, and Mailgun
- Compose and send HTML emails with CC/BCC and file attachments
- Reusable email templates with {{variable}} placeholders
- Template management UI (create, preview, delete)
- Email queue for scheduled and bulk sending
- Cron-based queue processor via Spark command
- Retry mechanism for failed emails (max 3 attempts)
- Email logging with full details, error tracking, and DataTables
- Dashboard with delivery statistics

Table Of Content
1 Prerequisites
- PHP 8.1+ with OpenSSL extension enabled
- Composer installed
- MySQL 5.7+ or MariaDB
- CodeIgniter 4.4+ (installed via Composer)
- A Gmail account with App Password OR a Mailtrap account (free tier works)
- Basic knowledge of CI4 MVC structure, controllers, models, and views
2 Project Setup & SMTP Configuration
composer create-project codeigniter4/appstarter ci4-email-system
cd ci4-email-system
Copy the env file to .env and configure it:
cp env .env
Open .env and update these settings:
CI_ENVIRONMENT = development
database.default.hostname = localhost
database.default.database = ci4_email_system
database.default.username = root
database.default.password =
database.default.DBDriver = MySQLi
app.baseURL = "http://localhost:8080"
Create the database:
CREATE DATABASE ci4_email_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Now create the SMTP configuration file app/Config/Email.php:
<?php
namespace Config;
use CodeIgniter\Config\BaseConfig;
class Email extends BaseConfig
{
/*
* SMTP Configuration
* Gmail: host=smtp.gmail.com, port=587, crypto=tls
* Use App Password (not Gmail password)
* https://myaccount.google.com/apppasswords
*
* Mailtrap: host=sandbox.smtp.mailtrap.io, port=2525
*
* Mailgun: host=smtp.mailgun.org, port=587
*/
public string $fromEmail = "noreply@example.com";
public string $fromName = "My App";
public string $protocol = "smtp";
public string $SMTPHost = "sandbox.smtp.mailtrap.io";
public int $SMTPPort = 2525;
public string $SMTPUser = "your-smtp-username";
public string $SMTPPass = "your-smtp-password";
public string $SMTPCrypto = "tls";
public int $SMTPTimeout = 30;
public bool $SMTPKeepAlive = false;
public string $mailType = "html";
public string $charset = "UTF-8";
public string $wordWrap = "true";
public bool $validate = true;
}
For Gmail: Change SMTPHost to smtp.gmail.com, SMTPPort to 587, and use an App Password (generate one at myaccount.google.com/apppasswords). Do not use your regular Gmail password — Google blocks it for third-party apps.For Mailtrap (recommended for development): Sign up at mailtrap.io, create an inbox, and copy the SMTP credentials. All emails are captured in the Mailtrap inbox without actually being delivered — perfect for testing.
Create the attachments upload directory:
mkdir -p writable/uploads/attachments
3 Database & Migration
php spark make:migration CreateEmailTables
Open app/Database/Migrations/xxxx_CreateEmailTables.php and add:
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateEmailTables extends Migration
{
public function up()
{
// Email logs table
$this->forge->addField([
"id" => [
"type" => "INT",
"constraint" => 11,
"unsigned" => true,
"auto_increment" => true,
],
"to_email" => [
"type" => "VARCHAR",
"constraint" => 255,
],
"to_name" => [
"type" => "VARCHAR",
"constraint" => 150,
"null" => true,
],
"cc" => ["type" => "TEXT", "null" => true],
"bcc" => ["type" => "TEXT", "null" => true],
"subject" => [
"type" => "VARCHAR",
"constraint" => 255,
],
"body" => ["type" => "LONGTEXT"],
"template" => [
"type" => "VARCHAR",
"constraint" => 100,
"null" => true,
],
"attachments" => ["type" => "TEXT", "null" => true],
"status" => [
"type" => "ENUM",
"constraint" => ["pending", "queued", "sent", "failed"],
"default" => "pending",
],
"attempts" => ["type" => "TINYINT", "unsigned" => true, "default" => 0],
"max_attempts" => ["type" => "TINYINT", "unsigned" => true, "default" => 3],
"error_message" => ["type" => "TEXT", "null" => true],
"sent_at" => ["type" => "DATETIME", "null" => true],
"scheduled_at" => ["type" => "DATETIME", "null" => true],
"created_at" => ["type" => "DATETIME", "null" => true],
"updated_at" => ["type" => "DATETIME", "null" => true],
]);
$this->forge->addKey("id", true);
$this->forge->addKey("status");
$this->forge->addKey("scheduled_at");
$this->forge->createTable("email_logs");
// Email templates table
$this->forge->addField([
"id" => [
"type" => "INT",
"constraint" => 11,
"unsigned" => true,
"auto_increment" => true,
],
"name" => ["type" => "VARCHAR", "constraint" => 100],
"slug" => ["type" => "VARCHAR", "constraint" => 100],
"subject" => ["type" => "VARCHAR", "constraint" => 255],
"body" => ["type" => "LONGTEXT"],
"variables" => ["type" => "TEXT", "null" => true],
"is_active" => ["type" => "TINYINT", "constraint" => 1, "default" => 1],
"created_at" => ["type" => "DATETIME", "null" => true],
"updated_at" => ["type" => "DATETIME", "null" => true],
]);
$this->forge->addKey("id", true);
$this->forge->addKey("slug", false, true);
$this->forge->createTable("email_templates");
}
public function down()
{
$this->forge->dropTable("email_logs");
$this->forge->dropTable("email_templates");
}
}
Run the migration:
php spark migrate
We create two tables: email_logs tracks every email sent or queued (with status, attempts, error messages, and timestamps), and email_templates stores reusable HTML templates with placeholder variables. 4.1 EmailLogModel
<?php
namespace App\Models;
use CodeIgniter\Model;
class EmailLogModel extends Model
{
protected $table = "email_logs";
protected $primaryKey = "id";
protected $useAutoIncrement = true;
protected $returnType = "array";
protected $useTimestamps = true;
protected $createdField = "created_at";
protected $updatedField = "updated_at";
protected $allowedFields = [
"to_email", "to_name", "cc", "bcc", "subject", "body",
"template", "attachments", "status", "attempts",
"max_attempts", "error_message", "sent_at", "scheduled_at",
];
public function getQueuedEmails(int $limit = 50): array
{
return $this->whereIn("status", ["queued", "pending"])
->where("attempts <", 3)
->where("scheduled_at <=", date("Y-m-d H:i:s"))
->orderBy("created_at", "ASC")
->limit($limit)
->findAll();
}
public function getRetryableEmails(): array
{
return $this->where("status", "failed")
->where("attempts <", 3)
->orderBy("created_at", "ASC")
->findAll();
}
public function markAsSent(int $id): bool
{
return $this->update($id, [
"status" => "sent",
"sent_at" => date("Y-m-d H:i:s"),
]);
}
public function markAsFailed(int $id, string $error): bool
{
$log = $this->find($id);
return $this->update($id, [
"status" => "failed",
"error_message" => $error,
"attempts" => ($log["attempts"] ?? 0) + 1,
]);
}
public function getStats(): array
{
return [
"total" => $this->countAllResults(false),
"sent" => $this->where("status", "sent")->countAllResults(false),
"failed" => $this->where("status", "failed")->countAllResults(false),
"queued" => $this->whereIn("status", ["queued", "pending"])->countAllResults(false),
"today" => $this->where("DATE(created_at)", date("Y-m-d"))->countAllResults(false),
];
}
}
The EmailLogModel handles all database operations for email tracking. The getQueuedEmails() method fetches emails that are ready to be sent (queued status, under 3 attempts, and past their scheduled time). The getStats() method powers the dashboard with real-time delivery statistics. 4.2 EmailTemplateModel
<?php
namespace App\Models;
use CodeIgniter\Model;
class EmailTemplateModel extends Model
{
protected $table = "email_templates";
protected $primaryKey = "id";
protected $useAutoIncrement = true;
protected $returnType = "array";
protected $useTimestamps = true;
protected $createdField = "created_at";
protected $updatedField = "updated_at";
protected $allowedFields = [
"name", "slug", "subject", "body", "variables", "is_active",
];
public function getBySlug(string $slug): ?array
{
return $this->where("slug", $slug)->where("is_active", 1)->first();
}
public function render(string $slug, array $data = []): ?array
{
$template = $this->getBySlug($slug);
if (!$template) return null;
$subject = $template["subject"];
$body = $template["body"];
foreach ($data as $key => $value) {
$subject = str_replace("{{" . $key . "}}", $value, $subject);
$body = str_replace("{{" . $key . "}}", $value, $body);
}
return ["subject" => $subject, "body" => $body, "name" => $template["name"]];
}
public function getActiveTemplates(): array
{
return $this->where("is_active", 1)->orderBy("name", "ASC")->findAll();
}
}
The key method here is render() — it loads a template by slug and replaces all {{variable}} placeholders with the provided data array. This enables a single "Welcome Email" template to be personalized for every user. Templates are stored with a unique slug for easy programmatic access. 5.1 EmailService — Send Email
<?php
namespace App\Libraries;
use App\Models\EmailLogModel;
use App\Models\EmailTemplateModel;
use CodeIgniter\Email\Email;
class EmailService
{
protected Email $email;
protected EmailLogModel $logModel;
protected EmailTemplateModel $templateModel;
public function __construct()
{
$this->email = \Config\Services::email();
$this->logModel = new EmailLogModel();
$this->templateModel = new EmailTemplateModel();
}
public function send(array $params): array
{
$this->email->clear(true);
$this->email->setFrom(
config("Email")->fromEmail ?? "noreply@example.com",
config("Email")->fromName ?? "CI4 App"
);
$this->email->setTo($params["to"]);
$this->email->setSubject($params["subject"]);
$this->email->setMessage($params["body"]);
$this->email->setMailType("html");
if (!empty($params["cc"])) $this->email->setCC($params["cc"]);
if (!empty($params["bcc"])) $this->email->setBCC($params["bcc"]);
// Handle attachments
$attachmentPaths = [];
if (!empty($params["attachments"])) {
foreach ($params["attachments"] as $attachment) {
if (file_exists($attachment)) {
$this->email->attach($attachment);
$attachmentPaths[] = $attachment;
}
}
}
// Log before sending
$logData = [
"to_email" => is_array($params["to"]) ? implode(", ", $params["to"]) : $params["to"],
"to_name" => $params["to_name"] ?? null,
"cc" => $params["cc"] ?? null,
"bcc" => $params["bcc"] ?? null,
"subject" => $params["subject"],
"body" => $params["body"],
"template" => $params["template"] ?? null,
"attachments" => !empty($attachmentPaths) ? json_encode($attachmentPaths) : null,
"status" => "pending",
"attempts" => 1,
];
$logId = $this->logModel->insert($logData);
// Attempt to send
if ($this->email->send(false)) {
$this->logModel->markAsSent($logId);
return ["success" => true, "message" => "Email sent successfully!", "log_id" => $logId];
}
$error = $this->email->printDebugger(["headers", "subject"]);
$this->logModel->markAsFailed($logId, $error);
return ["success" => false, "message" => "Failed to send email.", "error" => $error];
}
The send() method does four things in order: configures the email with SMTP settings from the config, logs the email to the database before sending (so we have a record even if the app crashes), attempts delivery via SMTP, and updates the log with the result. The printDebugger() call captures SMTP error details for troubleshooting. 5.2 Send with Template
public function sendWithTemplate(string $templateSlug, string $to, array $data = [], array $extra = []): array
{
$rendered = $this->templateModel->render($templateSlug, $data);
if (!$rendered) {
return ["success" => false, "message" => "Template not found."];
}
$params = array_merge([
"to" => $to,
"subject" => $rendered["subject"],
"body" => $rendered["body"],
"template" => $templateSlug,
], $extra);
return $this->send($params);
}
Usage is simple — pass the template slug, recipient email, and an array of placeholder values:
$emailService->sendWithTemplate("welcome", "user@example.com", [
"name" => "John Doe",
"app_name" => "My App",
"login_url" => "https://example.com/login",
]);
5.3 Queue & Process
public function queue(array $params, ?string $scheduledAt = null): array
{
$logData = [
"to_email" => is_array($params["to"]) ? implode(", ", $params["to"]) : $params["to"],
"to_name" => $params["to_name"] ?? null,
"cc" => $params["cc"] ?? null,
"bcc" => $params["bcc"] ?? null,
"subject" => $params["subject"],
"body" => $params["body"],
"template" => $params["template"] ?? null,
"attachments" => !empty($params["attachments"]) ? json_encode($params["attachments"]) : null,
"status" => "queued",
"attempts" => 0,
"scheduled_at" => $scheduledAt ?? date("Y-m-d H:i:s"),
];
$logId = $this->logModel->insert($logData);
return ["success" => true, "message" => "Email queued successfully.", "log_id" => $logId];
}
public function processQueue(int $batchSize = 50): array
{
$emails = $this->logModel->getQueuedEmails($batchSize);
$results = ["sent" => 0, "failed" => 0, "total" => count($emails)];
foreach ($emails as $emailLog) {
$this->email->clear(true);
$this->email->setFrom(config("Email")->fromEmail, config("Email")->fromName);
$this->email->setTo($emailLog["to_email"]);
$this->email->setSubject($emailLog["subject"]);
$this->email->setMessage($emailLog["body"]);
$this->email->setMailType("html");
if (!empty($emailLog["cc"])) $this->email->setCC($emailLog["cc"]);
if (!empty($emailLog["bcc"])) $this->email->setBCC($emailLog["bcc"]);
if (!empty($emailLog["attachments"])) {
$attachments = json_decode($emailLog["attachments"], true);
foreach ($attachments as $path) {
if (file_exists($path)) $this->email->attach($path);
}
}
$this->logModel->update($emailLog["id"], ["attempts" => $emailLog["attempts"] + 1]);
if ($this->email->send(false)) {
$this->logModel->markAsSent($emailLog["id"]);
$results["sent"]++;
} else {
$error = $this->email->printDebugger(["headers"]);
$this->logModel->markAsFailed($emailLog["id"], $error);
$results["failed"]++;
}
}
return $results;
}
The queue() method saves the email to the database with a "queued" status and an optional scheduled_at timestamp. The processQueue() method is called by a cron job — it fetches a batch of queued emails, sends each one via SMTP, and updates their status. This approach prevents your application from blocking during bulk email sends and keeps your web requests fast. 5.4 Retry Failed Emails
public function retry(int $logId): array
{
$emailLog = $this->logModel->find($logId);
if (!$emailLog) {
return ["success" => false, "message" => "Email log not found."];
}
if ($emailLog["status"] !== "failed") {
return ["success" => false, "message" => "Only failed emails can be retried."];
}
if ($emailLog["attempts"] >= $emailLog["max_attempts"]) {
return ["success" => false, "message" => "Maximum retry attempts reached."];
}
$this->logModel->update($logId, [
"status" => "queued",
"error_message" => null,
]);
return ["success" => true, "message" => "Email re-queued for retry."];
}
} // End of EmailService
The retry mechanism changes a failed email's status back to "queued" so the next cron run will pick it up. The max_attempts guard (default 3) prevents infinite retry loops — once an email has failed 3 times, it stays in "failed" status and must be manually investigated. 6 Email Controller
<?php
namespace App\Controllers;
use App\Libraries\EmailService;
use App\Models\EmailLogModel;
use App\Models\EmailTemplateModel;
use CodeIgniter\HTTP\ResponseInterface;
class EmailController extends BaseController
{
protected EmailService $emailService;
protected EmailLogModel $logModel;
protected EmailTemplateModel $templateModel;
public function __construct()
{
$this->emailService = new EmailService();
$this->logModel = new EmailLogModel();
$this->templateModel = new EmailTemplateModel();
}
public function index()
{
$data = [
"title" => "Email Dashboard",
"stats" => $this->logModel->getStats(),
"recent" => $this->logModel->orderBy("created_at", "DESC")->limit(5)->findAll(),
"templates" => $this->templateModel->getActiveTemplates(),
];
return view("emails/dashboard", $data);
}
public function compose()
{
$data = [
"title" => "Compose Email",
"templates" => $this->templateModel->getActiveTemplates(),
];
return view("emails/compose", $data);
}
public function send(): ResponseInterface
{
$rules = [
"to" => "required|valid_email",
"subject" => "required|max_length[500]",
"body" => "required",
];
if (!$this->validate($rules)) {
return $this->response->setJSON([
"success" => false,
"message" => implode(", ", $this->validator->getErrors()),
])->setStatusCode(422);
}
$params = [
"to" => $this->request->getPost("to"),
"to_name" => $this->request->getPost("to_name"),
"cc" => $this->request->getPost("cc") ?: null,
"bcc" => $this->request->getPost("bcc") ?: null,
"subject" => $this->request->getPost("subject"),
"body" => $this->request->getPost("body"),
];
// Handle attachments
$attachments = [];
$files = $this->request->getFileMultiple("attachments");
if ($files) {
foreach ($files as $file) {
if ($file->isValid() && !$file->hasMoved()) {
$newName = $file->getRandomName();
$file->move(WRITEPATH . "uploads/attachments/", $newName);
$attachments[] = WRITEPATH . "uploads/attachments/" . $newName;
}
}
}
$params["attachments"] = $attachments;
$sendType = $this->request->getPost("send_type") ?? "now";
if ($sendType === "queue") {
$scheduledAt = $this->request->getPost("scheduled_at") ?? date("Y-m-d H:i:s");
$result = $this->emailService->queue($params, $scheduledAt);
} else {
$result = $this->emailService->send($params);
}
return $this->response->setJSON($result);
}
public function retry(int $id): ResponseInterface
{
return $this->response->setJSON($this->emailService->retry($id));
}
public function templates()
{
$data = [
"title" => "Email Templates",
"templates" => $this->templateModel->getActiveTemplates(),
];
return view("emails/templates", $data);
}
}
The controller ties the EmailService library to the web interface. The send() method validates input, handles file uploads for attachments, and either sends immediately or queues based on the user's selection. 7 Spark Command (Cron Queue Processor)
<?php
namespace App\Commands;
use App\Libraries\EmailService;
use CodeIgniter\CLI\BaseCommand;
use CodeIgniter\CLI\CLI;
class ProcessEmailQueue extends BaseCommand
{
protected $group = "Email";
protected $name = "email:process-queue";
protected $description = "Process the email queue and send pending emails.";
protected $usage = "email:process-queue [batch_size]";
protected $arguments = [
"batch_size" => "Number of emails to process per run (default: 50)",
];
public function run(array $params)
{
$batchSize = (int) ($params[0] ?? 50);
CLI::write("Processing email queue...", "yellow");
CLI::write("Batch size: {$batchSize}", "white");
CLI::newLine();
$emailService = new EmailService();
$results = $emailService->processQueue($batchSize);
CLI::write("Total processed: {$results[\"total\"]}", "white");
CLI::write("Sent: {$results[\"sent\"]}", "green");
CLI::write("Failed: {$results[\"failed\"]}", "red");
CLI::newLine();
if ($results["total"] === 0) {
CLI::write("No emails in queue.", "yellow");
} else {
CLI::write("Queue processing complete!", "green");
}
}
}
Test it manually from the terminal:
php spark email:process-queue 50
Set up a cron job to run automatically every 5 minutes:
*/5 * * * * cd /path/to/project && php spark email:process-queue 50
The Spark command leverages CI4's CLI framework. It reads queued emails in configurable batch sizes, processes each one, and outputs color-coded results to the console. In production, the cron job handles this automatically. 8 Routes Configuration
$routes->group("emails", function ($routes) {
$routes->get("/", "EmailController::index");
$routes->get("compose", "EmailController::compose");
$routes->post("send", "EmailController::send");
$routes->get("templates", "EmailController::templates");
$routes->get("template/(:num)", "EmailController::template/$1");
$routes->post("retry/(:num)", "EmailController::retry/$1");
$routes->get("queue-status", "EmailController::queueStatus");
$routes->get("logs", "EmailController::logs");
$routes->get("logs-data", "EmailController::logsData");
});
All email routes are grouped under the /emails prefix. The dashboard lives at /emails, compose form at /emails/compose, and template management at /emails/templates. The logs-data endpoint serves DataTables AJAX requests for server-side pagination. 9 Views (Dashboard, Compose & Templates)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= esc($title) ?></title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
</head>
<body class="bg-light">
<nav class="navbar navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="/emails"><i class="bi bi-envelope"></i> CI4 Email System</a>
<div>
<a href="/emails/compose" class="btn btn-primary btn-sm"><i class="bi bi-pencil-square"></i> Compose</a>
<a href="/emails/templates" class="btn btn-outline-light btn-sm"><i class="bi bi-file-text"></i> Templates</a>
</div>
</div>
</nav>
<div class="container mt-4">
<div class="row g-3 mb-4">
<div class="col-md-3">
<div class="card text-center border-0 shadow-sm">
<div class="card-body">
<h3 class="text-primary"><?= $stats["total"] ?></h3>
<small class="text-muted">Total Emails</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center border-0 shadow-sm">
<div class="card-body">
<h3 class="text-success"><?= $stats["sent"] ?></h3>
<small class="text-muted">Sent</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center border-0 shadow-sm">
<div class="card-body">
<h3 class="text-danger"><?= $stats["failed"] ?></h3>
<small class="text-muted">Failed</small>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center border-0 shadow-sm">
<div class="card-body">
<h3 class="text-warning"><?= $stats["queued"] ?></h3>
<small class="text-muted">In Queue</small>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-header bg-white">
<h5 class="mb-0"><i class="bi bi-clock-history"></i> Recent Emails</h5>
</div>
<div class="card-body">
<table class="table table-hover">
<thead>
<tr><th>To</th><th>Subject</th><th>Status</th><th>Date</th></tr>
</thead>
<tbody>
<?php foreach ($recent as $log): ?>
<tr>
<td><?= esc($log["to_email"]) ?></td>
<td><?= esc($log["subject"]) ?></td>
<td><span class="badge bg-<?= $log["status"] === "sent" ? "success" : ($log["status"] === "failed" ? "danger" : "warning") ?>"><?= ucfirst($log["status"]) ?></span></td>
<td><?= date("M d, H:i", strtotime($log["created_at"])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
The dashboard displays four stat cards (total, sent, failed, queued) and a recent emails table with color-coded status badges. The compose and templates views follow the same Bootstrap 5 layout pattern with forms for email composition and template management. 10 Folder Structure
11 Run and Test
php spark serve
Open your browser and navigate to http://localhost:8080/emails.Test cases to verify:
1. Send a test email (Mailtrap recommended)
→ Go to /emails/compose, fill in a recipient, subject, and body, click Send. Check your Mailtrap inbox
2. Send using a template
→ Select the "Welcome Email" template, fill in the placeholder values, and send. Verify the rendered HTML arrives correctly
3. Queue an email
→ Select "Add to Queue" as the send type. The email should appear with "queued" status on the dashboard
4. Process the queue
→ Run php spark email:process-queue in the terminal. Queued emails should be sent and status updated
5. Test with invalid SMTP credentials
→ Use wrong credentials to trigger a failure. The email should be marked as "failed" with the SMTP error stored
6. Retry a failed email
→ Fix SMTP credentials, then click Retry on a failed email. It should be re-queued and sent on the next processing run
7. Check the dashboard stats
→ The stat cards should reflect accurate counts for total, sent, failed, and queued emails
8. Send with attachments
→ Attach a file in the compose form and send. The attachment should arrive with the email in Mailtrap
12 Conclusion
- SMTP configuration supporting Gmail, Mailtrap, and Mailgun
- Compose and send HTML emails with CC, BCC, and file attachments
- Reusable email templates with {{variable}} placeholder substitution
- Template management interface for creating and previewing templates
- Email queue system for background and scheduled delivery
- Custom Spark command for cron-based queue processing
- Automatic retry mechanism for failed emails (max 3 attempts)
- Complete email logging with status tracking and error messages
- Dashboard with real-time delivery statistics
- Database-backed architecture for reliability and auditability
This module is designed to be self-contained and reusable. You can drop it into any CI4 project by copying the library, models, controller, command, migration, and views. For further enhancements, consider adding rate limiting for SMTP connections, HTML email builder with drag-and-drop blocks, bounce and complaint handling via webhooks, and integration with transactional email services like SendGrid or Amazon SES.
Written by Revathi M
PHP Developer & Technical Writer · 10+ years building web applications with CodeIgniter and Laravel
Revathi specializes in PHP backend development, authentication systems, and REST API design. She writes practical, production-tested tutorials at Get Sample Code to help developers build secure applications faster.
Frequently Asked Questions
Gmail requires an App Password for third-party SMTP access. Go to myaccount.google.com/apppasswords, generate a 16-character App Password, and use it as your SMTPPass in the Email config. Make sure 2-Factor Authentication is enabled on your Google account first, as App Passwords require it.
Sending immediately delivers the email during the current web request, which blocks the user until the SMTP transaction completes. Queuing saves the email to the database with a queued status and returns instantly. A cron job then processes the queue in the background, making it ideal for bulk sends and keeping your application responsive.
When an email fails to send, its status is set to failed and the attempt count is incremented. Emails with fewer than 3 attempts can be retried by changing their status back to queued. The next cron run picks them up and tries again. After 3 failed attempts, the email stays in failed status and requires manual investigation via the logs.
Yes. You can skip the queue entirely and send all emails immediately using the send() method instead of queue(). However, for bulk email sending or production applications, the queue approach is recommended because it prevents timeouts and keeps your web application fast.
Insert a new row into the email_templates table with a unique slug, the HTML body containing placeholders like name and app_name, and a JSON array of variable names. You can then send emails using that template by calling sendWithTemplate() with the slug and a data array.
No, Mailtrap does not send real emails. It captures all outgoing SMTP traffic in a virtual inbox where you can inspect the HTML, headers, and attachments. This makes it perfect for development and testing without accidentally emailing real users. The free tier allows up to 100 emails per month.
