Writing a Mail Module

4 vues Markdown

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

Structure

The directory name, the file name and the class name are the same string. That is the whole registration mechanism.

layout
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

  1. Create coremio/modules/Mail/Acme/Acme.php, open it with the access guard and declare class Acme.
  2. In the constructor, load the module's settings and strings, then merge the optional override array.
  3. Implement subject, body, AddAddress and addAttachment so each returns $this.
  4. Implement getSubject, getBody and getAddresses for the delivery log.
  5. Implement submit(): truthy on success, or set $this->error and return false.

The Settings Page

  1. Add page_settings() returning the form. Core prefers it over pages/settings.php.
  2. Post three hidden fields: the operation, the controller name and the module name.
  3. Add the enable checkbox, ticked when the stored active driver equals your class.
  4. Add controller_save(): write changed fields into config.php, then flip modules/mail.
  5. Optionally add controller_test_connection() and a button that reposts the form with that controller.

Activating and Verifying

  1. Open {admin}/modules/mail, pick your module, fill in credentials and tick enable.
  2. Saving writes the class name into modules/mail, which disables the previous driver.
  3. 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.

MethodWhen core calls itMust return
__construct($external_config = [])Once per messagenothing
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 addedtruthy 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 submitthe 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.

exact signatures
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:

template handling inside body()
// 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.

meta.name Display name in the module list. The language file's name key wins.
meta.version Version string shown beside the module. Free form.
meta.logo Logo file name in the module directory. Left out, the list probes logo.svg, logo.webp, logo.png.
fname Sender display name. Every shipped driver uses this exact key.
from Sender address. Mailjet calls it femail, which is why setFromEmail() exists.
Crypt::encode() Secrets are stored encrypted with the install key: write with 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.

StepLooked forResult
1controllers/{controller}.php in the module, larger than 5 bytesincluded; its return becomes the response
2controller_{controller}() on the instance, hyphens as underscoresruns in a try/catch; its return becomes the response
fallbackneither 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.

coremio/modules/Mail/Acme/Acme.php
<?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'];
    }
}
coremio/helpers/notification.php
// 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

subject() clears the recipient list, and that is required

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.

submit() reports failure by returning false, not by throwing

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.

Read the secret with the pass through filter

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.

Enabling your driver disables the previous one

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.

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.