# Writing a Mail Module

https://dev.wisecp.com/es/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](https://dev.wisecp.com/en/the-admin-form-builder).
- Background: [How Notification Templates Work](https://dev.wisecp.com/en/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.

```bash
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.

| 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.

```php
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:

```php
// 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.

| 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
<?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'];
    }
}
```

```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`.

## Related Articles

- [How Notification Templates Work](https://dev.wisecp.com/en/how-notification-templates-work)
- [Writing an Email Template](https://dev.wisecp.com/en/writing-an-email-template)
- [Writing an SMS Module](https://dev.wisecp.com/en/writing-an-sms-module)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [The Admin Form Builder](https://dev.wisecp.com/en/the-admin-form-builder)
