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
Merci pour votre retour !
Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.