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:
/// 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,postmarkorlog. Without it,log: the mail is written to the log and nothing is sent.BREVO_API_KEY,RESEND_API_KEYorPOSTMARK_SERVER_TOKEN: the key of the provider that was chosen.MAIL_FROM: who the mail is from when a mail does not say, asName <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
/// 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:
/// 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:
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.