perfex-email
Perfex Email System
You are a Perfex CRM email engineer. Your job is to send transactional email reliably from inside modules — using send_simple_email correctly, rendering email-client-safe templates, falling back gracefully when admin recipients aren't configured, and queueing retries for transient SMTP failures so the user flow never breaks.
Perfex has an email templates system (Setup → Email Templates) and a simple-send helper for ad-hoc messages. For module-owned emails that don't need user-editable templates, send_simple_email is the right primitive.
The 3 send paths
| Primitive | When to use |
|---|---|
$this->emails_model->send_simple_email($to, $subject, $body) |
Module-owned emails, admin notifications |
send_mail_template('slug', ...) |
User-editable templates registered via register_merge_fields |
Raw $this->email (CI library) |
Don't. Use one of the above. |
send_simple_email pattern
public function notify_admin($contact_id, $event) {
$this->load->model('emails_model');
$to = $this->admin_email();
if (!$to) return;
$data = [
'contact_name' => get_contact_full_name($contact_id),
'event' => $event,
];
$body = $this->_render_email_template('admin_notification', $data);
try {
$sent = $this->emails_model->send_simple_email(
$to,
_l('my_module_admin_notification_subject'),
$body
);
if (!$sent) {
$this->enqueue_email_retry($to, $subject, $body, $data);
}
} catch (Throwable $e) {
log_message('error', 'my_module: send failed: ' . $e->getMessage());
$this->enqueue_email_retry($to, $subject, $body, $data);
}
}
Email failures MUST NOT fail the user flow. Wrap in try/catch, log the error, continue. The user already completed their action — don't punish them because SMTP blipped.
Admin-recipient fallback chain
When sending "notify the admin", fall back through configured options:
private function admin_email() {
return get_option('my_module_admin_email')
?: get_option('contact_form_notification_email')
?: get_option('smtp_email')
?: null;
}
Remember: get_option('key') ?: fallback — never pass a default as a second arg.
Render email template with merge
Put templates in modules/my_module/views/emails/. Use inline styles and table layout — Gmail strips <style> blocks, <div> grids break in Outlook.
private function _render_email_template($template, array $data = []) {
extract($data);
ob_start();
include(__DIR__ . '/../views/emails/' . $template . '.php');
return ob_get_clean();
}
Template example (views/emails/admin_notification.php):
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f4;padding:24px;">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;padding:32px;font-family:Arial,sans-serif;">
<tr><td>
<h1 style="margin:0 0 16px 0;font-size:20px;color:#111;">
New event from <?= htmlspecialchars($contact_name) ?>
</h1>
<p style="margin:0;color:#333;line-height:1.5;">
<?= htmlspecialchars($event) ?>
</p>
</td></tr>
</table>
</td></tr>
</table>
Always htmlspecialchars() user data. A newline in a name isn't a security issue but a <script> tag in one is.
Retry queue pattern
SMTP can transiently fail. Don't drop the email — enqueue it:
Schema
CREATE TABLE `tblmymodule_email_retries` (
`id` INT NOT NULL AUTO_INCREMENT,
`to_email` VARCHAR(191) NOT NULL,
`subject` VARCHAR(255) NOT NULL,
`body` LONGTEXT NOT NULL,
`context_json` TEXT NULL,
`attempts` TINYINT NOT NULL DEFAULT 0,
`last_error` TEXT NULL,
`next_try_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_next_try` (`next_try_at`, `attempts`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
Enqueue
private function enqueue_email_retry($to, $subject, $body, $context = []) {
$this->db->insert(db_prefix() . 'mymodule_email_retries', [
'to_email' => $to,
'subject' => $subject,
'body' => $body,
'context_json' => json_encode($context),
'attempts' => 0,
'next_try_at' => date('Y-m-d H:i:s', strtotime('+5 minutes')),
'created_at' => date('Y-m-d H:i:s'),
]);
}
Process via cron
Register a Perfex cron hook:
// module_name.php
hooks()->add_action('after_cron_run', 'my_module_process_email_retries');
function my_module_process_email_retries() {
$CI =& get_instance();
$CI->load->model('my_module/my_module_model');
$CI->my_module_model->process_email_retries();
}
In the model:
public function process_email_retries() {
$this->load->model('emails_model');
// Prune > 7 days old
$this->db->where('created_at <', date('Y-m-d H:i:s', strtotime('-7 days')));
$this->db->delete(db_prefix() . 'mymodule_email_retries');
// Grab up to 50 due, attempts < 5
$this->db->where('next_try_at <=', date('Y-m-d H:i:s'));
$this->db->where('attempts <', 5);
$this->db->order_by('next_try_at', 'ASC');
$this->db->limit(50);
$queue = $this->db->get(db_prefix() . 'mymodule_email_retries')->result();
foreach ($queue as $row) {
try {
$sent = $this->emails_model->send_simple_email($row->to_email, $row->subject, $row->body);
if ($sent) {
$this->db->where('id', $row->id)->delete(db_prefix() . 'mymodule_email_retries');
} else {
$this->_bump_retry($row, 'send_simple_email returned false');
}
} catch (Throwable $e) {
$this->_bump_retry($row, $e->getMessage());
}
}
}
private function _bump_retry($row, $error) {
$next_attempt = $row->attempts + 1;
// Exponential backoff: 5min, 15min, 1h, 6h, 24h
$backoffs = [5, 15, 60, 360, 1440];
$minutes = $backoffs[min($next_attempt - 1, count($backoffs) - 1)];
$this->db->where('id', $row->id)->update(db_prefix() . 'mymodule_email_retries', [
'attempts' => $next_attempt,
'last_error' => $error,
'next_try_at' => date('Y-m-d H:i:s', strtotime("+{$minutes} minutes")),
]);
}
Cap at 5 attempts. After that, alert the admin (another email!) or leave for manual review via the prune-at-7-days rule.
SMTP configuration check
If a dev environment has no SMTP configured, send_simple_email silently returns false. In a module's activation hook, warn:
if (!get_option('smtp_host')) {
set_alert('warning', 'My Module: SMTP is not configured. Emails will queue but never send.');
}
Common SMTP pitfalls
The most common reason send_simple_email returns false or throws has nothing to do with your code — it's SMTP configuration. Know these before you debug application logic:
1. smtp_host / smtp_port mismatch gives misleading errors
The Setup → Settings → Email form accepts any value. If smtp_host is right but port is wrong, CI's email library throws generic "SMTP connection failed" errors that look like network problems. Always verify host/port against the provider's docs (Gmail: 587 TLS, Office365: 587 TLS, AWS SES: 587 TLS on regional endpoint). Port 25 almost never works on shared hosting — blocked outbound.
2. Enable mail_debug during initial setup
In application/config/email.php:
$config['mail_debug'] = TRUE;
This surfaces the actual SMTP conversation. Disable again before going live — debug output leaks into error responses if send fails mid-flow.
3. Gmail / Workspace: DMARC rejects From: spoofing
If you set smtp_email = you@gmail.com but From: on the message is noreply@yourapp.com, Gmail's DMARC policy rejects the send entirely. Two fixes:
- Set
From:to match the authenticated SMTP account, OR - Use a provider (SendGrid, AWS SES, Postmark) with domain-authenticated DKIM for
noreply@yourapp.com
Perfex's admin notifications use smtp_email as From: by default — which is why Gmail-based installs sometimes silently stop sending admin emails after a domain config change.
4. $this->email->print_debugger() is your friend
Inside a try/catch around send_simple_email:
try {
$sent = $this->emails_model->send_simple_email($to, $subject, $body);
if (!$sent) {
log_message('error', 'email failed: ' . $this->email->print_debugger(['headers']));
}
} catch (Throwable $e) { /* ... */ }
print_debugger() returns headers + SMTP response codes — the actual reason, not CI's sanitized message.
5. CI's $config['smtp_timeout'] defaults to 5 seconds
Slow providers (AWS SES cold region, mail.com, any TLS handshake over high-latency link) exceed this. Bump in application/config/email.php:
$config['smtp_timeout'] = 30;
Timeout errors look identical to auth errors in Perfex's logs — a too-short timeout is a silent root cause of "email works locally, fails in production."
6. send_simple_email returns false, no exception
CI's email library catches most failure modes and returns false without throwing. Always check the return value AND wrap in try/catch — some errors (bad To: syntax, PHP mail() backend misconfigured) throw, others just silently return false. Your retry-queue code should handle both.
Related skills
perfex-module-dev— registering theafter_cron_runhook that processes the retry queue.perfex-database— DDL fortbl<module>_email_retrieswith the right column types.perfex-security— never log recipient email addresses or message bodies on failure (PII + token leak risk).
Upstream docs
- Perfex email templates: https://help.perfexcrm.com/email-templates/
- CI3 email library (underlying): https://codeigniter.com/userguide3/libraries/email.html
More from yasserstudio/perfex-crm-skills
perfex-security
Use whenever a Perfex CRM task touches security-sensitive code — issuing or consuming single-use tokens (password reset, magic link, confirmation), race-safe atomic UPDATE with `affected_rows()` check, handling user-controlled redirect URLs (`?next=`, `?redirect=`, `?return_to=`), rate-limiting an AJAX endpoint that leaks boolean state, cross-module model loads, logging PII, adding `target="_blank"` links, or excluding a webhook from CSRF. Also trigger when the user says "my magic link works twice", "password reset is racy", "someone can enumerate users by email", "open redirect in my module", "CSRF blocking my webhook", "rate limit this endpoint", or mentions "TOCTOU", "enumeration", `html_purify`, or `app_generate_hash()`. Every rule here exists because its absence caused a real Perfex production incident.
1perfex-core-apis
Use whenever the user is working inside a Perfex CRM codebase and touches `get_option`, `update_option`, `add_option`, `delete_option`, `hooks()`, `do_action`, `apply_filters`, `register_activation_hook`, `$this->load`, `get_instance()`, `$CI`, `db_prefix()`, auth helpers like `is_staff_logged_in` / `get_staff_user_id` / `staff_can`, or `_l()`. Also trigger when the user says "my Perfex get_option returns empty", "the hook isn't firing", "how do I hook into Perfex", "module-wide option", "Perfex helper function", "CI loader inside Perfex", or "$CI doesn't work outside a controller". This skill prevents the #1 Perfex bug — silently using `get_option('key', 'default')` which ignores the second argument.
1perfex-theme
Use whenever the user is building or debugging a Perfex CRM custom client-area theme — files under `assets/themes/<theme>/` and `application/views/themes/<theme>/`, asset injection via `app_customers_head`/`app_customers_footer`/`app_admin_head`/`app_admin_footer` hooks, overriding core views, dark mode with `[data-theme="dark"]` plus anti-FOUC `<head>` scripts, RTL/Arabic support, or the jQuery Validate bug where a submit button's `name` is stripped from POST (breaks "Pay Now" / "Save Draft" detection). Also trigger when the user says "my theme CSS is cached after deploy", "Pay Now button loses its value", "jQuery Validate ate my button name", "client area dark mode", "theme file isn't picked up on Linux", or "FOUC when switching themes".
1perfex-database
Use whenever the user writes SQL DDL for a Perfex CRM module, adds a foreign key referencing `tblcontacts`, `tblstaff`, `tblclients`, `tblinvoices`, or any `tbl*` core table, designs `tbl<module>_<entity>` schema, writes `install.php` / `uninstall.php` DDL, writes a migration or `ALTER TABLE`, or debugs "Cannot add foreign key constraint" / "incompatible" errors. Also trigger when the user says "FK won't create in Perfex", "my module's table has wrong collation", "schema in staging differs from prod", "add a column to my Perfex module table", or mentions `db_prefix()` in a DDL context, `utf8mb4_unicode_ci`, or `VARCHAR(191)` vs `VARCHAR(255)`. Prevents the UNSIGNED-INT-vs-signed-INT trap that silently drops foreign-key constraints pointing at Perfex core tables.
1perfex-module-dev
Use whenever the user is creating, modifying, or debugging a Perfex CRM module — anything under `modules/<module_name>/` including `module_name.php`, `install.php`, `uninstall.php`, controllers extending `AdminController` or `ClientsController`, models extending `App_Model`, views, language files, or menu items via `app_menu->add_sidebar_menu_item`. Also trigger when the user says "my Perfex module won't install", "activation hook not running", "the module doesn't show up in Setup", "controller returns 404", "model not loading in Perfex", "admin menu item not showing", or "build a new Perfex module from scratch". Covers module lifecycle, CI3 controller conventions, and the Linux case-sensitivity trap that silently breaks model loading on production.
1perfex-customfields
Use whenever the user is reading, writing, installing, or debugging Perfex CRM custom fields — `tblcustomfields` (definitions), `tblcustomfieldsvalues` (values keyed by `relid`), field types (`input`, `textarea`, `select`, `multiselect`, `checkbox`, `date`, `datetime`, `link`, `colorpicker`, `file`), `fieldto` values (`contacts`, `customers`, `leads`, `invoice`, `estimate`, `contracts`, `tasks`, `tickets`, etc.), `only_admin` visibility, `show_on_client_portal`, `bs_column`, the intentionally-misspelled `disalow_client_to_edit` column, or `render_custom_fields()`. Also trigger when the user says "my custom field isn't showing in the client portal", "I added a custom field in code but it doesn't appear", "custom field value not saving", "only_admin isn't respected", or "Perfex custom field types". Preserves the `disalow_client_to_edit` typo that Perfex core queries by exact name.
1