Zig 0.17.0

mail

On this page:

  • spider.mail: Sending mail through a provider's HTTP API (Brevo, Resend, Postmark) — see modules/mail/mail.zig.

spider.mail

Sending mail (spider.mail). The app builds a Mail and hands it to a Mailer; the Mailer's backend decides how it leaves the process:

zig
var mailer = try spider.mail.Mailer.fromEnv(); // once, at startup
_ = try mailer.send(c, .{
    .to = &.{.{ .address = "ada@example.com" }},
    .subject = "Welcome",
    .html = "<h1>Welcome!</h1>",
    .text = "Welcome!",
});

This is a client of mail providers (their HTTP APIs), not a mail server: it does not deliver to inboxes itself and does not receive mail.

Backends: .brevo, .resend, .postmark (real delivery), .log (development), .memory (tests, with an Outbox), .custom (the app's own Transport).

spider.mail.Mail

zig
pub const Mail = struct

A message to send. from may be left out when the Mailer has a default. It needs at least one recipient among to, cc and bcc, and a non-empty html or text; subject may be empty.

Fields

  • from: ?Mailbox = null
  • to: []const Mailbox = &.{}
  • cc: []const Mailbox = &.{}
  • bcc: []const Mailbox = &.{}
  • reply_to: ?Mailbox = null
  • subject: []const u8 = ""
  • html: ?[]const u8 = null
  • text: ?[]const u8 = null

spider.mail.Mailbox

zig
pub const Mailbox = struct

An address with an optional display name.

Fields

  • name: ?[]const u8 = null
  • address: []const u8

mail.Mailbox.parse

zig
pub fn parse(s: []const u8) !Mailbox

Parses Ada <ada@example.com>, "Lovelace, Ada" <ada@example.com> or a bare ada@example.com. The result borrows from s. Fails like validate: error.MailInvalidAddress, error.MailInvalidHeader.

mail.Mailbox.toHeader

zig
pub fn toHeader(self: Mailbox, arena: std.mem.Allocator) ![]const u8

Name <address> (the name quoted when it needs to be), allocated in arena, or the bare address itself (not a copy) when there is no name.

mail.Mailbox.validate

zig
pub fn validate(self: Mailbox) !void

error.MailInvalidAddress when the address is not well formed, error.MailInvalidHeader when the name has a control character.

spider.mail.Receipt

zig
pub const Receipt = struct

What a transport returns once it accepted a message. Acceptance is not inbox delivery.

Fields

  • message_id: ?[]const u8 = null: The id the transport gave the message, when it gave one.

spider.mail.validate

zig
pub fn validate(mail: Mail) !void

Checks a message before it reaches a transport: a sender, at least one recipient, a body, well-formed addresses, and no control characters (CR/LF above all) in anything that ends up in a header.

spider.mail.Brevo

zig
pub const Brevo = struct

Delivery through Brevo: the value of Backend.brevo.

Fields

  • api_key: []const u8: The API key, from the provider's dashboard (Mailer.fromEnv: BREVO_API_KEY). Not copied.
  • base_url: []const u8 = "https://api.brevo.com": The API's address, without a trailing slash. Change it for a mock server or a regional endpoint (Mailer.fromEnv: MAIL_BASE_URL).
  • timeout_ms: u32 = 30_000: How long one delivery may take, in milliseconds; past that it fails with error.MailDeliveryFailed. 0: no limit.

spider.mail.Resend

zig
pub const Resend = struct

Delivery through Resend: the value of Backend.resend. Recipients and reply_to are sent as bare addresses: their display names are dropped.

Fields

  • api_key: []const u8: The API key, from the provider's dashboard (Mailer.fromEnv: RESEND_API_KEY). Not copied.
  • base_url: []const u8 = "https://api.resend.com": The API's address, without a trailing slash. Change it for a mock server or a regional endpoint (Mailer.fromEnv: MAIL_BASE_URL).
  • timeout_ms: u32 = 30_000: How long one delivery may take, in milliseconds; past that it fails with error.MailDeliveryFailed. 0: no limit.

spider.mail.Postmark

zig
pub const Postmark = struct

Delivery through Postmark: the value of Backend.postmark.

Fields

  • api_key: []const u8: The server token, sent as X-Postmark-Server-Token (Mailer.fromEnv: POSTMARK_SERVER_TOKEN). Not copied.
  • base_url: []const u8 = "https://api.postmarkapp.com": The API's address, without a trailing slash. Change it for a mock server or a regional endpoint (Mailer.fromEnv: MAIL_BASE_URL).
  • timeout_ms: u32 = 30_000: How long one delivery may take, in milliseconds; past that it fails with error.MailDeliveryFailed. 0: no limit.

spider.mail.Log

zig
pub const Log = struct

The value of Backend.log: each mail becomes one info log line (scope mail) with sender, recipients and subject, and nothing is delivered. The receipt has no message id.

Fields

  • body: bool = true: Also log the body (the text one when there is one, else the HTML).

mail.Log.line

zig
pub fn line(self: Log, arena: std.mem.Allocator, mail: Mail) ![]const u8

The log line for a validated mail.

spider.mail.Outbox

zig
pub const Outbox = struct

Where the memory transport keeps messages. Safe to send to from several threads (handlers) while a test reads it.

Fields

  • arena: std.heap.ArenaAllocator
  • mails: std.ArrayList(Mail) = .empty
  • next_id: u64 = 1
  • lock: std.atomic.Mutex = .unlocked

mail.Outbox.init

zig
pub fn init(allocator: std.mem.Allocator) Outbox

An empty Outbox. The copies of the mails are allocated with allocator; call deinit() when done.

zig
var outbox: spider.mail.Outbox = .init(std.testing.allocator);
defer outbox.deinit();
const mailer: spider.mail.Mailer = .{ .backend = .{ .memory = &outbox } };

mail.Outbox.deinit

zig
pub fn deinit(self: *Outbox) void

Frees every stored mail.

mail.Outbox.send

zig
pub fn send(self: *Outbox, arena: std.mem.Allocator, mail: Mail) !Receipt

Stores a copy of mail: it stays readable after the sender's arena is gone. The receipt's id is allocated in arena.

mail.Outbox.sent

zig
pub fn sent(self: *Outbox) []const Mail

Everything sent so far, oldest first. Valid until clear() or deinit().

mail.Outbox.count

zig
pub fn count(self: *Outbox) usize

How many mails were sent since init() or the last clear().

mail.Outbox.last

zig
pub fn last(self: *Outbox) ?Mail

The most recent message, or null when nothing was sent.

mail.Outbox.clear

zig
pub fn clear(self: *Outbox) void

Forgets every message; slices handed out before are no longer valid.

spider.mail.Transport

zig
pub const Transport = struct

A transport written by the app (another provider, a queue, ...): .{ .custom = .{ .ptr = &my_state, .sendFn = mySend } }. sendFn gets a message that already passed validate, with from set.

Fields

  • ptr: *anyopaque: The transport's own state, handed back to sendFn. Not owned by the Mailer.
  • sendFn: *const fn (ptr: *anyopaque, arena: std.mem.Allocator, io: std.Io, mail: Mail) anyerror!Receipt: Delivers mail. arena and io are the ones of the send call (the request's, for Mailer.send). An error it returns is the send's error.

spider.mail.Backend

zig
pub const Backend = union(enum)

How a Mailer delivers. One of these goes in Mailer.backend:

zig
const mailer: spider.mail.Mailer = .{
    .backend = .{ .memory = &outbox },
    .from = .{ .address = "app@example.com" },
};

Fields

  • brevo: Brevo: Brevo's HTTP API.
  • resend: Resend: Resend's HTTP API.
  • postmark: Postmark: Postmark's HTTP API.
  • log: Log: Nothing is delivered: the mail is written to the log (development).
  • memory: *Outbox: Nothing is delivered: the mail is copied into the Outbox (tests). The Outbox must outlive the Mailer.
  • custom: Transport: The app's own delivery function.

spider.mail.Settings

zig
pub const Settings = struct

What Mailer.fromSettings reads; Mailer.fromEnv fills it from the environment.

Fields

  • transport: []const u8 = "log": "brevo", "resend", "postmark" or "log".
  • from: ?[]const u8 = null: The default sender: "Name <address>" or a bare address.
  • api_key: ?[]const u8 = null: The provider's API key (Postmark: the server token). Required unless the transport is "log".
  • base_url: ?[]const u8 = null: Replaces the provider's API URL (a mock server, a regional endpoint).
  • timeout_ms: u32 = 30_000: How long one delivery may take, in milliseconds, before it fails with error.MailDeliveryFailed. 0: no limit. A provider that accepts the connection and then stays silent used to hold the request that was sending for as long as it liked.

spider.mail.Mailer

zig
pub const Mailer = struct

Sends mail through one backend. Create it once at startup (fromEnv, fromSettings, or a literal with a Backend) and keep it: it is a plain value, safe to copy and to use from several requests.

Fields

  • backend: Backend: Where the mail goes.
  • from: ?Mailbox = null: The sender of a mail that declares none.

mail.Mailer.send

zig
pub fn send(self: Mailer, c: *Ctx, mail: Mail) anyerror!Receipt

Sends from a handler.

Errors: MailMissingFrom, MailMissingRecipients, MailMissingBody, MailInvalidAddress, MailInvalidHeader (nothing was sent); MailUnauthorized (the provider refused the API key), MailRejected (it refused this message), MailDeliveryFailed (network, rate limit or provider failure: worth retrying). A .custom transport returns its own errors. The request to the provider is given up after the transport's timeout_ms (30 seconds unless set otherwise: also MailDeliveryFailed) and is not retried. The receipt's id is allocated in the request arena.

mail.Mailer.sendWith

zig
pub fn sendWith(self: Mailer, arena: std.mem.Allocator, io: std.Io, mail: Mail) anyerror!Receipt

Sends outside a request (jobs, boot): mailer.sendWith(arena, hub.io, mail). Same checks and errors as send. arena holds the request sent to the provider, its answer and the receipt's id: use an arena, nothing is freed one by one.

mail.Mailer.fromSettings

zig
pub fn fromSettings(settings: Settings) !Mailer

A Mailer for settings. The result borrows the strings of settings: they must stay valid as long as the Mailer. Errors: MailTransportUnknown (a transport other than brevo, resend, postmark or log), MailApiKeyMissing (a provider without a key, or an empty one), MailInvalidAddress / MailInvalidHeader (a bad from).

mail.Mailer.fromEnv

zig
pub fn fromEnv() !Mailer

Reads MAIL_TRANSPORT (brevo | resend | postmark | log, default log), MAIL_FROM, MAIL_BASE_URL, MAIL_TIMEOUT_MS (default 30000; 0 for no limit) and the transport's key: BREVO_API_KEY, RESEND_API_KEY or POSTMARK_SERVER_TOKEN. Call it once at startup and keep the Mailer (each call copies the variables it reads).

mail.Mailer.fromLookup

zig
pub fn fromLookup(lookup: *const fn (key: []const u8) ?[]const u8) !Mailer

fromEnv() over any variable lookup.