Skip to content

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 *_notification modules, 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 *_notification module
  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

  getProcessInstanceAttachments()  // the files to deliver, seeded and enriched
  addAttachment()                  // contribute one before the channels run
}

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 payload is semantic, never rendered mail: each channel (and an ECA model) templates it its own way, in each recipient's language. The orchestra.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 --> ECA["ECA model / custom<br/>subscriber"]
  CH --> OTHER["SMS / chat / push"]
  MAIL --> R["per-recipient render,<br/>each in their own language"]
  EE --> R

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, interaction_link, and a neutral default for any custom type). A send failure is logged on the Orchestra channel 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/Bcc headers, 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.

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\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 getProcessInstanceAttachments(), 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 only public://, 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 any orchestra_mail message by its key (the example gives its request_processed type its own wording this way).
  • Deliver it differently: the event is the seam. Enable a different channel in place of orchestra_mail and it takes over, since a channel only has to subscribe and send. Or subscribe to OrchestraNotificationEvent yourself, in a custom module or an ECA model, conditioning on the type and mapping the event's params and variables into the message.
  • A new audience: a custom Audience plugin joins in by implementing getRecipients() and honoring the shared notify flag; to also staff a task it implements AssignmentInterface (getCandidates() and getViewerTokens()). 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 backing AccountInterface, or NULL for an account-less recipient.

So a channel reads what it needs:

  • orchestra_mail reads $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.

Requirements

  • orchestra (defines the event, the emitter and the audience resolver)
  • a dispatcher submodule for the points you want: orchestra_inbox_notification (tasks), orchestra_interaction_notification (bearer links), orchestra_notification (the Notify node)
  • a channel: orchestra_mail, orchestra_easy_email, or your own subscriber