Zig 0.17.0

Mail

spider.mail sends mail through a provider's API: Brevo, Resend or Postmark. It is not a mail server, and there is no SMTP. While you develop, it writes the mail to the log.

The code on this page is from Bookings, the example app in examples/bookings (source), which mails whoever booked a room when the booking is confirmed.

A mailer

Make the mailer once, when the app starts, and keep it:

zig
/// How the mail leaves. Until `boot` runs it only writes to the log.
pub var mailer: spider.mail.Mailer = .{ .backend = .{ .log = .{} } };

/// Read once, when the server starts (`server.mountFeatures` calls it):
/// MAIL_TRANSPORT and its key decide how mail is sent (the log, when
/// nothing is set), BOOKINGS_WEBHOOK_URL who else is told.
pub fn boot(_: spider.Boot) !void {
    mailer = try spider.mail.Mailer.fromEnv();
    webhook_url = spider.env.get("BOOKINGS_WEBHOOK_URL");
}

Mailer.fromEnv reads how mail leaves from the environment:

  • MAIL_TRANSPORT: brevo, resend, postmark or log. Without it, log: the mail is written to the log and nothing is sent.
  • BREVO_API_KEY, RESEND_API_KEY or POSTMARK_SERVER_TOKEN: the key of the provider that was chosen.
  • MAIL_FROM: who the mail is from when a mail does not say, as Name <address>.
  • MAIL_TIMEOUT_MS: how long one delivery may take. 30 seconds unless you say otherwise.

Keep the keys in the environment, as Configuration says.

Sending

zig
/// Who a mail about a booking goes to.
pub const Owner = struct { name: []const u8, email: []const u8 };

fn mail(c: *spider.Ctx, booking: model.Booking, to: Owner, day_label: []const u8) !void {
    const a = c.arena;
    const link = try std.fmt.allocPrint(a, "{s}/bookings/{d}", .{ spider.env.getOr("BOOKINGS_URL", "http://localhost:3000"), booking.id });
    const recipients = try a.alloc(spider.mail.Mailbox, 1);
    recipients[0] = .{ .name = to.name, .address = to.email };
    _ = try mailer.send(c, .{
        .from = mailer.from orelse default_sender,
        .to = recipients,
        .subject = try std.fmt.allocPrint(a, "Confirmed: {s}, {s} {s}", .{ booking.room, day_label, booking.starts }),
        .text = try std.fmt.allocPrint(a,
            \\Your booking is confirmed.
            \\
            \\{s}
            \\{s}
            \\{s}, {s} to {s}
            \\
            \\See it or cancel it: {s}
            \\
        , .{ booking.title, booking.room, day_label, booking.starts, booking.ends, link }),
    });
}

A mail needs someone to send it to, and a body: text, html, or both. mailer.send(c, mail) sends from a handler. Outside a request, in a job, it is mailer.sendWith(arena, io, mail).

A name or a subject is checked before anything is sent: a line break in one of them is error.MailInvalidHeader, so text a visitor typed cannot add a header of its own.

When it fails

send fails with an error that says whose problem it is:

  • error.MailUnauthorized: the provider refused the key.
  • error.MailRejected: the provider refused this mail. Sending it again unchanged will not help.
  • error.MailDeliveryFailed: the network, a limit, the provider having a bad moment, or the time limit. Worth trying again later.

Decide what a failure means for the visitor. In Bookings a booking is confirmed whether or not its mail could be sent, so the failure goes to the log and the request goes on:

zig
/// Tells the owner, and the webhook, that `booking` is confirmed.
pub fn confirmed(c: *spider.Ctx, booking: model.Booking, owner: ?Owner, day_label: []const u8) void {
    if (owner) |to| mail(c, booking, to, day_label) catch |err| {
        std.log.warn("bookings: the confirmation of booking {d} was not mailed: {s}", .{ booking.id, @errorName(err) });
    };
    if (webhook_url) |url| call(c, url, booking) catch |err| {
        std.log.warn("bookings: the webhook was not told about booking {d}: {s}", .{ booking.id, @errorName(err) });
    };
}

Sending takes as long as the provider takes to answer, and the visitor waits for it. That is fine for one mail after a click. For many, queue them and send from a job.

Testing

A test swaps the mailer for a box in memory and reads what was put in it:

zig
test "booking: confirming it mails who booked and tells the webhook, once" {
    const app = try app_test.running();
    const notify = features.bookings.notify;
    var id_buf: [20]u8 = undefined;
    const cedar = try roomId(&id_buf, "Cedar");
    var buf: [1024]u8 = undefined;
    const ana = app.with(&.{try app_test.signUp(app, &buf, "Ana Ribeiro", "ana.mail@example.com")});

    // The mail goes to a box in memory, the webhook to a server of the test.
    var outbox: spider.mail.Outbox = .init(std.testing.allocator);
    defer outbox.deinit();
    const mailer_before = notify.mailer;
    const webhook_before = notify.webhook_url;
    defer notify.mailer = mailer_before;
    defer notify.webhook_url = webhook_before;
    notify.mailer = .{ .backend = .{ .memory = &outbox } };

    var hook = try Hook.listen(std.testing.io);
    defer hook.server.deinit(std.testing.io);
    var url_buf: [64]u8 = undefined;
    notify.webhook_url = try std.fmt.bufPrint(&url_buf, "http://127.0.0.1:{d}/hooks/bookings", .{hook.port});
    const listener = try std.Thread.spawn(.{}, Hook.serveOne, .{&hook});

    var made = try ana.postForm("/bookings/create", Booking{ .room = cedar, .date = "2032-07-19", .start = "10:00", .end = "11:00", .title = "Sprint demo" });
    defer made.deinit();
    const location = made.header("Location").?;
    try std.testing.expectEqual(@as(usize, 0), outbox.count()); // held: nothing is said yet

    var confirm_buf: [64]u8 = undefined;
    const confirm = try std.fmt.bufPrint(&confirm_buf, "{s}/confirm", .{location});
    var confirmed = try ana.postForm(confirm, .{});
    defer confirmed.deinit();
    try confirmed.expectRedirect(location);
    listener.join();

    try std.testing.expectEqual(@as(usize, 1), outbox.count());
    const mail = outbox.last().?;
    try std.testing.expectEqualStrings("ana.mail@example.com", mail.to[0].address);
    try std.testing.expectEqualStrings("Ana Ribeiro", mail.to[0].name.?);
    try std.testing.expectEqualStrings("Confirmed: Cedar, Monday, 19 July 10:00", mail.subject);
    try std.testing.expect(std.mem.indexOf(u8, mail.text.?, "Sprint demo") != null);
    try std.testing.expect(std.mem.indexOf(u8, mail.text.?, location) != null);

    const sent = hook.received[0..hook.len];
    try std.testing.expect(std.mem.startsWith(u8, sent, "POST /hooks/bookings HTTP/1.1"));
    try std.testing.expect(std.mem.indexOf(u8, sent, "\"event\":\"booking.confirmed\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, sent, "\"room\":\"Cedar\"") != null);
    try std.testing.expect(std.mem.indexOf(u8, sent, "\"day\":\"2032-07-19\"") != null);

    // Confirming again changes nothing and says nothing.
    notify.webhook_url = null;
    var again = try ana.postForm(confirm, .{});
    defer again.deinit();
    try std.testing.expectEqual(@as(usize, 1), outbox.count());
}

outbox.count(), outbox.last() and outbox.sent() give what was sent, as the Mail values the app built.