Writing a Mail Module
A Mail module is the driver that delivers e-mail: it implements the methods the notification helper calls on every message.
Overview
There is no MailModule base class: the type is duck typed. Core loads the class named after the module directory and calls a fixed set of methods on it.
Exactly one Mail module is active, named in modules/mail. The module writes that key when the operator ticks enable.
Start from SampleMail: it implements the whole contract and writes messages to disk.
Prerequisites
- Write access to
coremio/modules/Mail/. - A transport reachable from the server: SMTP, or an HTTP API with a key.
- The settings form: The Admin Form Builder.
- Background: How Notification Templates Work.
Structure
The directory name, the file name and the class name are the same string. That is the whole registration mechanism.
coremio/modules/Mail/Acme/
├── Acme.php the driver class, named Acme, no namespace
├── config.php returns an array: meta + the saved settings
├── lang/en.php returns a flat key => string array
├── lang/tr.php
├── logo.png optional, referenced from config meta.logo
└── pages/ optional, settings.php as an alternative to page_settings()
The class carries no namespace. The first line is the direct access guard.
Walkthrough
Building the Driver Class
- Create
coremio/modules/Mail/Acme/Acme.php, open it with the access guard and declareclass Acme. - In the constructor, load the module's settings and strings, then merge the optional override array.
- Implement
subject,body,AddAddressandaddAttachmentso each returns$this. - Implement
getSubject,getBodyandgetAddressesfor the delivery log. - Implement
submit(): truthy on success, or set$this->errorand return false.
The Settings Page
- Add
page_settings()returning the form. Core prefers it overpages/settings.php. - Post three hidden fields: the operation, the controller name and the module name.
- Add the enable checkbox, ticked when the stored active driver equals your class.
- Add
controller_save(): write changed fields intoconfig.php, then flipmodules/mail. - Optionally add
controller_test_connection()and a button that reposts the form with that controller.
Activating and Verifying
- Open
{admin}/modules/mail, pick your module, fill in credentials and tick enable. - Saving writes the class name into
modules/mail, which disables the previous driver. - Trigger a notification. During development use SampleMail and read the captured file.
Reference
What Core Calls, and From Where
These are the only methods the helper uses.
| Method | When core calls it | Must return |
|---|---|---|
__construct($external_config = []) | Once per message | nothing |
body($text, $template, $variables, $lang, $user) | First | $this |
subject($arg) | After body, if the caller overrides it | $this |
addAttachment($path, $name) | Once per attachment | $this |
AddAddress($address, $name) | Last, once per recipient | $this |
submit($isthis = false) | After the recipient is added | truthy on success, false on failure |
getSubject() | After a successful submit (log row) | string |
getBody() | After a successful submit (log row) | string |
getAddresses() | After a successful submit (log row) | flat array of addresses |
$error (public property) | After a falsy submit | the failure text |
Two methods are optional: set_credentials(array $data) overrides the saved credentials, and setFromEmail() plus setFromName() override the sender.
Driver Method Signatures
Copy these signatures literally: core calls some of them with fewer arguments than they accept.
public function __construct($external_config = []);
// $text the raw body, used as is when $template is false
// $template "group/name", e.g. "invoice/invoice-created"; false skips rendering
// $variables the placeholder map handed to the template
// $lang the recipient's language code, not the operator's
// $user the recipient's user id, or 0 for an address with no account
public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0);
// Resets the recipient list before setting the subject. That is deliberate, see Pitfalls.
public function subject($arg = '');
// $arg1 is either an address string, or a map of address => name.
public function AddAddress($arg1 = '', $arg2 = '');
public function addAttachment($path = '', $name = '');
public function setFromEmail($email = '');
public function setFromName($name = '');
public function set_credentials($data = []);
// $isthis = true returns the driver instead of the boolean, so the call can chain.
public function submit($isthis = false);
public function getSubject();
public function getBody();
public function getAddresses();
public function address_reset();
When $template is truthy, the driver builds the message itself. The call and its return shape are fixed:
// View::notifications($type, $template_name, $content, $variables, $lang, $user): array
$look = View::notifications("mail", $template, $text, $variables, $lang, $user);
// Returns ['subject' => '...', 'content' => '...'], or false when the template is missing.
if ($look !== false && isset($look["subject"]) && isset($look["content"])) {
$this->subject($look["subject"]);
$text = $look["content"];
}
The Config File and Its Keys
config.php returns a plain array. meta is read by the module list; other keys are yours.
name key wins.
logo.svg, logo.webp, logo.png.
femail, which is why setFromEmail() exists.
Crypt::encode($v, Config::get("crypt/user")), read back with Crypt::decode(). Never paste a plaintext key into config.php.
How a Settings Submission Reaches You
The form posts operation=module_controller. That operation loads the module, then resolves the controller name in two steps.
| Step | Looked for | Result |
|---|---|---|
| 1 | controllers/{controller}.php in the module, larger than 5 bytes | included; its return becomes the response |
| 2 | controller_{controller}() on the instance, hyphens as underscores | runs in a try/catch; its return becomes the response |
| fallback | neither exists | ['status' => 'error', 'message' => 'Module controller not found'] |
So controller=test-connection reaches controller_test_connection(). It may throw; the resolver catches it. Return ['status' => 'successful', 'message' => '...'].
Example
A complete driver, then the core code that drives it.
<?php
defined('CORE_FOLDER') OR exit('You can not get in here!');
class Acme
{
public $error = null;
public $lang = [];
public $config = [];
public $credentials;
private $subject = '';
private $body = '';
private $addresses = [];
private $attachments = [];
public function __construct($external_config = [])
{
$config = Modules::Config('Mail', __CLASS__);
$this->lang = Modules::Lang('Mail', __CLASS__);
$this->config = array_merge($config ?: [], $external_config);
}
public function set_credentials($data = [])
{
$this->credentials = $data;
return $this;
}
public function subject($arg = '')
{
// Clearing here is what keeps the dispatcher's recipient loop from accumulating.
$this->address_reset();
$this->subject = (string) $arg;
return $this;
}
public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0)
{
if ($template) {
$look = View::notifications('mail', $template, $text, $variables, $lang, $user);
if ($look !== false && isset($look['subject']) && isset($look['content'])) {
$this->subject($look['subject']);
$text = $look['content'];
}
}
$this->body = (string) $text;
return $this;
}
public function setFromEmail($email = '')
{
$this->config['from'] = $email;
return $this;
}
public function setFromName($name = '')
{
$this->config['fname'] = $name;
return $this;
}
public function AddAddress($arg1 = '', $arg2 = '')
{
if (is_array($arg1)) foreach ($arg1 as $address => $name) $this->addresses[$address] = $name;
else $this->addresses[$arg1] = $arg2;
return $this;
}
public function addAttachment($path = '', $name = '')
{
$this->attachments[] = ['path' => $path, 'name' => $name];
return $this;
}
public function getAddresses()
{
return array_keys($this->addresses);
}
public function getSubject()
{
return $this->subject;
}
public function getBody()
{
return $this->body;
}
public function address_reset()
{
$this->addresses = [];
return true;
}
public function submit($isthis = false)
{
$config = $this->credentials ?: $this->config;
$key = Crypt::decode($config['api_key'] ?? '', Config::get("crypt/user"));
$payload = [
'from' => ['email' => $config['from'] ?? '', 'name' => $config['fname'] ?? ''],
'to' => array_map(fn ($a, $n) => ['email' => $a, 'name' => $n], array_keys($this->addresses), $this->addresses),
'subject' => $this->subject,
'html' => $this->body,
];
$response = Utility::HttpRequest([
'url' => 'https://api.example.com/v1/send',
'type' => 'POST',
'data' => Utility::jencode($payload),
'header' => ['Authorization: Bearer ' . $key, 'Content-Type: application/json'],
]);
$decoded = Utility::jdecode((string) $response, true) ?: [];
$sent = (string) ($decoded['status'] ?? '') === 'queued';
// The dispatcher reads $this->error after a falsy return; it does not catch throws here.
if (!$sent) $this->error = $decoded['message'] ?? 'Acme refused the message.';
return $isthis ? $this : $sent;
}
public function controller_save(): array
{
$from = (string) Filter::init("POST/from", "email");
$fname = (string) Filter::init("POST/fname", "hclear");
$apiKey = (string) Filter::init("POST/api_key", "password");
if (!$from) throw new Exception($this->lang['error-from-required'] ?? 'Sender address is required.');
$sets = [];
if ($from !== ($this->config['from'] ?? '')) $sets['from'] = $from;
if ($fname !== ($this->config['fname'] ?? '')) $sets['fname'] = $fname;
// The form shows a mask for a stored key; the mask must never overwrite the real value.
if ($apiKey !== '*****' && $apiKey !== Crypt::decode($this->config['api_key'] ?? '', Config::get("crypt/user")))
$sets['api_key'] = Crypt::encode($apiKey, Config::get("crypt/user"));
if ($sets) {
$merged = array_replace_recursive($this->config, $sets);
$write = FileManager::file_write(__DIR__ . DS . "config.php", Utility::array_export($merged, ['pwith' => true]));
if (!$write) throw new Exception('Failed to save settings');
}
$status = (bool) (int) Filter::init("POST/status", "numbers");
$current = Config::get("modules/mail") == __CLASS__;
if ($current != $status) {
$modules = Config::get("modules");
$modules['mail'] = $status ? __CLASS__ : 'none';
Config::save("modules", Config::set("modules", $modules));
}
return ['status' => "successful", 'message' => $this->lang['settings-save-successful'] ?? 'Saved'];
}
}
// Called with no module name, Load resolves the ACTIVE driver from modules/mail.
Modules::Load("Mail");
$mailModule = Config::get("modules/mail");
$mail = $mailModule && $mailModule !== 'none' ? new $mailModule() : false;
// One pass per recipient. Note the order: body, subject, attachments, address, submit.
foreach ($adminContacts['emails'] as $address => $nameStr) {
$parse = explode("|", (string) $nameStr);
$aLang = $parse[1] ?? $localLang;
$sendMail = $mail->body($body, $templatePath, $variables, $aLang);
if ($subject) $mail->subject($subject);
if ($attachments) foreach ($attachments as $fn => $fname) $sendMail->addAttachment($fn, $fname);
$sendMail = $sendMail->addAddress($address, $parse[0] ?? '')->submit();
if ($sendMail) LogManager::Mail_Log(0, $reason, $mail->getSubject(), $mail->getBody(), implode(",", $mail->getAddresses()));
else $errors['mail'][$address] = $mail->error;
}
Three call sites use this shape: the template dispatcher, the queue worker and the bulk sender.
Pitfalls
All four shipped drivers call address_reset() first inside subject(). The dispatcher reuses one instance per recipient, so without it the second recipient also receives the first address.
The dispatch loop has no try/catch and reads $mail->error straight after a falsy return. A throw from submit() aborts the loop and drops the remaining recipients. controller_* methods may throw.
Any other filter strips exactly the characters that make an API key strong. The form shows a stored key as five asterisks, so a save that ignores that literal overwrites the key with the mask.
modules/mail holds a single class name. SampleMail accepts everything, writes each message to temp/sample-mail/ as an EML file, and fails for any recipient whose local part starts with fail.
Related Articles
Thanks for your feedback!
Our support team is here around the clock for anything you can't find above.