ElementsChannel

Overview

Reaching humans — declare email, SMS, WhatsApp, and push templates, send through fx.send, track receipts and consent.

Channel is how your backend reaches a person — the order-confirmation email, the SMS sign-in code, a WhatsApp notice, or a device push. You declare a template on a medium, fill a {{field}} body catalog, and send from a Flow with fx.send.

For developers wiring Mailpit locally and Resend / Taqnyat / FCM in production — templates and drivers first, never vendor SDKs inside do.

The one rule

Declare a template (channel.email(…).template(…)), then fx.send(template, { to, data }). Bodies live in the catalog (subject / text / html), not on the declare call. Consent and prior bounces suppress before any driver runs.

Around one fx.send

via: ["smtp", "resend"]
scenarioclean send
  1. 1Consent

    not suppressed — provider may be contacted

  2. 2Locale

    default:en → body en

  3. 3Drivers

    smtp — single attempt

  4. Receipt — driver, ok/error, timestamp, message id

Smallest Example

Declare an email template

src/core/channel.ts
import { channel } from "okengine";
import { z } from "zod";

const mail = channel.email({ from: "Notes <notes@localhost>" });

export const noteCreatedMail = mail.template("note-created", {
  locales: ["en"],
  schema: z.object({
    id: z.string(),
    title: z.string(),
  }),
});

Import this module before oke() so auto-registry adopts the template (or pass it in oke({ channel: { templates: […] } })).

Send from a Flow

src/flows/notes/on-created.ts
import { on, flow } from "okengine";
import { noteCreatedMail } from "@/core/channel";
import { noteCreated } from "./signals";

export const onCreated = on(
  noteCreated,
  flow("notes.onCreated", {
    do: async (payload, fx) => {
      await fx.send(noteCreatedMail, {
        to: "you@localhost",
        data: { id: payload.id, title: payload.title },
      });
    },
  }),
);

The compiler stamps sends: ["note-created"] on the Flow’s effects (template name — not email:note-created).

See it locally

With drivers.channel.email.dev: "smtp" and Mailpit pinned, open the Mailpit UI (MAILPIT_UI_URL). Missing catalog bodies fall back to subject: note-created and text: JSON.stringify(data).

Progressive Patterns

From a bare send to catalog bodies, locale, and same-medium failover:

Template handle + recipient — catalog optional for local smoke tests:

await fx.send(noteCreatedMail, {
  to: "alice@example.com",
  data: { id: "n1", title: "Hello" },
});

Declaration Reference

DeclarationSignaturePurpose
channel.emailchannel.email(options?)Email medium binder
channel.smschannel.sms(options?)SMS medium binder
channel.whatsappchannel.whatsapp(options?)WhatsApp medium binder
channel.pushchannel.push(options?)Push medium binder
binder.templatebinder.template(name, options?)Auto-registered template
channel.templatechannel.template(name, options?)Medium-agnostic — not auto-registered

Medium options

OptionTypeDefaultMeaning
fromstringDefault sender (email From / SMS sender id)
senderstringAlias stored as from

Template options

OptionTypeDefaultMeaning
descriptionstringnameConsole / docs label
localesstring[]Declared locale tags for the template
schemaSchemaPayload shape (Zod / Standard Schema)
fromstringbinder’sOverride sender on agnostic channel.template only
mediummedium"email"Only on channel.template()

Empty name throws TypeError: channel.template: name is required.

There is no subject / body / html on declare — those belong in the catalog.

fx surface

MethodCapability / sendsMeaning
fx.send(template, opts?)template nameDeliver through the medium’s driver chain
fx.sendOtp(opts)"sms-otp"Provider-managed SMS OTP (Taqnyat Verify)
fx.verifyOtp(opts)"sms-otp"Check a provider OTP code
fx.deliverOtp(opts)"auth-otp"App-owned OTP across email / SMS / WhatsApp

fx.send options

OptionTypeMeaning
tostringRecipient (email / E.164 / FCM token / …)
dataobjectInterpolated into {{field}} catalog bodies
viarefsSame-medium driver order for failover
localestringExplicit locale (wins)
profileLocalestringProfile locale step
acceptLanguagestringRaw Accept-Language header

Dry-run records would have fired and never contacts a provider. Undeclared send → OKE1004 UNDECLARED_SEND.

Per-environment drivers

Email defaults from DRIVER_DEFAULTS.channel.email. SMS / WhatsApp / push are opt-in:

oke.config.ts
import { defineConfig } from "okengine/config";

export default defineConfig({
  drivers: {
    channel: {
      email: { dev: "smtp", test: "console", prod: "smtp" },
      // sms: { prod: "taqnyat" },
      // whatsapp: { prod: "wa-cloud" },
    },
  },
  images: {
    channel: { email: "axllent/mailpit:v1.31.1" },
  },
});
KeyDefault (dev / test / prod)Driver ids
channel.emailsmtp / console / smtpconsole · smtp · resend · sndr · taqnyat-mail
channel.smsnonetaqnyat · msegat · unifonic (console opens nothing)
channel.whatsappnonewa-cloud · taqnyat-whatsapp
channel.pushnone — not auto-boundPass oke({ channel: { drivers: […] } }) with webpush / fcm

Env knobs: Environment variables. Local SMTP catcher: Mailpit.

The Capabilities of Channel

Troubleshooting

Learn more

  • Email — drivers, catalog, Mailpit
  • SMSfx.sendOtp / fx.verifyOtp
  • WhatsAppchannel.whatsapp + boot drivers
  • Push — FCM / Web Push binding
  • Receipts — ledger, outcomes, suppression
  • OTP plugin/auth/otp/* over Channel
  • fxfx.send options
  • i18n — Channel catalogs vs fx.t

Next

On this page