Guide 09 · Developer docs

Automation, jobs and integration

A job is a trigger, conditions and an ordered list of actions, set up under Customization › Orchestration in the admin; jobs were called hooks before the module was renamed, and the API's routes keep that name. What starts a job, what it can do, branches and loops, email, scheduled work, integrations with other systems, and every event and action.

L2–L3
Unit 05 · Email

Three worked emails

Email bodies rendered from template files in the theme repository: the constraints of email HTML and why page partials rarely work unchanged, then an order receipt, a completion email that links to a generated certificate, and the platform's password reset. Attachments, and what to send instead.

Who this is for. Theme developers writing the emails a site sends. Afterwards you can write a body that renders in mail clients, uses the job's data correctly in both engines, and links to a generated file.

Keep every body in a template file under templates/hooks/emails/ and point the Send Email action at it. A long body typed into the action is invisible to code review and version history, and it is the first thing that differs between staging and production.

Why the theme's partials rarely work unchanged

  • A job has different variables. No page globals exist in a job: no _currentSite, _request or _currentUser. A header partial that reads one renders empty or fails (Templates in a job).
  • Records are API models. Read a layout area through the active revision, getActiveRevision().getAreaText('intro'); a form submission's answers are getData().
  • A mail client is not a browser. The theme's stylesheet is not loaded, many clients drop a <style> block, relative links point nowhere, and scripts never run.

The rules that follow from it:

  • Lay out with tables about 600 pixels wide, with every style inline on the element it styles.
  • Make every URL absolute: https://{$site->getPrimaryHostname()}/…, the primary hostname of the environment the job runs in, so links from staging point at staging.
  • Give every image an alt and expect it to be blocked: the message must read without images.
  • Put a <body> tag in it, or the message is sent as plain text.
  • Escape every value a customer typed. Smarty needs |escape; Twig on Core dna does not escape by default either, so it needs |e.

An order receipt

The job: entity event on Orders, methods Created and Updated, with {financialStatus} as the Pre-Event property, and the became-paid condition from Guarding against recursion first, so a later edit of the order does not send a second receipt. Inside it, Send Email to {$event->getRecord()->getPerson()->getId()} (a person id, so the mailer's placeholders work), subject Your order {$event->getRecord()->getNumber()}, and this template file:

Template code templates/hooks/emails/order-receipt.html
{$order = $event->getRecord()}
<!DOCTYPE html>
<html><body style="margin:0;padding:24px;background:#f5f5f5;font-family:Arial,sans-serif;font-size:14px;color:#333333;">
<table width="600" cellpadding="24" cellspacing="0" style="background:#ffffff;"><tr><td>
  <p>Hi {$order->getPerson()->getFirstName()|escape},</p>
  <p>Thank you for order {$order->getNumber()|escape}.</p>
  <table width="100%" cellpadding="6" cellspacing="0">
    {foreach $order->getItems() as $item}
    <tr><td>{$item->getName()|escape} &times; {$item->getQuantity()}</td>
        <td align="right">{$item->getTotalAsMoney()->format()}</td></tr>
    {/foreach}
    <tr><td>Shipping</td><td align="right">{$order->getShippingTotalAsMoney()->format()}</td></tr>
    <tr><td>Tax</td><td align="right">{$order->getTaxTotalAsMoney()->format()}</td></tr>
    <tr><td><strong>Total</strong></td><td align="right"><strong>{$order->getTotalAsMoney()->format()}</strong></td></tr>
  </table>
  <p><a href="https://{$site->getPrimaryHostname()}/my-account">View your orders</a></p>
</td></tr></table>
</body></html>
{% set order = event.getRecord() %}
<!DOCTYPE html>
<html><body style="margin:0;padding:24px;background:#f5f5f5;font-family:Arial,sans-serif;font-size:14px;color:#333333;">
<table width="600" cellpadding="24" cellspacing="0" style="background:#ffffff;"><tr><td>
  <p>Hi {{ order.getPerson().getFirstName()|e }},</p>
  <p>Thank you for order {{ order.getNumber()|e }}.</p>
  <table width="100%" cellpadding="6" cellspacing="0">
    {% for item in order.getItems() %}
    <tr><td>{{ item.getName()|e }} &times; {{ item.getQuantity() }}</td>
        <td align="right">{{ item.getTotalAsMoney().format() }}</td></tr>
    {% endfor %}
    <tr><td>Shipping</td><td align="right">{{ order.getShippingTotalAsMoney().format() }}</td></tr>
    <tr><td>Tax</td><td align="right">{{ order.getTaxTotalAsMoney().format() }}</td></tr>
    <tr><td><strong>Total</strong></td><td align="right"><strong>{{ order.getTotalAsMoney().format() }}</strong></td></tr>
  </table>
  <p><a href="https://{{ site.getPrimaryHostname() }}/my-account">View your orders</a></p>
</td></tr></table>
</body></html>

Money is formatted by the money object in the order's currency; do not add a currency symbol by hand.

A completion email with a certificate link

The job: entity event on a custom entity completion, method Created, for records staff or an import create when a learner finishes a course. Its layout has a record area learner (a person) and a text area course. Earlier actions in the job made the certificate and saved it with a Create File named save-certificate (A certificate, end to end). Send Email to {$event->getRecord()->getActiveRevision()->getAreaRecord('learner')->getId()}, subject Your certificate is ready:

Template code templates/hooks/emails/course-completed.html
{$revision = $event->getRecord()->getActiveRevision()}
{$certificate = $runData->get('save-certificate')->file}
<!DOCTYPE html>
<html><body style="margin:0;padding:24px;background:#f5f5f5;font-family:Arial,sans-serif;font-size:14px;color:#333333;">
<table width="600" cellpadding="24" cellspacing="0" style="background:#ffffff;"><tr><td>
  <p>Hi %firstName%,</p>
  <p>You have completed <strong>{$revision->getAreaText('course')|escape}</strong>.</p>
  <p><a href="https://{$site->getPrimaryHostname()}{$certificate->getPath()}">Download your certificate</a></p>
  <p style="font-size:12px;color:#777777;">The link works for 90 days. Save the file to keep it.</p>
</td></tr></table>
</body></html>
{% set revision = event.getRecord().getActiveRevision() %}
{% set certificate = runData.get('save-certificate').file %}
<!DOCTYPE html>
<html><body style="margin:0;padding:24px;background:#f5f5f5;font-family:Arial,sans-serif;font-size:14px;color:#333333;">
<table width="600" cellpadding="24" cellspacing="0" style="background:#ffffff;"><tr><td>
  <p>Hi %firstName%,</p>
  <p>You have completed <strong>{{ revision.getAreaText('course')|e }}</strong>.</p>
  <p><a href="https://{{ site.getPrimaryHostname() }}{{ certificate.getPath() }}">Download your certificate</a></p>
  <p style="font-size:12px;color:#777777;">The link works for 90 days. Save the file to keep it.</p>
</td></tr></table>
</body></html>

%firstName% is filled by the mailer from the person the email is addressed to, which is why the recipient is a person id. If another job creates the completion records, remember that a record created by an asynchronous job starts no Created trigger: chain the two jobs with Trigger job instead.

The password reset

The password reset is not a job. The platform sends it when a member asks for one, from a template file it looks for in the site first: templates/users/emails/reset-password-smarty.html on a Smarty site, templates/users/emails/reset-password-twig.html on a Twig site. The platform ships only the Smarty file, so a Twig site must have its own file, or the reset fails with Neither site nor framework email template file exists. The platform's default is branded for the platform, not the client, so every site should replace it.

The file is not rendered like a job template. On a Smarty site only its placeholders are replaced, and Smarty tags in it reach the member as they are; on a Twig site the placeholders become Twig variables. Write it with placeholders only and the same file works on both:

PlaceholderIs
%resetUrl%The one-time sign-in link. It works for two days, for at most ten uses, and for an hour after its first use
%username%The member's username
%firstName%, %lastName%, %toName%, %email%From the member's person record
%hostname%, %showInBrowserUrl%As on every email
HTML templates/users/emails/reset-password-smarty.html
<!DOCTYPE html>
<html><body style="margin:0;padding:24px;background:#f5f5f5;font-family:Arial,sans-serif;font-size:14px;color:#333333;">
<table width="600" cellpadding="24" cellspacing="0" style="background:#ffffff;"><tr><td>
  <p>Hi %firstName%,</p>
  <p>Someone asked to reset the password for the account <strong>%username%</strong> on %hostname%.</p>
  <p><a href="%resetUrl%">Choose a new password</a></p>
  <p style="font-size:12px;color:#777777;">The link works for two days. If you did not ask for this, ignore this email; your password has not changed.</p>
</td></tr></table>
</body></html>

The subject and the sender are Users settings, under Forgot password email in frontend: Subject (by default Core dna: Password Reset), From email and From name. Set them too, or the member receives a message that names the platform.

Attachments, or a link

A job cannot attach a file. The mailer supports attachments and uses them for its own exports, but the Send Email action has no attachment field. A file an earlier action produced reaches the recipient as a link, as in the completion email above. That is also the answer for any file too large to attach: a link has no size limit and does not count against the recipient's mailbox.

  1. Produce the content: a Render Template (a CSV, an HTML certificate) or an HTTP Request to a service that returns the file.
  2. Save it with Create File, with a path of its own so it outlives the run, a name nobody can guess, and an expiry (Where files go).
  3. Link to it from the body with the path the Create File result gives.
Go deeper

Was this page helpful?

Your answer goes to the docs team.