Notification delivery¶
Defining a notification (see Notifications) resolves who
and what and dispatches a single, channel-neutral event. A delivery
channel turns that event into a real message and decides how it is sent.
This page is that half: the event that is the seam, the shipped channels
(orchestra_mail, orchestra_easy_email), and how to add your own or reach
another medium.
The split is deliberate: audience resolution and the dispatch points live once in the dispatchers, so a channel only has to subscribe and send.
The event¶
One event carries every notification, whatever the point that raised it:
OrchestraNotificationEvent {
recipients // Recipient value objects, already resolved by the dispatcher
type // a template id a channel renders on (task_assigned, interaction_link, generic, ...)
params // structured render data (label, link, message, ...)
instance, token, context // context carries the instance variables and the node
dispositions // optional per-recipient To/Cc/Bcc hint, keyed by recipient key
tokenData // optional extra token contexts, keyed by token type, for a templating channel
getAttachments() // the files to deliver, seeded and enriched
addAttachment() // contribute one before the channels run
isShared() // is this one shared message, or a private copy each?
groupByDisposition() // the To, Cc and Bcc recipient lists, Cc promoted
}
Everything but the attachments is a readonly property. The attachments are
reached through their two methods instead, because any subscriber may add one:
channels subscribe at OrchestraNotificationEvent::DELIVERY_PRIORITY, which
runs after the default-priority subscribers, so a message carries every file
contributed by the time it is sent.
The last two answer the delivery question a mail channel has to ask, so that
both shipped channels ask it rather than each working it out from
dispositions. A Cc or a Bcc means the recipients are meant to see who else
was addressed, so isShared() is TRUE and the channel sends one message with
headers; otherwise it sends a private copy per recipient.
groupByDisposition() then returns the three lists, dropping anyone with no
address on the channel and promoting Cc to To when To is empty, because a
message needs a visible To. The Bcc-only case is left to the caller: there is
nothing to group under, so each blind copy goes out on its own, and the two
channels do that differently enough not to fold it in. A channel with no
notion of dispositions (SMS, chat) ignores both and reaches recipients.
The payload is semantic, never rendered mail: each channel templates it its
own way, in each recipient's language. The orchestra_engine.notification
emitter stamps every event with the process context (the instance variables and
the current node), so a channel can compose the message from process values
without re-reading the engine.
flowchart LR
EV["OrchestraNotificationEvent<br/>type + params + recipients"] --> CH{subscribers}
CH --> MAIL["orchestra_mail<br/>(hook_mail template)"]
CH --> EE["orchestra_easy_email<br/>(Easy Email template)"]
CH --> SUB["custom subscriber"]
CH --> OTHER["SMS / chat / push"]
MAIL --> R["per-recipient render,<br/>each in their own language"]
EE --> R
When a notification is sent¶
A dispatcher does not send: OrchestraNotification::notify() queues the
notification (orchestra_notification), in the step's own database
transaction, so a step that is rolled back sends nothing. What is queued is a
snapshot of what the event is built from as it was when the step ran: the
recipients, the params, the instance variables at that branch's scope, the
place it is rendered in, and the account that made the step. The workflow
moves on, and the message says what was true when it was sent for. When it is
sent, the event is dispatched in that same place and as that same account, so a
template renders the same whether it goes after the response or from cron,
which runs as nobody.
Notification profiles¶
When it goes, and what happens when it cannot, is its notification profile
(orchestra_notification_profile, under Configuration » Workflow » Orchestra
» Notification profiles):
- Sending: before the page is sent (the page waits for it), once the page
has been sent (the default), or by cron after a delay. "Once the page has been
sent" runs on
kernel.terminate, which under PHP-FPM is after the response has reached the browser; on a server without FPM the connection may still be held, and "by cron" with no delay is the choice there. - On failure: how many attempts (the first retry waits a minute, each next
one twice as long), whether the person who acted is warned when a send
before the page fails, and what giving it up leaves: a line in the process's
history (
notification_undeliverable, see Audit), or an incident on the process, which keeps it from completing until an operator sends it again or leaves it unsent.
Three are installed, shared by every tenant:
| Profile | Sending | Attempts | Warns | Given up to |
|---|---|---|---|---|
| Standard | once the page has been sent | 5 (about 15 minutes) | no | history |
| Important | before the page | 10 (about 8.5 hours) | yes | incident |
| Delayed | by cron, after 15 minutes | 5 | no | history |
A site adds its own, shared or for one tenant, as statuses are. A profile can be disabled: nobody new is offered it, and whatever already names it keeps using it. One that anything still names cannot be deleted.
Which profile a notification uses¶
A Notify node and an interaction's Notify on arrival each name a profile, or follow their workflow, which follows its tenant, which follows the site, the same order read access and retention resolve in. Each level has one default per kind of notification, because kinds cost differently when lost. The module sending a kind declares it, with the profile a site gets until it names another:
- Notifications (the engine's own, Standard): Notify nodes, and the emails a task sends when it changes hands or is due, which have no node of their own and start at the workflow.
- Interaction links (declared by
orchestra_interaction, Important): the link sent to someone outside the workflow who must act on a step. It is their only way in, so a lost one blocks the process.
A module declares a kind of its own with
hook_orchestra_notification_kind_info(). The site's defaults are on the
Orchestra settings page; a tenant's and a workflow's on their own
Notifications page, one select per kind the installed modules send. A node's
choice travels with the workflow version a run is pinned to, like the rest of
the node; the workflow's, the tenant's and the site's apply at once, to runs
already started too. A level naming a profile that has since been deleted is
passed over as if it followed, and so is one naming a profile of another tenant
than the run's.
Retried to whom it failed, once, and in order¶
A channel reports on the event which recipients it could not reach
(OrchestraNotificationEvent::reportDeliveryFailure()), and the retry goes to
them alone, so those it reached do not get it twice. A notification already
sent is not sent again when its queue item is handed out a second time; only
a send still under way when its five-minute lease runs out can go twice. The
notifications of one
instance go out in the order they were queued: each waits for the one before it,
for an hour at most, so a lost one cannot hold the others for ever. One sent
after a delay takes no place in that order, since it is late on purpose, and
neither does one sent before the page, which is early on purpose.
An interaction link is sent only while its step is still waiting: one queued
with a delay, or tried again, whose token has left the step by the time its
turn comes is dropped and logged, since the link would open onto nothing. A
dispatcher of your own asks for the same with notify()'s only_while_parked.
Cron sends whatever the request did not: an item whose process died, recipients to try again, and everything a profile leaves to cron. So a site whose notifications matter runs cron regularly.
Orchestra Mail¶
orchestra_mail is a dumb channel: one subscriber to
OrchestraNotificationEvent turns it into email, with the notification type as
the mail key so hook_mail renders the matching template (task_assigned,
task_reminder, task_canceled, interaction_link, and a neutral default for
any custom type).
A send failure is logged on the Orchestra channel, reported on the event so
those recipients are sent it again, and never breaks the run.
Delivery follows the recipients' disposition:
- No Cc/Bcc (all recipients are To, which is every task notification and a simple Notify node): one private mail per recipient, each rendered in their own language. No one sees anyone else's address.
- Any Cc or Bcc present: the notification is a shared message, so it goes as
one email with
To/Cc/Bccheaders, rendered once (in the first recipient's language). With only Bcc recipients and no visible To, the blind copies are sent individually instead, so nothing forces a shared header.
Easy Email¶
Orchestra Easy Email (orchestra_easy_email) is a shipped alternative
channel that renders Easy Email
templates instead of hook_mail. It resolves a template through a most-specific
-first cascade over four dimensions, tenant, workflow, notification type and
an optional orchestra_notification_template_suffix variable, falling back to
orchestra_generic, so a site can theme one type site-wide, per workflow or
per tenant (e.g. per domain). Enable it in place of orchestra_mail; see
its README for the exact candidate ids.
The channel resolves the Orchestra tokens itself and leaves the rest to Easy Email, and every value it substitutes is made inert on the way in: Easy Email renders the same subject and bodies twice more at send time, so token syntax that arrived in a value, a process variable filled from a web form or a label a submitter chose, would otherwise be evaluated by those later passes. A value is data, and it reaches the reader as it was written.
One template can serve many notifications when it reads the node's
values, as
[orchestra:node-value:NAME].
Templates and layouts¶
How to word and lay out these emails with as few templates as possible, one
layout for the whole site, one Easy Email template for most notifications and
the words set on each node, is the subject of its own page:
Easy Email templating. It covers which template an
email uses, the tokens each notification carries, node values, the Twig layout
files and their variables (orchestra and node_values), and a step-by-step
setup.
Run exactly one email channel¶
orchestra_mail and orchestra_easy_email are independent subscribers to the
same event, and they do not know about each other by design: neither reaches
across to disable the other, because a channel's only job is to subscribe and
send. That independence is the whole point of the event seam, but it means the
two email channels are mutually exclusive in practice: enable both and every
recipient gets two emails (the plain hook_mail one and the Easy Email one),
including the same signed action link twice. Enable the one you want and leave
the other uninstalled. This is a deliberate not-a-bug: the choice is the
operator's, not something a channel should silently override. orchestra_easy_email's
requirements check surfaces a warning when it detects both are enabled, as a
reminder rather than an enforcement.
Attachments¶
A dispatcher can carry files on the event, for delivery alongside the
notification: a generated PDF ticket, a calendar invite, a receipt. It seeds the
event's last constructor argument with a list of NotificationAttachment value
objects, each built with a filename, a MIME type and its source, either raw
bytes or a stream-wrapper URI:
use Drupal\orchestra_engine\NotificationAttachment;
$attachments = [
NotificationAttachment::fromContent('ticket-1234.pdf', 'application/pdf', $pdfBytes),
NotificationAttachment::fromUri('invite.ics', 'text/calendar', 'private://invites/1234.ics'),
];
A module that did not dispatch the notification contributes a file the same way,
by calling addAttachment() on the event from a default-priority subscriber. A
channel reads the whole set with getAttachments(), which returns the
seeded and the contributed files in the order they were added.
The value object is channel neutral, like the rest of the event: it exposes only the file's data, never a transport format. Each channel maps it to what its transport needs, so a future SMS, WhatsApp or chat channel maps the same object to its own media upload. Both shipped mail channels honor attachments:
- Orchestra Mail maps each to the Drupal mail attachment array and hands it
to the mailer through
params['attachments'](Mime Mail and Symfony Mailer both read this). Every recipient's mail, or the one shared mail, gets them. - Orchestra Easy Email does the same, folding the files into the outgoing
message at the mail layer rather than onto the Easy Email entity. This keeps a
private file (a ticket PDF in
private://) out of Easy Email's own attachment allowlist, whose default permits onlypublic://, so sensitive files never need to be world-readable to be delivered.
A channel with no notion of attachments simply ignores them.
Customizing and replacing¶
- Reword a type:
hook_mail_alter()rewrites the subject or body of anyorchestra_mailmessage by its key (the example gives itsrequest_processedtype its own wording this way). - Deliver it differently: the event is the seam. Enable a different channel
in place of
orchestra_mailand it takes over, since a channel only has to subscribe and send. Or subscribe toOrchestraNotificationEventyourself, in a custom module, conditioning on thetypeand mapping the event's params and variables into the message. ECA has no event for it: the one Orchestra announcement an ECA model can react to is a timeout, whichorchestra_ecare-dispatches as itsorchestra_task_timed_outcustom event. - A new audience: a custom
Audienceplugin joins in by implementinggetRecipients()and honoring the sharednotifyflag; to also staff a task it implementsAssignmentInterface(getCandidates()andgetViewerTokens()). See Task assignment.
Other channels: SMS, chat, push¶
The event carries recipients as Recipient value objects, not resolved
addresses. A recipient may be backed by a Drupal account or be account-less (a
fixed external email/phone), and a channel asks it for the contact detail it
needs rather than reading a user field directly. So a new delivery channel is
just another subscriber to OrchestraNotificationEvent, with no change to the
event, the audience resolution, the Notify node or the dispatchers. Each
Recipient exposes:
getContact($channel): the address for a channel (mail,sms, ...), or NULL when the recipient has none for it;getLangcode($fallback): the recipient's preferred language, for a per-recipient render;label(): a human name for logs and templates;getAccount(): the backingAccountInterface, or NULL for an account-less recipient.
So a channel reads what it needs:
orchestra_mailreads$recipient->getContact('mail');- an SMS or WhatsApp channel reads
$recipient->getContact('sms'); - a Slack, Teams or Telegram channel reads its own contact key;
- a push channel reads a stored device token contact.
Each channel templates from the event's type, params and the process
variables in context, in each recipient's language (getLangcode()). That
call takes the langcode to use for a recipient with no preference of their own,
and what to pass is the site's default language, never a fixed one: an
account-less recipient has no preference, so a literal langcode would decide the
language of every external address the site notifies. The to/cc/bcc
disposition is the mail channel's interpretation; another channel is free to
ignore it (message everyone) or reinterpret it (treat bcc as a silent copy).
Because a recipient can be account-less, a channel must never assume
getAccount() is non-NULL; ask getContact() instead.
A channel that cannot reach a recipient says so with
$event->reportDeliveryFailure($recipient_keys, $reason), and they alone are
sent it again, as the profile says. A message that could never go (no template
for it) is not a failure to report, since trying again would fail the same way.
Subscribing is also what switches the dispatchers on. A dispatcher asks
OrchestraNotification::hasChannel() before it resolves an audience, and that
is true as soon as anything listens for the event. With no channel installed a
site pays no entity queries to assemble a notification that would be dropped,
so a dispatcher of your own should ask the same question first.
Requirements¶
orchestra_engine(defines the event, the emitter and the audience resolver)- the module owning each point you want to notify from:
orchestra_inboxfor tasks,orchestra_interactionfor bearer links; the Notify node needs nothing beyondorchestra_engine - a channel:
orchestra_mail,orchestra_easy_email, or your own subscriber