How Notification Templates Work

7 views Markdown

Every mail and text message the platform sends is built from three files on disk, one set per event and language. A template author owns those files the same way a theme author owns a view.

Overview

A notification is not written in code. Code decides that something happened and hands over the facts; the wording, the markup and the subject line live under templates/notifications. An installation can change what a customer reads without a developer, and a module can ship its own wording.

The unit is the event, written as group/name. Each event owns three files per installed language: the mail body, the text message body and a small file carrying the subject. The mail body is a fragment, not a document.

Two things reach that template. The resolver for the group turns what the caller passed (an invoice row, a service id, a ticket) into named variables. The platform then adds the installation's own values: logos, colours, company details, the recipient's profile. The template only prints.

Structure

The File Layout

Three roots, each answering a different question. A subject line and a text message read the same whatever the mail looks like, so they live outside the designs entirely. The mail body belongs to the design that produces it.

layout
templates/notifications/
├── content/                     # SHARED TEXT — one copy serves every design
│   └── en/invoice/
│       ├── invoice-created.json   # {"subject": "..."}
│       └── invoice-created.txt    # the text message body, no shell at all
├── themes/                      # DESIGN — one directory per installed design
│   ├── aurora/                  # the design that ships with the product
│   │   ├── header.html            # the outermost shell
│   │   ├── content.html           # the body slot, a single {$notifi_body} placeholder
│   │   ├── footer.html            # the closing shell
│   │   └── en/invoice/
│   │       └── invoice-created.html   # the mail body, a FRAGMENT of the shell
│   └── ledger/                  # a second design; may carry only some events
├── custom/                      # the operator's own edits, one tree per design
│   └── ledger/en/invoice/invoice-created.html
└── .htaccess                    # nothing in this tree is served over HTTP

Never build one of these paths by hand; the helpers below are the only place that knows the order. A design carrying no file for an event still sends the mail. The body comes from the base design; the shell stays its own.

View::notification_file() The mail body: custom/{active}themes/{active}themes/aurora. The first file that exists wins.
View::notification_content_file() The subject and the text message, from content/{lang} — no design takes part.
View::notification_write_path() Where an edit is saved: custom/{active} for any design other than the base one. Deleting that file restores the default.

The Shell

A mail body is never sent on its own. The platform concatenates three files into one shell. It assigns the finished event body to notifi_body and builds the shell with the same variables. The shell resolves through the designs in the same order as the body. A design that ships none frames its mails with the base one.

header + content + footer Concatenated in that order and cached per language for the life of the process. The shipped content.html in each language is a single placeholder, so the frame is really the header and the footer.
the event body is a fragment The header opens a table and leaves it open; the event body continues it and the footer closes it. An event body that opens its own document produces markup no mail client will lay out.
text messages get no shell The concatenation is skipped for the text channel. A .txt body is delivered exactly as written, with no logo, footer or contact block.
the subject goes through the engine too The .json file's subject goes through the same engine and the same variables, so it can carry placeholders. It is read for the mail channel only.

Groups and Resolvers

A group is a directory and a section of the configuration file at once. It decides which resolver builds the variables and which notification category the recipient's preferences are checked against.

GroupEvents shippedResolverPreference category
invoice10resolve_invoice_contextinvoices
service11resolve_service_contextproduct
order4resolve_order_contextproduct
domain13resolve_domain_contextdomain
user36resolve_user_contextgeneral
user-tickets8resolve_ticket_contextsupport
admin-tickets4resolve_ticket_contextsupport
admin-messages18resolve_admin_message_contextgeneral
sms-intl3resolve_sms_intl_contextproduct
newsletter2nonenot dispatched, see below
license-transfer3nonenot registered, see below

The last two rows are the two ways an event can exist outside the normal path. newsletter has configuration entries but no resolver, so a dispatch would answer error. Its templates are built directly and pushed onto the queue by the newsletter code, which supplies the variables itself. license-transfer is the opposite: three events sit on disk with no configuration section at all. They are sent through the low-level entry point, which falls back to mail-and-text-on when it finds no settings.

The Delivery Path

From a single call to an actual message the chain is fixed, and every step can end it.

one dispatch, step by step
Notification::dispatch('invoice', 'invoice-created', ['entity' => $invoice])
  1. gate:notification.dispatch          a listener may veto      -> blocked
  2. read notifications/invoice/invoice-created                   -> disabled if absent or off
  3. run the group's resolver: entity -> variables + user_id      -> error if it returns nothing
  4. check the recipient's preference bitmask for the category    -> opted_out
  5. build the recipient list (owner, extra addresses, admins)    -> no_recipients if empty
  6. render per recipient, in THAT recipient's language and channel
  7. deliver now if the event is in the sync list, otherwise queue -> sent | queued
  8. write the in-app rows: the recipient's, and the admin copy
  9. action:notification.dispatched

Step 6 reads the template once per recipient, not once per dispatch. Two recipients of the same event can be reading in different languages and on different channels. The synchronous list at step 7 is short and holds security-critical events; everything else waits for the queue.

Reference

The Two Entry Points

coremio/helpers/notification.php
public static function dispatch(string $group, string $name, array $context = []): array;
public static function send(array $params): array|string|bool;
public static function get_recipients(string $group, string $name, array $context = []): array;
Notification::dispatch() The one to use. It honours the on/off switch, the recipient's preferences and the gate hook, and returns an array whose status says what happened.
Notification::send() The low-level one. It takes a single array and skips the resolver entirely, which means you supply the variables. Legitimate for a body with no template, a raw address list, or the queue worker delivering a row that is already built.
Notification::get_recipients() Runs the first half of a dispatch and stops, on the same arguments: returns who would be written to instead of writing. Useful while wiring up a new event.

What a Dispatch Returns

Never a boolean. The array always carries status, and a successful one also carries batch_id and one entry per recipient under items.

statusMeaningWhere it is decided
queuedRows written to the queue, delivery follows within a minute.normal path
sentDelivered inline, because the event is in the synchronous list or the caller forced it.normal path
blockedA listener vetoed the event.the dispatch gate hook
disabledThe event has no configuration entry, or its switch is off. This is what an unregistered event answers.the configuration file
errorThe group has no resolver, or the resolver returned nothing (unknown invoice, deleted user).the resolver map
opted_outThe recipient's preference bitmask excludes the group's category.the recipient's profile
no_recipientsNobody left after the channel switches and per-channel preferences were applied.the recipient builder

The Template Engine

Which engine parses the files is an installation-wide setting, read from options/notification-template-engine. It ships as Smarty, and every template in the tree is written for it. Two other values exist: Twig, and a plain string replacement mode that understands nothing but {name}.

It is not the theme sandbox and does not behave like one:

no automatic escaping Values print exactly as they arrive, markup included. A theme escapes by default; this one does not. Use the escape modifier on anything a customer typed.
a restricted policy Thirteen platform classes are callable from a template and thirty nine language functions are allowed. Anything else is a compile error, and a compile error has consequences: see the pitfalls.

The Queue

NotificationQueue::add() Takes one row that is already built: channel, recipient, subject, body, attachments, priority, batch id, optional schedule. Returns its id. The dispatch writes one row per recipient.
NotificationQueue::process() Delivers one row through the mail or text module. The panel's manual retry uses this same path, so a row that fails by hand fails the same way in the background.
the scheduled drain Runs every minute, reclaims rows stranded by a crash, and hands out at most two hundred per tick. Because the body was built at dispatch time, editing a template does not change a message already waiting in the queue.

Pitfalls

A missing language file is silence, not an error

The path is built from the recipient's language with no fallback. If the file is not there nothing is produced and the recipient is skipped. The dispatch still reports success for everyone else. Measured in the shipped tree: eight events exist in English and are absent in German.

One bad tag leaves the whole file unrendered

Compilation is all or nothing, and a failure is caught and answered with the raw source. A stray placeholder does not fail on its own line. Every other placeholder in the file ships unrendered too, and the customer receives a message full of visible braces. The warning goes to the log, not to the operator.

Under the base design there is no override layer

The base design ships with the product. An edit made while it is active is written straight over the shipped file, and the next update replaces it. Any other design saves into custom/, which an update cannot touch.

Files alone are not an event

Adding three files gives you nothing. Without a configuration entry the switch cannot be found and the dispatch answers disabled. Without a resolver case the variables never exist, and without a label the panel lists the raw key. The trio is one of several places that have to agree.

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.