Writing an SMS Template

3 views Markdown

The text message body of an event is a single plain file with no shell around it. That makes it the shortest template to write, and the easiest one to fill with markup by accident.

Overview

Every event carries a .txt alongside its mail body, and that file is the whole message: nothing is prepended or appended. Whatever the file produces is what arrives on the handset.

It is the same engine, the same variables and the same event as the mail. The differences all follow from the channel. There is no frame to inherit context from and no markup to lean on. An account without a mobile number is not a recipient at all.

Prerequisites

an event that already exists A text body is one third of an event, not an event of its own. The configuration entry, the resolver case and the panel label are shared with the mail, so wire those first.
the text channel switched on The file is read only when user-sms or admin-sms is set in the event's configuration entry. With both at 0 the file is inert no matter what it contains.
a mobile number on the account The recipient's number and its dialling code come from the account profile.
a sandbox text module A driver writes each message to disk instead of calling a gateway. That lets you read the exact delivered characters, including the ones a browser would have hidden.

Structure

No Shell, No Markup

The mail path concatenates a header, a body slot and a footer first. The text path skips that entirely, so the file stands alone.

templates/notifications/content/en/service/service-activated.txt, in full
Your service has been activated.

Service Information
------------------------------------
{$service_name} - {$service_amount}

Service Details
------------------------------------
{$service_detail_link}
say who you are The only branding is the sender name the gateway shows and whatever the text says. A message that opens with "your service" and never names the site reads like a stranger's.
plain text, and nothing escapes it The engine does not escape and does not strip. A variable holding rich text prints its tags verbatim into a message that cannot display them.
links are long Detail and payment links are absolute and carry tokens, so one of them can be most of the message. Put it last, on its own line, and do not wrap it in punctuation a handset will swallow into the address.

Which File the Channel Reads

ChannelBody fileShellSubject
mail{name}.htmlheader, content slot, footersubject from the .json, with variables filled in
text message{name}.txtnonenone: the .json is not read for this channel

The engine understands a title key in the .json and would return it for the text channel. The dispatcher does not carry it further, and no shipped template declares one.

Walkthrough

1. Write the Body

  1. Open the event's .txt in every installed language directory. It was created with the trio; if it is missing, the text channel has nothing to read.
  2. Write the message as a customer would want to receive it: what happened, to which service, and where to look. One or two short lines and a link.
  3. Use the same placeholder syntax as the mail body. The engine is the same, so a syntax error fails the same way and leaves the whole file unrendered.
  4. Prefer the short variables. A description field or a ticket message is not sized for this channel even when it fits.

2. Enable the Channel

  1. Set user-sms to 1 in the event's configuration entry for a message to the account holder. Set admin-sms for a copy to staff.
  2. Staff numbers resolve from the event's departments plus the entry's own phone list. They fall back to the root administrator when the switch is on and nothing else resolved.
  3. Leave both at 0 for any event whose text version is not worth a charge. Most events ship that way on purpose.

3. Keep It Plain

  1. Strip anything that might carry markup at the point of printing: a ticket reply, a description, an operator-written note.
  2. Watch the alphabet: one character outside the basic set changes how the whole message is counted.
  3. Keep the wording independent of the mail. The text version is the message for a reader who will never open the mail.

4. Verify the Send

  1. Trigger the real flow with an account that actually has a mobile number, and check the returned status. A no_recipients answer with the mail arriving normally means the number is what is missing.
  2. Read the delivered file from the sandbox module's output directory. It records the sender name, the destination and the exact body, so trailing whitespace and stray tags become visible.
  3. Repeat in the other language. A text body is small enough that a missing translation is easy to overlook and produces silence rather than an error.

Reference

Length and Encoding

The notification path does not measure, split or truncate a text body. It reads the file and hands the string to the module. Any limit is the gateway's, and any cost is per message part.

That arithmetic exists in the platform, in the credit-funded international messaging panel rather than in notification templates. It is still the right model to size a template against.

EncodingWhen it appliesSingle messagePer part once split
basic alphabetevery character is in the standard messaging alphabet160153
wideone single character outside it, anywhere in the message7067
Sms::analyze_message() Returns the encoding, the counted length, the number of parts and whether it had to be cut. Called by the panel's quoting path, never by a notification, so nothing here trims a template for you.
some characters count twice In the basic alphabet a handful of symbols are transmitted as two units. In the wide encoding an emoji counts as two. A body sized by eye is routinely one unit over.
the part ceiling is configurable The panel refuses to go beyond a configured number of parts, six by default. It cuts the text rather than letting the gateway drop it. A notification has no such guard.

What the Module Receives

A text module is handed a body, a destination and a dialling code, and is asked to submit. The notification path uses only this much of its surface.

the calls a text module gets from the delivery path
$sms = new $smsModule();

// The already-rendered body. Passing a template name here instead is the module's
// own shortcut and is what the low-level sender uses; the dispatch never does.
$sms->body($item['body']);

// One recipient, or a list. The dialling code is a separate argument because the
// account stores it separately from the number.
$sms->addNumber($item['recipient'], $item['recipient_cc']);

$sent = $sms->submit();
if (!$sent) $error = $sms->getError();
the sender name is the module's It is set from the module's own configuration when the module is constructed, and the delivery path never overrides it. A template cannot change who the message appears to be from.
the dialling code travels separately Number and code are two fields on the account and two arguments here. A module that concatenates them itself decides its own format. That is why a number that works on one gateway can fail on another.
a successful send is logged with its body The message log keeps the sender name, the text and the destinations. Useful for support, and a reason not to print anything secret into a text body.

Example

The text half of an event, next to the mail half it shares everything else with.

templates/notifications/content/en/service/acme-quota-reached.txt
{$company_name}: {$service_name} has used {$quota_percent}% of its quota.

{* A condition costs nothing in the output, so the message stays one line longer
   only when it has to. Comments like this one are stripped entirely. *}
{if $quota_percent >= 100}New uploads are refused until the allowance is raised.
{/if}
{$service_detail_link}
a body that must survive rich text: strip at the point of printing
{* {$admin_reply} is the reply as it was stored: line breaks were turned into
   newlines, but every other tag the editor produced is still in there. *}
Ticket {$ticket_num} has been answered.

{$admin_reply|strip_tags|truncate:120}

{$ticket_link}
the same dispatch feeds both channels
// One call. Which files are read depends on the recipient's channel, and both
// channels can be produced for the same person in the same dispatch.
$result = \Notification::dispatch('service', 'acme-quota-reached', [
    'entity'  => $service,
    'percent' => 100,
]);

// Every recipient row says which channel it was written for, so a missing text
// message is visible here rather than only in the gateway's report.
foreach ($result['items'] ?? [] as $item)
    if (($item['channel'] ?? '') === 'sms')
        Logger::getInstance()->info('quota notice queued as text', [
            'recipient' => $item['recipient'] ?? '',
            'queue_id'  => $item['queue_id'] ?? 0,
        ]);
restricting one dispatch to the text channel
// Overrides the four channel switches for this call only and drops the staff copy.
// It also bypasses the recipient's category preference, so reserve it for a message
// the account explicitly asked for, such as a verification code.
$result = \Notification::dispatch('user', 'gsm-activation', [
    'entity'          => $userId,
    'code'            => $code,
    '_force_channels' => ['sms'],
    '_sync'           => true,   // deliver inline: the customer is waiting on this
]);

Pitfalls

Rich text reaches the text channel intact

Ticket messages are stored as the editor produced them; only the line breaks are normalised on the way out. Printing one into a text body sends the tags along, and the recipient pays for the characters. Strip at the point of printing rather than trusting the variable.

No number, no recipient, no error

An account without a mobile number never enters the recipient list. The mail still goes out, the dispatch still reports success, and the text message that was never sent leaves no trace. When the text half seems missing, check the account before the template.

One accented letter more than halves the room

The counting switches to the wide encoding as soon as a single character falls outside the basic alphabet. The limit drops from a hundred and sixty to seventy. A translation that reads as long as its English original routinely costs twice as many parts.

An empty body is a decision, so make it one

A file that produces nothing is skipped silently. That is exactly right when the event has no text version, and indistinguishable from a mistake when it does. If an event should not send text, turn the channel off in its configuration entry. An empty file is not a way to say it.

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.