Notifications#
A notification pairs a registered typed handler with email body templates. Each delivery creates a persisted occurrence. The handler selects recipients and supplies template parameters and email options; the configured transport captures the message or sends it through SMTP.
For generation and setup steps, see Add a notification.
Registration and handlers#
Export the notifications module from src/app.zig:
pub const notifications = @import("notifications.zig");
In src/notifications.zig, register a name and its handler:
const zyn = @import("zyn");
pub const registry: zyn.NotificationRegistry = &.{
.notification("welcome", welcome),
};
fn welcome(
notification: *zyn.Notification,
args: struct { recipient: []const u8 },
) !void {
try notification.email(.{}, .{
.to = &.{zyn.Email.Address.init(args.recipient)},
.subject = "Welcome",
});
}
This handler needs a text body at src/notifications/welcome.email.txt.ez. It passes no template parameters; the body can contain literal text such as Welcome to the application.
Registration rules#
"welcome"is the durable name used for delivery and template lookup. It must be unique and use strict snake case. It need not match the handler name.- Handlers may be local or imported. Registries may be assembled at compile time.
- Zyn checks delivery names and argument fields and types during application compilation. Invalid or duplicate registered names also fail compilation.
Handler context#
The first argument provides db, logger, io, and arena. The second contains the typed delivery arguments.
A handler may query current application data before deciding whether to send. Returning successfully without calling email records the notification as skipped.
Delivery#
Actions, hooks, middleware, and jobs expose notifications:
try action.notifications.deliver_later("welcome",
.{ .recipient = "recipient@example.com" },
);
| Call | When it runs |
|---|---|
notifications.deliver_later | A background worker handles the persisted occurrence |
notifications.deliver_now | Before returning, including handler, rendering, persistence, and transport latency |
tx.notifications.deliver_later | After commit; the application write and occurrence commit or roll back together |
Both delivery methods return !void to application code. Transactions expose only deferred delivery.
zyn dev runs the background worker with the HTTP application. Production deployments start a slot-matched worker when notifications are registered. Deferred delivery outside those managed lifecycles requires the application's background worker to be running.
Delivery arguments, template parameters, and email options#
| Value | Supplied by | Purpose |
|---|---|---|
| Delivery arguments | Caller of deliver_now or deliver_later | The registered handler's Args |
| Template parameters | First argument to notification.email | Values such as @name in text and HTML bodies |
| Email options | Second argument to notification.email | Recipients, sender overrides, subject, and headers |
Delivery arguments become the handler's second argument. Zyn encrypts and stores them before running it. Prefer durable identifiers when the handler should load current recipient data or preferences at execution time.
Inside the handler, notification.email receives two different values. For templates that declare @name, the call would be:
try notification.email(
.{ .name = "Ada" }, // template parameters
.{ // zyn.Email.Options
.to = &.{zyn.Email.Address.init(args.recipient)},
.subject = "Welcome",
},
);
name becomes @name in both body templates. Email options have type zyn.Email.Options.
When a direct local registry and handler can be inspected, anonymous email-parameter structs are checked against the selected notification's templates before application compilation. Other calls are checked against registered template shapes during compilation and against the selected notification before rendering; this includes handlers imported from other files.
A notification occurrence may call email once. A second call returns error.NotificationEmailAlreadyDelivered. Zyn renders and stages the message at that call, then checkpoints and contacts the transport only after the handler returns successfully. If the handler returns an error, the staged email is not sent. Deferred delivery retries the handler; immediate delivery returns the error.
If application code catches an email error and then returns successfully, the occurrence is recorded as skipped:
notification.email(params, options) catch |err| {
notification.logger.warn("welcome email skipped: {s}", .{@errorName(err)});
};
Recipients and headers#
Use Address.init for an address without a display name and Address.named for one with a display name:
const support = zyn.Email.Address.init("support@example.com");
const customer = zyn.Email.Address.named("Ada", "ada@example.com");
to is required. cc, bcc, from, reply_to, and custom headers are optional:
try notification.email(.{}, .{
.to = &.{zyn.Email.Address.named("Ada", args.recipient)},
.cc = &.{zyn.Email.Address.init("accounts@example.com")},
.reply_to = zyn.Email.Address.init("support@example.com"),
.subject = "Your account",
.headers = &.{
.{ .name = "X-Category", .value = "accounts" },
},
});
When from is omitted, Zyn uses the address configured by the selected SMTP profile. Capture transport uses notifications@example.test by default. Bcc addresses are included in the SMTP envelope but omitted from the MIME headers.
Mailbox addresses use a conservative ASCII dot-atom form. Display names and subjects accept valid UTF-8. Zyn rejects control characters, reserved MIME headers, invalid addresses, empty recipient lists, excess recipients, and messages over the configured size limit.
Templates#
Every registered notification requires a NAME.email.txt.ez text template under src/notifications/. A matching NAME.email.html.ez HTML template is optional. NAME is the explicit registered name.
Templates may be placed in domain directories, but each registered notification basename must remain unique:
src/notifications/
├── accounts/
│ ├── welcome.email.txt.ez
│ ├── welcome.email.html.ez
│ └── layouts/
│ ├── email.txt.ez
│ └── email.html.ez
├── layouts/
│ ├── email.txt.ez
│ └── email.html.ez
└── shared/
├── signature.email.txt.ez
└── signature.email.html.ez
A domain layout overrides the root layout for templates in that directory. Layouts receive the rendered body as @children and may use the same parameters as the body. Text and HTML bodies must declare exactly the same top-level parameters.
Use safe-rooted includes for partials:
<% @include('shared/signature.email.html.ez', %{}) %>
HTML email templates escape ordinary interpolation. Notification templates cannot access credentials or use web URL, asset, or storage helpers. Compute such values in the handler and pass them as template parameters.
Email configuration#
Capture and limits#
Email settings belong in zyn.zon. Limits apply to every profile:
.email = .{
.max_recipients = 50,
.max_message_bytes = 2_097_152,
.timeout_seconds = 30,
.retry_max_attempts = 5,
.profiles = .{
.development = .{ .capture = .{ .path = ".zyn/emails" } },
.testing = .{ .capture = .{ .path = ".zyn/test/emails" } },
},
},
Development and testing default to capture transport when email configuration is omitted. Capture writes one private .eml file per delivery. With the configuration above, development messages appear under .zyn/emails; no network connection is made. With capture transport, sent means captured, not delivered to an inbox.
SMTP#
Applications with registered notifications must explicitly configure a production transport. SMTP uses implicit TLS and defaults to port 465:
.email = .{
.profiles = .{
.production = .{ .smtp = .{
.host = "smtp.example.com",
.port = 465,
.helo = "app.example.com",
.from = "notifications@example.com",
.username_credential = "email.username",
.password_credential = "email.password",
} },
},
},
Credential references must name private fields. SMTP authentication is optional, but username_credential and password_credential must either both be present or both be omitted. Zyn verifies the server certificate and hostname and supports implicit TLS only; it neither uses STARTTLS nor falls back to plaintext.
Persistence and retries#
Zyn inserts a stable UUIDv7 occurrence and email-delivery row in the same transaction. zyn.notifications stores the notification name, encrypted handler arguments, occurrence status and attempts, retry limit, handler error, processing lease, and timestamps. zyn.notification_deliveries stores the unique email channel, transport status and attempts, encrypted frozen message, provider outcome, and timestamps.
Notification arguments and frozen messages use the same authenticated encrypted field format and database key as zyn.encrypted_bytes application columns. The ciphertext is bound to its framework table, column, and row identifier. The serialized payload also records the notification name and both stable identifiers; mismatches fail delivery as an invalid notification payload.
Initial encrypted arguments are stored on notifications. After a handler returns successfully, Zyn encrypts and checkpoints the frozen MIME message on notification_deliveries before transport. Transport retries reuse that checkpoint instead of rerunning application queries or rendering. The framework zyn.jobs row contains only the occurrence reference and argument-contract digest.
Handler work before the email checkpoint may run again after an error or interruption and must be idempotent. Handler failures before email update only the occurrence; delivery remains pending. SMTP delivery is at least once: a connection can fail after acceptance. Zyn reuses the delivery identifier as the MIME Message-ID, but SMTP does not guarantee deduplication.
Terminal notification occurrences are removed according to jobs.failed_retention_days by workers and zyn jobs prune; their delivery rows are removed by the cascading foreign key. Master rotation retains the data key used by pending and retrying notifications.
Testing#
Managed application tests always use capture transport. Unlike application-code delivery, app.notifications.deliver_now returns a recorded outcome, including status and delivery_id, for assertions. Captured messages are stored as {delivery_id}.eml, with the identifier in lowercase hexadecimal.
app.notifications.deliver_later records a pending occurrence. Execute it with app.jobs.perform_one or perform_ready, then inspect the capture file. app.email_capture_path is isolated for each managed application and removed by app.deinit.
Development command#
With missing arguments, an interactive terminal prompts for required fields. Bare zyn notify selects the only registration or presents a numbered picker. The command uses the development database, credentials, and email profile, so handler queries, recipient preferences, templates, and capture or SMTP delivery follow the same path as deliver_now. It does not start the HTTP server or worker.