ElementsChannel

Email

Transactional email — channel.email templates, {{field}} catalogs, SMTP/Mailpit, Resend, SNDR, and Taqnyat Mail.

Email (channel.email) is the default Channel medium. Declare a binder with a From address, register templates, put bodies in the catalog, and send with fx.send.

For developers who need local catchers and production HTTP MTAs — same fx.send path, swap the driver.

The one rule

Use channel.email({ from }).template(name, { schema, locales }) — never channel.email("name", { subject, body }). Subject and body live in the catalog.

Smallest Example

Declare binder + template

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

const mail = channel.email({ from: "App <noreply@example.com>" });

export const passwordReset = mail.template("auth.resetPassword", {
  description: "Password reset link",
  locales: ["en"],
  schema: z.object({ resetLink: z.string().url() }),
});

Catalog + send

src/app.ts
import { oke } from "okengine";

oke({
  name: "app",
  channel: {
    catalog: {
      "auth.resetPassword": {
        en: {
          subject: "Reset your password",
          text: "Open {{resetLink}} to choose a new password.",
          html: '<p><a href="{{resetLink}}">Reset your password</a></p>',
        },
      },
    },
  },
});
src/flows/auth/reset.ts
import { on, flow, http } from "okengine";
import { z } from "zod";
import { passwordReset } from "@/core/channel";

export const reset = on(
  http
    .post({
      in: z.object({ email: z.string().email() }),
    })
    .public(),
  flow({
    do: async ({ email }, fx) => {
      await fx.send(passwordReset, {
        to: email,
        data: { resetLink: `https://example.com/reset?t=${fx.id()}` },
      });
      return { ok: true };
    },
  }),
);

Inspect locally

With Mailpit (images.channel.email + SMTP_URL), open MAILPIT_UI_URL. Manifest lists sends: ["auth.resetPassword"] on the Flow.

Progressive Patterns

No catalog — runtime uses subject: templateName and text: JSON.stringify(data):

await fx.send(passwordReset, {
  to: "alice@example.com",
  data: { resetLink: "https://example.com/r/1" },
});

Options Reference

channel.email(options?)

OptionTypeMeaning
fromstringDefault From / sender
senderstringAlias for from

.template(name, options?)

OptionTypeMeaning
descriptionstringConsole label
localesstring[]Declared locales
schemaSchemaPayload contract for data

Default From when neither binder nor template sets one: "oke@localhost.test".

Drivers

Driver idOpens at bootEnv / keys
smtpyes (default)SMTP_URL or OKE_CHANNEL_EMAIL_URL; optional SMTP_USER / SMTP_PASSWORD
consoleyes (test)In-process inbox — no network
resendyesRESEND_API_KEY
sndryesSNDR_API_KEY; optional SNDR_BASE_URL
taqnyat-mailyesTAQNYAT_MAIL_TOKEN + TAQNYAT_CAMPAIGN
oke.config.ts
drivers: {
  channel: {
    email: { dev: "smtp", test: "console", prod: "resend" },
  },
},
images: {
  channel: { email: "axllent/mailpit:v1.31.1" },
},

Consequence: dev and prod both speak SMTP protocol by default — Mailpit locally, your relay in production. Swap prod to resend / sndr / taqnyat-mail when you want HTTP APIs.

Unknown id → oke boot: unknown email channel driver "…". Missing SMTP URL → oke boot: smtp driver needs SMTP_URL.

Locale chain and suppression run before the driver (see Overview and Receipts). Opted-out or prior hard-bounce addresses never reach SMTP / Resend.

Channel catalogs are not ICU — do not use fx.t for email bodies (i18n).

Troubleshooting

Learn more

Next

On this page