zynagent-first application framework

Add a notification#

Use a registered notification for typed email delivery with application-owned recipient selection and templates. Delivery arguments, template parameters, and email options are three separate values.

Choose the delivery contract#

Decide the durable notification name, minimal delivery arguments, when recipient data should be loaded, text and HTML template parameters, and whether delivery must be immediate, deferred, or committed with a database transaction.

Implement#

Generate and register the notification#

Generate the registration and templates:

zyn g notification welcome

Generated applications already export the notifications module from src/app.zig. The generator adds this export if it is missing:

pub const notifications = @import("notifications.zig");

The registration and handler belong in src/notifications.zig. Keep any existing registrations when editing the file. The generated handler accepts a recipient email address; this recipe does not require an application user record:

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",
    });
}

Edit the body templates#

Edit the subject in the handler and the required text body in src/notifications/welcome.email.txt.ez:

Welcome to the application.

The optional HTML body belongs in src/notifications/welcome.email.html.ez:

<p>Welcome to the application.</p>

These bodies need no template parameters, so the handler passes .{} to notification.email. For dynamic content, pass template parameters as its first argument; a field named name becomes @name in the templates. Keep the same top-level parameter shape in both bodies. Recipients, sender overrides, and headers belong in the second argument.

Deliver the notification#

Deliver the registered selector from an action, hook, middleware, job, or transaction, replacing the example address with the intended recipient:

try action.notifications.deliver_later("welcome",
    .{ .recipient = "recipient@example.com" },
);

Passing an address fixes the recipient when the notification is queued. Use an application identifier instead only when the handler needs to load an existing recipient's current address or preferences at execution time.

Use deliver_now only when the current operation must include rendering, persistence, and transport latency. Use tx.notifications.deliver_later when the application write and deferred occurrence must commit together. Deferred delivery requires a worker; zyn dev runs it with the application.

Keep delivery arguments limited to values needed to run the handler. Zyn encrypts persisted notification arguments, but handlers should still avoid unnecessary secret material.

See Notifications for the handler, template, and delivery contracts.

Configure email delivery#

Development and testing default to capture transport, so SMTP setup is not required to try the recipe. Development capture writes .eml files under .zyn/emails by default; it does not send email to an inbox.

Sending real email requires SMTP configuration. Applications with registered notifications must explicitly select a production transport. For production SMTP, merge this section into zyn.zon, using the provider's host and an authorized sender address:

.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",
        } },
    },
},

Zyn supports implicit TLS, normally on port 465, not STARTTLS or plaintext. For authenticated SMTP, declare these fields in credentials/schema.zon:

.email = .{
    .username = .optional_private,
    .password = .optional_private,
},

Optional private fields may be absent from capture-only profiles, but both values are required in a profile whose SMTP configuration references them. Set the production values:

zyn credentials set email.username smtp-account -p production
zyn credentials set email.password -p production

The password command prompts for its value; do not put it in source files or command-line arguments. If the provider does not require authentication, omit both credential references and their setup.

zyn notify uses the development profile, not production. To send real email with that command, configure .email.profiles.development with SMTP too and run the credential commands above with development instead of production. Managed application tests always use capture transport.

Verify#

Run the handler with development configuration:

zyn db migrate
zyn notify welcome recipient=recipient@example.com
zyn check

Replace the example address with an inbox you control when using SMTP. With capture transport, inspect the .eml file at the command's reported capture path; a sent result records capture success, not inbox delivery.

Add a managed application test to check the captured message:

const std = @import("std");
const zyn = @import("zyn");

test "welcome sends email" {
    var app = try zyn.testing.start(
        std.testing.io,
        std.testing.allocator,
        .{},
    );
    defer app.deinit();

    const delivery = try app.notifications.deliver_now("welcome",
        .{ .recipient = "recipient@example.com" },
    );
    try std.testing.expectEqual(zyn.testing.Status.sent, delivery.status);

    const delivery_hex = std.fmt.bytesToHex(delivery.delivery_id, .lower);
    const path = try std.fmt.allocPrint(
        std.testing.allocator,
        "{s}/{s}.eml",
        .{ app.email_capture_path, &delivery_hex },
    );
    defer std.testing.allocator.free(path);

    const message = try std.Io.Dir.cwd().readFileAlloc(
        std.testing.io,
        path,
        std.testing.allocator,
        .limited(2 * 1024 * 1024),
    );
    defer std.testing.allocator.free(message);
    try std.testing.expect(std.mem.indexOf(u8, message, "Subject: Welcome") != null);
}

For deferred delivery, call app.notifications.deliver_later, then execute the occurrence with app.jobs.perform_one or perform_ready before inspecting the capture. Test recipient preferences and skipped delivery as applicable.