Shipping Templates with a Module

4 views Markdown

Ship a notification with your module, so enabling it is all the operator has to do.

Overview

A module that sends its own mail installs two things. The registration puts the template on the Notification Templates screen, where the operator turns it on or off. The files put the subject, the body and the text message where they are read from.

Skip the first and dispatch() returns disabled. Skip the second and the mail goes out with no body and no subject. One call does both.

Notification::seed_templates() Adds the missing registration and writes the parts to their roots. An existing file is never overwritten, so an operator's edit survives every re-enable.
Notification::dispatch() Sends it afterwards. A seeded template is dispatched like a core one.

Prerequisites

  • A module with an enable() path.
  • A group name. Reuse a core group when the mail belongs to that domain, or use your own.
  • Read How Notification Templates Work first. It explains the roots this call writes into.

Structure

Keep the templates inside the module, so they travel and are removed with it. Two source shapes are read; use whichever suits you.

module layout
coremio/modules/Servers/Acme/
├── Acme.php
└── notifications/                       # flat form: one file per part
    ├── en/
    │   ├── acme-quota-reached.json        # {"subject": "..."}
    │   ├── acme-quota-reached.html        # the mail body
    │   └── acme-quota-reached.txt         # the text message
    └── tr/ ...

coremio/modules/Servers/Acme/notifications/    # folder form: adds a per-design body
└── en/acme-quota-reached/
    ├── content.json
    ├── content.txt
    ├── content.html                       # the base design's body
    └── ledger.html                        # the Ledger design's own body

The folder form is what a release package uses, so a module and a release describe a template alike.

Walkthrough

  1. Write the mail body as a fragment. The shell around it comes from the active design.
  2. Write one file per language you support. A language you skip has no message; the operator can fill it in.
  3. Use Smarty placeholders ({$service_name}) and declare them in settings, so the panel offers them.
  4. Call the seeder from enable(). Add a guard that also runs on update: an installation enabled long ago never toggles the module to receive a new template.
  5. Send with dispatch() and read the returned status. disabled means the registration is missing or the operator turned it off.

Reference

signature
static function seed_templates(string $group, array $templates, array $options = []): array
{
    // ...
}
$templates[key]['settings'] The config entry, written only when the key is absent. Anything you omit is defaulted: status 1, user-mail 1, admin-mail/user-sms/admin-sms 0, empty emails/phones/departments. Leave the key out entirely and the config file is not touched at all — for a module that maintains its own entry.
$templates[key]['source'] Directory holding the files. Both shapes above are read; the folder form is tried first because it is the one that can carry a per-design body.
$templates[key]['text'] Inline alternative to source: [lang => ['subject' => …, 'html' => …, 'sms' => …, 'themes' => [design => html]]]. A part you leave out is not written.
$options['themes'] 'base' (default) writes the body to the design that ships with the product and lets every other design fall back to it. 'all' copies that body into each installed design — see the pitfall below before choosing it.
return ['written' => string[], 'skipped' => int, 'registered' => string[]] — paths written this run, files left alone because they already existed, and the group/key pairs added to the config.

Example

coremio/modules/Servers/Acme/Acme.php
private function ensure_notification_template(): void
{
    static $checked = false;
    if ($checked) return;
    $checked = true;

    \Notification::seed_templates('service', [
        'acme-quota-reached' => [
            'settings' => [
                'variables' => '{service_id},{service_name},{quota_usage}',
                'status'    => 1,
                'user-mail' => 1,
            ],
            'source' => __DIR__ . DS . 'notifications',
        ],
    ]);
}
sending it
$this->ensure_notification_template();

$result = \Notification::dispatch('service', 'acme-quota-reached', [
    'user_id'   => (int) $service['owner_id'],
    'variables' => [
        '{service_id}'   => $service['id'],
        '{service_name}' => $service['name'],
        '{quota_usage}'  => $usage . '%',
    ],
]);

// 'disabled' is a decision, not a failure: the operator turned this mail off.
if (($result['status'] ?? '') === 'error') \Logger::error('Acme quota mail failed');

Pitfalls

A design you do not ship is not a gap

A design with no file for the event shows the base body inside its own shell. Copying the body into every design is not the same thing. The copy outranks the fallback, so that theme's author can never ship a design for the mail afterwards.

Registration alone is not enough

A config entry with no files sends an empty mail. The first sign of it is a customer complaint. If you maintain the entry yourself, still call the seeder for the files. Pass no settings key and your entry is left alone.

Never build the path yourself

The parts live in different roots, and the body's root depends on the active design. Code that joins templates/notifications/ to a language folder writes where nothing reads. The write succeeds and the mail stays empty.

Was this helpful?

Thanks for your feedback!

Still Need Help?

Our support team is here around the clock for anything you can't find above.