Email framework

Create mailers with `jetzig generate mailer <name>`. Mailers define
default values for email fields (e.g. subject, from address, etc.).
Mailers use Zmpl for rendering text/HTML parts.

Send an email from a request with `request.mail()`. Call
`deliver(.background, .{})` on the return value to use the built-in
mail job to send the email asynchronously.

Improve query/HTTP request body param parsing - unescape `+` and `%XX`
characters.
This commit is contained in:
Bob Farrell 2024-04-15 22:28:27 +01:00
parent 3f6c1a4919
commit 47c35632b5
38 changed files with 1195 additions and 148 deletions

View File

@ -15,15 +15,13 @@ const zmpl_build = @import("zmpl");
pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const template_path_option = b.option([]const u8, "zmpl_templates_path", "Path to templates") orelse
"src/app/views/";
const template_path: []const u8 = if (std.fs.path.isAbsolute(template_path_option))
try b.allocator.dupe(u8, template_path_option)
else
std.fs.cwd().realpathAlloc(b.allocator, template_path_option) catch |err| switch (err) {
error.FileNotFound => "",
else => return err,
};
const templates_paths = try zmpl_build.templatesPaths(
b.allocator,
&.{
.{ .prefix = "views", .path = &.{ "src", "app", "views" } },
.{ .prefix = "mailers", .path = &.{ "src", "app", "mailers" } },
},
);
const lib = b.addStaticLibrary(.{
.name = "jetzig",
@ -40,32 +38,13 @@ pub fn build(b: *std.Build) !void {
jetzig_module.addImport("mime_types", mime_module);
lib.root_module.addImport("jetzig", jetzig_module);
const zmpl_version = b.option(
enum { v1, v2 },
"zmpl_version",
"Zmpl syntax version (default: v1)",
) orelse .v2;
if (zmpl_version == .v1) {
std.debug.print(
\\[WARN] Zmpl v1 is deprecated and will soon be removed.
\\ Update to v2 by modifying `jetzigInit` in your `build.zig`:
\\
\\ try jetzig.jetzigInit(b, exe, .{{ .zmpl_version = .v2 }});
\\
\\ See https://jetzig.dev/documentation.html for information on migrating to Zmpl v2.
\\
, .{});
}
const zmpl_dep = b.dependency(
"zmpl",
.{
.target = target,
.optimize = optimize,
.zmpl_templates_path = template_path,
.zmpl_templates_paths = templates_paths,
.zmpl_auto_build = false,
.zmpl_version = zmpl_version,
.zmpl_constants = try zmpl_build.addTemplateConstants(b, struct {
jetzig_view: []const u8,
jetzig_action: []const u8,
@ -84,11 +63,17 @@ pub fn build(b: *std.Build) !void {
const zmd_dep = b.dependency("zmd", .{ .target = target, .optimize = optimize });
const smtp_client_dep = b.dependency("smtp_client", .{
.target = target,
.optimize = optimize,
});
lib.root_module.addImport("zmpl", zmpl_module);
jetzig_module.addImport("zmpl", zmpl_module);
jetzig_module.addImport("args", zig_args_dep.module("args"));
jetzig_module.addImport("zmd", zmd_dep.module("zmd"));
jetzig_module.addImport("jetkv", jetkv_dep.module("jetkv"));
jetzig_module.addImport("smtp", smtp_client_dep.module("smtp_client"));
const main_tests = b.addTest(.{
.root_source_file = .{ .path = "src/tests.zig" },
@ -115,15 +100,20 @@ pub fn build(b: *std.Build) !void {
/// Build-time options for Jetzig.
pub const JetzigInitOptions = struct {
zmpl_version: enum { v1, v2 } = .v1,
zmpl_version: enum { v1, v2 } = .v2,
};
pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigInitOptions) !void {
if (options.zmpl_version == .v1) {
std.debug.print("Zmpl v1 has now been removed. Please upgrade to v2.\n", .{});
return error.ZmplVersionNotSupported;
}
const target = b.host;
const optimize = exe.root_module.optimize orelse .Debug;
const jetzig_dep = b.dependency(
"jetzig",
.{ .optimize = optimize, .target = target, .zmpl_version = options.zmpl_version },
.{ .optimize = optimize, .target = target },
);
const jetzig_module = jetzig_dep.module("jetzig");
const zmpl_module = jetzig_dep.module("zmpl");
@ -140,17 +130,31 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
}
const root_path = b.build_root.path orelse try std.fs.cwd().realpathAlloc(b.allocator, ".");
const templates_path = try std.fs.path.join(
const templates_path: []const u8 = try std.fs.path.join(
b.allocator,
&[_][]const u8{ root_path, "src", "app" },
);
const views_path: []const u8 = try std.fs.path.join(
b.allocator,
&[_][]const u8{ root_path, "src", "app", "views" },
);
const jobs_path = try std.fs.path.join(
b.allocator,
&[_][]const u8{ root_path, "src", "app", "jobs" },
);
const mailers_path = try std.fs.path.join(
b.allocator,
&[_][]const u8{ root_path, "src", "app", "mailers" },
);
var generate_routes = try Routes.init(b.allocator, root_path, templates_path, jobs_path);
var generate_routes = try Routes.init(
b.allocator,
root_path,
templates_path,
views_path,
jobs_path,
mailers_path,
);
try generate_routes.generateRoutes();
const write_files = b.addWriteFiles();
const routes_file = write_files.add("routes.zig", generate_routes.buffer.items);

View File

@ -7,8 +7,8 @@
.hash = "12207d49df326e0c180a90fa65d9993898e0a0ffd8e79616b4b81f81769261858856",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/1fcdfc42444224d43eb6e5389d9b4aa239fcd787.tar.gz",
.hash = "1220f7bc1e2b2317790db37b7dec685c7d9a2ece9708108a4b636477d996be002c71",
.url = "https://github.com/jetzig-framework/zmpl/archive/4511ae706e8679385d38cc1366497082f8f53afb.tar.gz",
.hash = "1220d493e6fdfaccbafff41df2b7b407728ed11619bebb198c90dae9420f03a6d29d",
},
.args = .{
.url = "https://github.com/MasterQ32/zig-args/archive/01d72b9a0128c474aeeb9019edd48605fa6d95f7.tar.gz",
@ -18,6 +18,10 @@
.url = "https://github.com/jetzig-framework/jetkv/archive/a6fcc2df220c1a40094e167eeb567bb5888404e9.tar.gz",
.hash = "12207bd2d7465b33e745a5b0567172377f94a221d1fc9aab238bb1b372c64f4ec1a0",
},
.smtp_client = .{
.url = "https://github.com/karlseguin/smtp_client.zig/archive/e79e411862d4f4d41657bf41efb884efca3d67dd.tar.gz",
.hash = "12209907c69891a38e6923308930ac43bfb40135bc609ea370b5759fc2e1c4f57284",
},
},
.paths = .{

View File

@ -1,17 +1,19 @@
const std = @import("std");
const args = @import("args");
const secret = @import("generate/secret.zig");
const util = @import("../util.zig");
const view = @import("generate/view.zig");
const partial = @import("generate/partial.zig");
const layout = @import("generate/layout.zig");
const middleware = @import("generate/middleware.zig");
const job = @import("generate/job.zig");
const secret = @import("generate/secret.zig");
const util = @import("../util.zig");
const mailer = @import("generate/mailer.zig");
/// Command line options for the `generate` command.
pub const Options = struct {
pub const meta = .{
.usage_summary = "[view|partial|layout|middleware|job|secret] [options]",
.usage_summary = "[view|partial|layout|mailer|middleware|job|secret] [options]",
.full_text =
\\Generate scaffolding for views, middleware, and other objects.
\\
@ -36,34 +38,38 @@ pub fn run(
_ = options;
var generate_type: ?enum { view, partial, layout, middleware, job, secret } = null;
const Generator = enum { view, partial, layout, mailer, middleware, job, secret };
var sub_args = std.ArrayList([]const u8).init(allocator);
defer sub_args.deinit();
for (positionals) |arg| {
if (generate_type == null and std.mem.eql(u8, arg, "view")) {
generate_type = .view;
} else if (generate_type == null and std.mem.eql(u8, arg, "partial")) {
generate_type = .partial;
} else if (generate_type == null and std.mem.eql(u8, arg, "layout")) {
generate_type = .layout;
} else if (generate_type == null and std.mem.eql(u8, arg, "job")) {
generate_type = .job;
} else if (generate_type == null and std.mem.eql(u8, arg, "middleware")) {
generate_type = .middleware;
} else if (generate_type == null and std.mem.eql(u8, arg, "secret")) {
generate_type = .secret;
} else if (generate_type == null) {
std.debug.print("Unknown generator command: {s}\n", .{arg});
return error.JetzigCommandError;
} else {
try sub_args.append(arg);
}
const map = std.ComptimeStringMap(Generator, .{
.{ "view", .view },
.{ "partial", .partial },
.{ "layout", .layout },
.{ "job", .job },
.{ "mailer", .mailer },
.{ "middleware", .middleware },
.{ "secret", .secret },
});
var available_buf = std.ArrayList([]const u8).init(allocator);
defer available_buf.deinit();
for (map.kvs) |kv| try available_buf.append(kv.key);
const available_help = try std.mem.join(allocator, "|", available_buf.items);
defer allocator.free(available_help);
const generate_type: ?Generator = if (positionals.len > 0) map.get(positionals[0]) else null;
if (positionals.len > 1) {
for (positionals[1..]) |arg| try sub_args.append(arg);
}
if (other_options.help and generate_type == null) {
try args.printHelp(Options, "jetzig generate", writer);
return;
} else if (generate_type == null) {
std.debug.print("Missing sub-command. Expected: [{s}]\n", .{available_help});
return error.JetzigCommandError;
}
if (generate_type) |capture| {
@ -71,12 +77,10 @@ pub fn run(
.view => view.run(allocator, cwd, sub_args.items, other_options.help),
.partial => partial.run(allocator, cwd, sub_args.items, other_options.help),
.layout => layout.run(allocator, cwd, sub_args.items, other_options.help),
.mailer => mailer.run(allocator, cwd, sub_args.items, other_options.help),
.job => job.run(allocator, cwd, sub_args.items, other_options.help),
.middleware => middleware.run(allocator, cwd, sub_args.items, other_options.help),
.secret => secret.run(allocator, cwd, sub_args.items, other_options.help),
};
} else {
std.debug.print("Missing sub-command. Expected: [view|partial|layout|job|middleware|secret]\n", .{});
return error.JetzigCommandError;
}
}

View File

@ -31,7 +31,7 @@ pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, he
const file = dir.createFile(filename, .{ .exclusive = true }) catch |err| {
switch (err) {
error.PathAlreadyExists => {
std.debug.print("Partial already exists: {s}\n", .{filename});
std.debug.print("Job already exists: {s}\n", .{filename});
return error.JetzigCommandError;
},
else => return err,
@ -42,10 +42,20 @@ pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, he
\\const std = @import("std");
\\const jetzig = @import("jetzig");
\\
\\/// The `run` function for all jobs receives an arena allocator, a logger, and the params
\\/// passed to the job when it was created.
\\// The `run` function for a job is invoked every time the job is processed by a queue worker
\\// (or by the Jetzig server if the job is processed in-line).
\\//
\\// Arguments:
\\// * allocator: Arena allocator for use during the job execution process.
\\// * params: Params assigned to a job (from a request, any values added to `data`).
\\// * env: Provides the following fields:
\\// - logger: Logger attached to the same stream as the Jetzig server.
\\// - environment: Enum of `{ production, development }`.
\\pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, logger: jetzig.Logger) !void {
\\ // Job execution code
\\ _ = allocator;
\\ _ = params;
\\ // Job execution code goes here. Add any code that you would like to run in the background.
\\ try env.logger.INFO("Running a job.", .{});
\\}
\\
);

View File

@ -0,0 +1,146 @@
const std = @import("std");
/// Run the mailer generator. Create a mailer in `src/app/mailers/`
pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8, help: bool) !void {
if (help or args.len != 1) {
std.debug.print(
\\Generate a new Mailer. Mailers provide an interface for sending emails from a Jetzig application.
\\
\\Example:
\\
\\ jetzig generate mailer iguana
\\
, .{});
if (help) return;
return error.JetzigCommandError;
}
const name = args[0];
const dir_path = try std.fs.path.join(allocator, &[_][]const u8{ "src", "app", "mailers" });
defer allocator.free(dir_path);
var dir = try cwd.makeOpenPath(dir_path, .{});
defer dir.close();
const filename = try std.mem.concat(allocator, u8, &[_][]const u8{ name, ".zig" });
defer allocator.free(filename);
const mailer_file = dir.createFile(filename, .{ .exclusive = true }) catch |err| {
switch (err) {
error.PathAlreadyExists => {
std.debug.print("Mailer already exists: {s}\n", .{filename});
return error.JetzigCommandError;
},
else => return err,
}
};
try mailer_file.writeAll(
\\const std = @import("std");
\\const jetzig = @import("jetzig");
\\
\\// Default values for this mailer.
\\pub const defaults: jetzig.mail.DefaultMailParams = .{
\\ .from = "no-reply@example.com",
\\ .subject = "Default subject",
\\};
\\
\\// The `deliver` function is invoked every time this mailer is used to send an email.
\\// Use this function to modify mail parameters before the mail is delivered, or simply
\\// to log all uses of this mailer.
\\//
\\// To use this mailer from a request:
\\// ```
\\// const mail = request.mail("<mailer-name>", .{ .to = &.{"user@example.com"} });
\\// try mail.deliver(.background, .{});
\\// ```
\\// A mailer can provide two Zmpl templates for rendering email content:
\\// * `src/app/mailers/<mailer-name>/html.zmpl
\\// * `src/app/mailers/<mailer-name>/text.zmpl
\\//
\\// Arguments:
\\// * allocator: Arena allocator for use during the mail delivery process.
\\// * mail: Mail parameters. Inspect or override any values assigned when the mail was created.
\\// * params: Params assigned to a mail (from a request, any values added to `data`). Params
\\// can be modified before email delivery.
\\// * env: Provides the following fields:
\\// - logger: Logger attached to the same stream as the Jetzig server.
\\// - environment: Enum of `{ production, development }`.
\\pub fn deliver(
\\ allocator: std.mem.Allocator,
\\ mail: *jetzig.mail.MailParams,
\\ params: *jetzig.data.Value,
\\ env: jetzig.jobs.JobEnv,
\\) !void {
\\ _ = allocator;
\\ _ = params;
\\
\\ try env.logger.INFO("Delivering email with subject: '{?s}'", .{mail.get(.subject)});
\\}
\\
);
mailer_file.close();
const realpath = try dir.realpathAlloc(allocator, filename);
defer allocator.free(realpath);
std.debug.print("Generated mailer: {s}\n", .{realpath});
const template_dir_path = try std.fs.path.join(allocator, &[_][]const u8{ "src", "app", "mailers", name });
defer allocator.free(template_dir_path);
var template_dir = try cwd.makeOpenPath(template_dir_path, .{});
defer template_dir.close();
const html_template_file: ?std.fs.File = template_dir.createFile(
"html.zmpl",
.{ .exclusive = true },
) catch |err| blk: {
switch (err) {
error.PathAlreadyExists => {
std.debug.print("Template already exists: `{s}/html.zmpl` - skipping.\n", .{template_dir_path});
break :blk null;
},
else => return err,
}
};
const text_template_file: ?std.fs.File = template_dir.createFile(
"text.zmpl",
.{ .exclusive = true },
) catch |err| blk: {
switch (err) {
error.PathAlreadyExists => {
std.debug.print("Template already exists: `{s}/text.zmpl` - skipping.\n", .{template_dir_path});
break :blk null;
},
else => return err,
}
};
if (html_template_file) |file| {
try file.writeAll(
\\<div>HTML content goes here</div>
\\
);
file.close();
const html_template_realpath = try template_dir.realpathAlloc(allocator, "html.zmpl");
defer allocator.free(html_template_realpath);
std.debug.print("Generated mailer template: {s}\n", .{html_template_realpath});
}
if (text_template_file) |file| {
try file.writeAll(
\\Text content goes here
\\
);
file.close();
const text_template_realpath = try template_dir.realpathAlloc(allocator, "text.zmpl");
defer allocator.free(text_template_realpath);
std.debug.print("Generated mailer template: {s}\n", .{text_template_realpath});
}
}

View File

@ -18,7 +18,7 @@ pub fn build(b: *std.Build) !void {
// All dependencies **must** be added to imports above this line.
try jetzig.jetzigInit(b, exe, .{ .zmpl_version = .v2 });
try jetzig.jetzigInit(b, exe, .{});
b.installArtifact(exe);

View File

@ -1,9 +1,22 @@
const std = @import("std");
const jetzig = @import("jetzig");
/// The `run` function for all jobs receives an arena allocator, a logger, and the params
/// passed to the job when it was created.
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, logger: jetzig.Logger) !void {
_ = allocator;
try logger.INFO("Job received params: {s}", .{try params.toJson()});
/// The `run` function for all jobs receives an arena allocator, the params passed to the job
/// when it was created, and an environment which provides a logger, the current server
/// environment `{ development, production }`.
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
try env.logger.INFO("Job received params: {s}", .{try params.toJson()});
const mail = jetzig.mail.Mail.init(
allocator,
.{
.subject = "Hello!!!",
.from = "bob@jetzig.dev",
.to = &.{"bob@jetzig.dev"},
.html = "<div>Hello!</div>",
.text = "Hello!",
},
);
try mail.deliver();
}

View File

@ -0,0 +1,36 @@
const std = @import("std");
const jetzig = @import("jetzig");
// Default values for this mailer.
pub const defaults: jetzig.mail.DefaultMailParams = .{
.from = "no-reply@example.com",
.subject = "Default subject",
};
// The `deliver` function is invoked every time this mailer is used to send an email.
// Use this function to set default mail params (e.g. a default `from` address or
// `subject`) before the mail is delivered.
//
// A mailer can provide two Zmpl templates for rendering email content:
// * `src/app/mailers/<mailer-name>/html.zmpl
// * `src/app/mailers/<mailer-name>/text.zmpl
//
// Arguments:
// * allocator: Arena allocator for use during the mail delivery process.
// * mail: Mail parameters. Inspect or override any values assigned when the mail was created.
// * params: Params assigned to a mail (from a request, any values added to `data`). Params
// can be modified before email delivery.
// * env: Provides the following fields:
// - logger: Logger attached to the same stream as the Jetzig server.
// - environment: Enum of `{ production, development }`.
pub fn deliver(
allocator: std.mem.Allocator,
mail: *jetzig.mail.MailParams,
params: *jetzig.data.Value,
env: jetzig.jobs.JobEnv,
) !void {
_ = allocator;
_ = params;
try env.logger.INFO("Delivering email with subject: '{?s}'", .{mail.get(.subject)});
}

View File

@ -0,0 +1 @@
<div>{{.message}}</div>

View File

@ -0,0 +1 @@
{{.message}}

View File

@ -8,8 +8,8 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
var job = try request.job("example");
// Add a param `foo` to the job.
try job.put("foo", data.string("bar"));
try job.put("id", data.integer(std.crypto.random.int(u32)));
try job.params.put("foo", data.string("bar"));
try job.params.put("id", data.integer(std.crypto.random.int(u32)));
// Schedule the job for background processing. The job is added to the queue. When the job is
// processed a new instance of `example_job` is created and its `run` function is invoked.

View File

@ -0,0 +1,20 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
var root = try data.object();
try root.put("message", data.string("Welcome to Jetzig!"));
// Create a new mail using `src/app/mailers/welcome.zig`.
// HTML and text parts are rendered using Zmpl templates:
// * `src/app/mailers/welcome/html.zmpl`
// * `src/app/mailers/welcome/text.zmpl`
// All mailer templates have access to the same template data as a view template.
const mail = request.mail("welcome", .{ .to = &.{"hello.dev"} });
// Deliver the email asynchronously via a built-in mail Job. Use `.now` to send the email
// synchronously (i.e. before the request has returned).
try mail.deliver(.background, .{});
return request.render(.ok);
}

View File

@ -0,0 +1,3 @@
<div>
<span>Your email has been sent!</span>
</div>

View File

@ -0,0 +1 @@
<div>{{.data.test}}</div>

View File

@ -0,0 +1 @@
{{.data.test}}

View File

@ -0,0 +1 @@
<div>Hello</div>

View File

@ -40,6 +40,19 @@ pub const jetzig_options = struct {
// milliseconds.
// pub const job_worker_sleep_interval_ms: usize = 10;
/// SMTP configuration for Jetzig Mail. It is recommended to use a local SMTP relay,
/// e.g.: https://github.com/juanluisbaptiste/docker-postfix
// pub const smtp: jetzig.mail.SMTPConfig = .{
// .port = 25,
// .encryption = .none, // .insecure, .none, .tls, .start_tls
// .host = "localhost",
// .username = null,
// .password = null,
// };
/// Force email delivery in development mode (instead of printing email body to logger).
// pub const force_development_email_delivery = false;
// Set custom fragments for rendering markdown templates. Any values will fall back to
// defaults provided by Zmd (https://github.com/bobf/zmd/blob/main/src/zmd/html.zig).
pub const markdown_fragments = struct {

View File

@ -4,8 +4,10 @@ const jetzig = @import("jetzig.zig");
ast: std.zig.Ast = undefined,
allocator: std.mem.Allocator,
root_path: []const u8,
templates_path: []const u8,
views_path: []const u8,
jobs_path: []const u8,
mailers_path: []const u8,
buffer: std.ArrayList(u8),
dynamic_routes: std.ArrayList(Function),
static_routes: std.ArrayList(Function),
@ -87,8 +89,10 @@ const Arg = struct {
pub fn init(
allocator: std.mem.Allocator,
root_path: []const u8,
templates_path: []const u8,
views_path: []const u8,
jobs_path: []const u8,
mailers_path: []const u8,
) !Routes {
const data = try allocator.create(jetzig.data.Data);
data.* = jetzig.data.Data.init(allocator);
@ -96,8 +100,10 @@ pub fn init(
return .{
.allocator = allocator,
.root_path = root_path,
.templates_path = templates_path,
.views_path = views_path,
.jobs_path = jobs_path,
.mailers_path = mailers_path,
.buffer = std.ArrayList(u8).init(allocator),
.static_routes = std.ArrayList(Function).init(allocator),
.dynamic_routes = std.ArrayList(Function).init(allocator),
@ -122,9 +128,18 @@ pub fn generateRoutes(self: *Routes) !void {
\\pub const routes = [_]jetzig.Route{
\\
);
try self.writeRoutes(writer);
try writer.writeAll(
\\};
\\
);
try writer.writeAll(
\\
\\pub const mailers = [_]jetzig.MailerDefinition{
\\
);
try self.writeMailers(writer);
try writer.writeAll(
\\};
\\
@ -133,11 +148,10 @@ pub fn generateRoutes(self: *Routes) !void {
try writer.writeAll(
\\
\\pub const jobs = [_]jetzig.JobDefinition{
\\ .{ .name = "__jetzig_mail", .runFn = jetzig.mail.Job.run },
\\
);
try self.writeJobs(writer);
try writer.writeAll(
\\};
\\
@ -148,13 +162,14 @@ pub fn generateRoutes(self: *Routes) !void {
pub fn relativePathFrom(
self: Routes,
root: enum { root, views, jobs },
root: enum { root, views, mailers, jobs },
sub_path: []const u8,
format: enum { os, posix },
) ![]u8 {
const root_path = switch (root) {
.root => self.root_path,
.views => self.views_path,
.mailers => self.mailers_path,
.jobs => self.jobs_path,
};
@ -250,7 +265,11 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
const view_name = try route.viewName();
defer self.allocator.free(view_name);
const template = try std.mem.concat(self.allocator, u8, &[_][]const u8{ view_name, "/", route.name });
const template = try std.mem.concat(
self.allocator,
u8,
&[_][]const u8{ view_name, "/", route.name },
);
std.mem.replaceScalar(u8, module_path, '\\', '/');
@ -579,9 +598,61 @@ fn normalizePosix(self: Routes, path: []const u8) ![]u8 {
return try std.mem.join(self.allocator, std.fs.path.sep_str_posix, buf.items);
}
//
// Generate Jobs
//
fn writeMailers(self: Routes, writer: anytype) !void {
var dir = std.fs.openDirAbsolute(self.mailers_path, .{ .iterate = true }) catch |err| {
switch (err) {
error.FileNotFound => {
std.debug.print(
"[jetzig] Mailers directory not found, no mailers generated: `{s}`\n",
.{self.mailers_path},
);
return;
},
else => return err,
}
};
defer dir.close();
var count: usize = 0;
var walker = try dir.walk(self.allocator);
while (try walker.next()) |entry| {
if (!std.mem.eql(u8, std.fs.path.extension(entry.path), ".zig")) continue;
const realpath = try dir.realpathAlloc(self.allocator, entry.path);
defer self.allocator.free(realpath);
const root_relative_path = try self.relativePathFrom(.root, realpath, .posix);
defer self.allocator.free(root_relative_path);
const mailers_relative_path = try self.relativePathFrom(.mailers, realpath, .posix);
defer self.allocator.free(mailers_relative_path);
const module_path = try self.zigEscape(root_relative_path);
defer self.allocator.free(module_path);
const name_path = try self.zigEscape(mailers_relative_path);
defer self.allocator.free(name_path);
const name = chompExtension(name_path);
try writer.writeAll(try std.fmt.allocPrint(
self.allocator,
\\ .{{
\\ .name = "{0s}",
\\ .deliverFn = @import("{1s}").deliver,
\\ .defaults = if (@hasDecl(@import("{1s}"), "defaults")) @import("{1s}").defaults else null,
\\ .html_template = "{0s}/html",
\\ .text_template = "{0s}/text",
\\ }},
\\
,
.{ name, module_path },
));
count += 1;
}
std.debug.print("[jetzig] Imported {} mailer(s)\n", .{count});
}
fn writeJobs(self: Routes, writer: anytype) !void {
var dir = std.fs.openDirAbsolute(self.jobs_path, .{ .iterate = true }) catch |err| {

View File

@ -118,7 +118,7 @@ fn renderMarkdown(
defer allocator.free(prefixed_name);
defer allocator.free(prefixed_name);
if (zmpl.find(prefixed_name)) |layout| {
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
view.data.content = .{ .data = content };
return try layout.render(view.data);
} else {
@ -133,7 +133,7 @@ fn renderZmplTemplate(
route: jetzig.views.Route,
view: jetzig.views.View,
) !?[]const u8 {
if (zmpl.find(route.template)) |template| {
if (zmpl.findPrefixed("views", route.template)) |template| {
try view.data.addConst("jetzig_view", view.data.string(route.name));
try view.data.addConst("jetzig_action", view.data.string(@tagName(route.action)));
@ -142,7 +142,7 @@ fn renderZmplTemplate(
const prefixed_name = try std.mem.concat(allocator, u8, &[_][]const u8{ "layouts_", layout_name });
defer allocator.free(prefixed_name);
if (zmpl.find(prefixed_name)) |layout| {
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
return try template.renderWithLayout(layout, view.data);
} else {
std.debug.print("Unknown layout: {s}\n", .{layout_name});

View File

@ -14,6 +14,7 @@ pub const util = @import("jetzig/util.zig");
pub const types = @import("jetzig/types.zig");
pub const markdown = @import("jetzig/markdown.zig");
pub const jobs = @import("jetzig/jobs.zig");
pub const mail = @import("jetzig/mail.zig");
/// The primary interface for a Jetzig application. Create an `App` in your application's
/// `src/main.zig` and call `start` to launch the application.
@ -52,6 +53,9 @@ pub const Job = jobs.Job;
/// A container for a job definition, includes the job name and run function.
pub const JobDefinition = jobs.Job.JobDefinition;
/// A container for a mailer definition, includes mailer name and mail function.
pub const MailerDefinition = mail.MailerDefinition;
/// A generic logger type. Provides all standard log levels as functions (`INFO`, `WARN`,
/// `ERROR`, etc.). Note that all log functions are CAPITALIZED.
pub const Logger = loggers.Logger;
@ -95,6 +99,18 @@ pub const config = struct {
/// milliseconds.
pub const job_worker_sleep_interval_ms: usize = 10;
/// SMTP configuration for Jetzig Mail.
pub const smtp: mail.SMTPConfig = .{
.port = 25,
.encryption = .none, // .insecure, .none, .tls, .start_tls
.host = "localhost",
.username = null,
.password = null,
};
/// Force email delivery in development mode (instead of printing email body to logger).
pub const force_development_email_delivery = false;
/// Reconciles a configuration value from user-defined values and defaults provided by Jetzig.
pub fn get(T: type, comptime key: []const u8) T {
const self = @This();

View File

@ -79,6 +79,7 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void {
self.server_options,
routes.items,
&routes_module.jobs,
&routes_module.mailers,
&mime_map,
&jet_kv,
);
@ -87,8 +88,13 @@ pub fn start(self: App, routes_module: type, options: AppOptions) !void {
var worker_pool = jetzig.jobs.Pool.init(
self.allocator,
&jet_kv,
&routes_module.jobs,
server.logger, // TODO: Optional separate log streams for workers
.{
.logger = server.logger,
.environment = server.options.environment,
.routes = routes.items,
.jobs = &routes_module.jobs,
.mailers = &routes_module.mailers,
},
);
defer worker_pool.deinit();

View File

@ -17,7 +17,7 @@ const Options = struct {
environment: EnvironmentName = .development,
log: []const u8 = "-",
@"log-error": []const u8 = "-",
@"log-level": jetzig.loggers.LogLevel = .INFO,
@"log-level": ?jetzig.loggers.LogLevel = null,
@"log-format": jetzig.loggers.LogFormat = .development,
detach: bool = false,
@ -41,7 +41,7 @@ const Options = struct {
\\Optional path to separate error log file. Use '-' for stderr. If omitted, errors are logged to the location specified by the `log` option (or stderr if `log` is '-').
,
.@"log-level" =
\\Minimum log level. Log events below the given level are ignored. Must be one of: { TRACE, DEBUG, INFO, WARN, ERROR, FATAL } (default: DEBUG)
\\Minimum log level. Log events below the given level are ignored. Must be one of: { TRACE, DEBUG, INFO, WARN, ERROR, FATAL } (default: DEBUG in development, INFO in production)
,
.@"log-format" =
\\Output logs in the given format. Must be one of: { development, json } (default: development)
@ -69,11 +69,13 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions {
std.process.exit(0);
}
const environment = options.options.environment;
var logger = switch (options.options.@"log-format") {
.development => jetzig.loggers.Logger{
.development_logger = jetzig.loggers.DevelopmentLogger.init(
self.allocator,
options.options.@"log-level",
resolveLogLevel(options.options.@"log-level", environment),
try getLogFile(.stdout, options.options),
try getLogFile(.stderr, options.options),
),
@ -81,7 +83,7 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions {
.json => jetzig.loggers.Logger{
.json_logger = jetzig.loggers.JsonLogger.init(
self.allocator,
options.options.@"log-level",
resolveLogLevel(options.options.@"log-level", environment),
try getLogFile(.stdout, options.options),
try getLogFile(.stderr, options.options),
),
@ -93,8 +95,6 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions {
std.process.exit(1);
}
const environment = options.options.environment;
// TODO: Generate nonce per session - do research to confirm correct best practice.
const secret_len = jetzig.http.Session.Cipher.key_length + jetzig.http.Session.Cipher.nonce_length;
const secret = (try self.getSecret(&logger, secret_len, environment))[0..secret_len];
@ -162,3 +162,10 @@ fn getSecret(self: Environment, logger: *jetzig.loggers.Logger, comptime len: u1
}
};
}
fn resolveLogLevel(level: ?jetzig.loggers.LogLevel, environment: EnvironmentName) jetzig.loggers.LogLevel {
return level orelse switch (environment) {
.development => .DEBUG,
.production => .INFO,
};
}

View File

@ -109,12 +109,18 @@ fn mappingParam(input: []const u8) ?struct { key: []const u8, field: []const u8
fn dataValue(self: Query, value: ?[]const u8) *jetzig.data.Data.Value {
if (value) |item_value| {
return self.data.string(item_value);
const duped = self.data.getAllocator().dupe(u8, item_value) catch @panic("OOM");
return self.data.string(uriDecode(duped));
} else {
return jetzig.zmpl.Data._null(self.data.getAllocator());
}
}
fn uriDecode(input: []u8) []const u8 {
std.mem.replaceScalar(u8, input, '+', ' ');
return std.Uri.percentDecodeInPlace(input);
}
test "simple query string" {
const allocator = std.testing.allocator;
const query_string = "foo=bar&baz=qux";
@ -190,3 +196,22 @@ test "query string with param without value" {
else => std.testing.expect(false),
};
}
test "query string with encoded characters" {
const allocator = std.testing.allocator;
const query_string = "foo=bar+baz+qux&bar=hello%20%21%20how%20are%20you%20doing%20%3F%3F%3F";
var data = jetzig.data.Data.init(allocator);
var query = init(allocator, query_string, &data);
defer query.deinit();
try query.parse();
try std.testing.expectEqualStrings(
"bar baz qux",
(data.getT(.string, "foo")).?,
);
try std.testing.expectEqualStrings(
"hello ! how are you doing ???",
(data.getT(.string, "bar")).?,
);
}

View File

@ -330,6 +330,65 @@ pub fn job(self: *Request, job_name: []const u8) !*jetzig.Job {
return background_job;
}
const RequestMail = struct {
request: *Request,
mail_params: jetzig.mail.MailParams,
name: []const u8,
// Will allow scheduling when strategy is `.later` (e.g.).
const DeliveryOptions = struct {};
pub fn deliver(self: RequestMail, strategy: enum { background, now }, options: DeliveryOptions) !void {
_ = options;
var mail_job = try self.request.job("__jetzig_mail");
try mail_job.params.put("mailer_name", mail_job.data.string(self.name));
const from = if (self.mail_params.from) |from| mail_job.data.string(from) else null;
try mail_job.params.put("from", from);
var to_array = try mail_job.data.array();
if (self.mail_params.to) |capture| {
for (capture) |to| try to_array.append(mail_job.data.string(to));
}
try mail_job.params.put("to", to_array);
const subject = if (self.mail_params.subject) |subject| mail_job.data.string(subject) else null;
try mail_job.params.put("subject", subject);
const html = if (self.mail_params.html) |html| mail_job.data.string(html) else null;
try mail_job.params.put("html", html);
const text = if (self.mail_params.text) |text| mail_job.data.string(text) else null;
try mail_job.params.put("text", text);
if (self.request.response_data.value) |value| try mail_job.params.put("params", value);
switch (strategy) {
.background => try mail_job.schedule(),
.now => try mail_job.definition.?.runFn(
self.request.allocator,
mail_job.params,
jetzig.jobs.JobEnv{
.environment = self.request.server.options.environment,
.logger = self.request.server.logger,
.routes = self.request.server.routes,
.mailers = self.request.server.mailer_definitions,
.jobs = self.request.server.job_definitions,
},
),
}
}
};
pub fn mail(self: *Request, name: []const u8, mail_params: jetzig.mail.MailParams) RequestMail {
return .{
.request = self,
.name = name,
.mail_params = mail_params,
};
}
fn extensionFormat(self: *Request) ?jetzig.http.Request.Format {
const extension = self.path.extension orelse return null;
if (std.mem.eql(u8, extension, ".html")) {

View File

@ -17,6 +17,7 @@ logger: jetzig.loggers.Logger,
options: ServerOptions,
routes: []*jetzig.views.Route,
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
mime_map: *jetzig.http.mime.MimeMap,
std_net_server: std.net.Server = undefined,
initialized: bool = false,
@ -29,6 +30,7 @@ pub fn init(
options: ServerOptions,
routes: []*jetzig.views.Route,
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
mime_map: *jetzig.http.mime.MimeMap,
jet_kv: *jetzig.jetkv.JetKV,
) Server {
@ -38,6 +40,7 @@ pub fn init(
.options = options,
.routes = routes,
.job_definitions = job_definitions,
.mailer_definitions = mailer_definitions,
.mime_map = mime_map,
.jet_kv = jet_kv,
};
@ -150,7 +153,7 @@ fn renderHTML(
route: ?*jetzig.views.Route,
) !void {
if (route) |matched_route| {
const template = zmpl.find(matched_route.template);
const template = zmpl.findPrefixed("views", matched_route.template);
if (template == null) {
request.response_data.noop(bool, false); // FIXME: Weird Zig bug ? Any call here fixes it.
if (try self.renderMarkdown(request, route)) |rendered_markdown| {
@ -238,7 +241,7 @@ fn renderMarkdown(
);
defer self.allocator.free(prefixed_name);
if (zmpl.find(prefixed_name)) |layout| {
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
rendered.view.data.content = .{ .data = markdown_content };
rendered.content = try layout.render(rendered.view.data);
} else {
@ -307,7 +310,7 @@ fn renderTemplateWithLayout(
const prefixed_name = try std.mem.concat(self.allocator, u8, &[_][]const u8{ "layouts", "/", layout_name });
defer self.allocator.free(prefixed_name);
if (zmpl.find(prefixed_name)) |layout| {
if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
return try template.renderWithLayout(layout, view.data);
} else {
try self.logger.WARN("Unknown layout: {s}", .{layout_name});

View File

@ -1,4 +1,6 @@
pub const Job = @import("jobs/Job.zig");
pub const JobEnv = Job.JobEnv;
pub const JobConfig = Job.JobConfig;
pub const JobDefinition = Job.JobDefinition;
pub const Pool = @import("jobs/Pool.zig");
pub const Worker = @import("jobs/Worker.zig");

View File

@ -4,7 +4,15 @@ const jetzig = @import("../../jetzig.zig");
/// Job name and run function, used when generating an array of job definitions at build time.
pub const JobDefinition = struct {
name: []const u8,
runFn: *const fn (std.mem.Allocator, *jetzig.data.Value, jetzig.loggers.Logger) anyerror!void,
runFn: *const fn (std.mem.Allocator, *jetzig.data.Value, JobEnv) anyerror!void,
};
pub const JobEnv = struct {
logger: jetzig.loggers.Logger,
environment: jetzig.Environment.EnvironmentName,
routes: []*const jetzig.Route,
mailers: []const jetzig.MailerDefinition,
jobs: []const jetzig.JobDefinition,
};
allocator: std.mem.Allocator,
@ -12,8 +20,8 @@ jet_kv: *jetzig.jetkv.JetKV,
logger: jetzig.loggers.Logger,
name: []const u8,
definition: ?JobDefinition,
data: ?*jetzig.data.Data = null,
_params: ?*jetzig.data.Value = null,
data: *jetzig.data.Data,
params: *jetzig.data.Value,
const Job = @This();
@ -34,45 +42,30 @@ pub fn init(
}
}
const data = allocator.create(jetzig.data.Data) catch @panic("OOM");
data.* = jetzig.data.Data.init(allocator);
return .{
.allocator = allocator,
.jet_kv = jet_kv,
.logger = logger,
.name = name,
.definition = definition,
.data = data,
.params = data.object() catch @panic("OOM"),
};
}
/// Deinitialize the Job and frees memory
pub fn deinit(self: *Job) void {
if (self.data) |data| {
data.deinit();
self.allocator.destroy(data);
}
}
/// Add a parameter to the Job
pub fn put(self: *Job, key: []const u8, value: *jetzig.data.Value) !void {
var job_params = try self.params();
try job_params.put(key, value);
self.data.deinit();
self.allocator.destroy(self.data);
}
/// Add a Job to the queue
pub fn schedule(self: *Job) !void {
_ = try self.params();
const json = try self.data.?.toJson();
try self.params.put("__jetzig_job_name", self.data.string(self.name));
const json = try self.data.toJson();
try self.jet_kv.prepend("__jetzig_jobs", json);
try self.logger.INFO("Scheduled job: {s}", .{self.name});
}
fn params(self: *Job) !*jetzig.data.Value {
if (self.data == null) {
self.data = try self.allocator.create(jetzig.data.Data);
self.data.?.* = jetzig.data.Data.init(self.allocator);
self._params = try self.data.?.object();
try self._params.?.put("__jetzig_job_name", self.data.?.string(self.name));
}
return self._params.?;
}
// TODO: Tests :)

View File

@ -6,8 +6,7 @@ const Pool = @This();
allocator: std.mem.Allocator,
jet_kv: *jetzig.jetkv.JetKV,
job_definitions: []const jetzig.jobs.JobDefinition,
logger: jetzig.loggers.Logger,
job_env: jetzig.jobs.JobEnv,
pool: std.Thread.Pool = undefined,
workers: std.ArrayList(*jetzig.jobs.Worker),
@ -15,14 +14,12 @@ workers: std.ArrayList(*jetzig.jobs.Worker),
pub fn init(
allocator: std.mem.Allocator,
jet_kv: *jetzig.jetkv.JetKV,
job_definitions: []const jetzig.jobs.JobDefinition,
logger: jetzig.loggers.Logger,
job_env: jetzig.jobs.JobEnv,
) Pool {
return .{
.allocator = allocator,
.jet_kv = jet_kv,
.job_definitions = job_definitions,
.logger = logger,
.job_env = job_env,
.workers = std.ArrayList(*jetzig.jobs.Worker).init(allocator),
};
}
@ -43,10 +40,9 @@ pub fn work(self: *Pool, threads: usize, interval: usize) !void {
const worker = try self.allocator.create(jetzig.jobs.Worker);
worker.* = jetzig.jobs.Worker.init(
self.allocator,
self.logger,
self.job_env,
index,
self.jet_kv,
self.job_definitions,
interval,
);
try self.workers.append(worker);

View File

@ -4,26 +4,23 @@ const jetzig = @import("../../jetzig.zig");
const Worker = @This();
allocator: std.mem.Allocator,
logger: jetzig.loggers.Logger,
job_env: jetzig.jobs.JobEnv,
id: usize,
jet_kv: *jetzig.jetkv.JetKV,
job_definitions: []const jetzig.jobs.JobDefinition,
interval: usize,
pub fn init(
allocator: std.mem.Allocator,
logger: jetzig.loggers.Logger,
job_env: jetzig.jobs.JobEnv,
id: usize,
jet_kv: *jetzig.jetkv.JetKV,
job_definitions: []const jetzig.jobs.JobDefinition,
interval: usize,
) Worker {
return .{
.allocator = allocator,
.logger = logger,
.job_env = job_env,
.id = id,
.jet_kv = jet_kv,
.job_definitions = job_definitions,
.interval = interval * 1000 * 1000, // millisecond => nanosecond
};
}
@ -65,7 +62,7 @@ fn matchJob(self: Worker, json: []const u8) ?jetzig.jobs.JobDefinition {
const job_name = parsed_json.value.__jetzig_job_name;
// TODO: Hashmap
for (self.job_definitions) |job_definition| {
for (self.job_env.jobs) |job_definition| {
if (std.mem.eql(u8, job_definition.name, job_name)) {
parsed_json.deinit();
return job_definition;
@ -94,7 +91,7 @@ fn processJob(self: Worker, job_definition: jetzig.JobDefinition, json: []const
defer arena.deinit();
if (data.value) |params| {
job_definition.runFn(arena.allocator(), params, self.logger) catch |err| {
job_definition.runFn(arena.allocator(), params, self.job_env) catch |err| {
self.log(
.ERROR,
"[worker-{}] Encountered error processing job `{s}`: {s}",
@ -115,7 +112,7 @@ fn log(
comptime message: []const u8,
args: anytype,
) void {
self.logger.log(level, message, args) catch |err| {
self.job_env.logger.log(level, message, args) catch |err| {
// XXX: In (daemonized) deployment stderr will not be available, find a better solution.
// Note that this only occurs if logging itself fails.
std.debug.print("[worker-{}] Logger encountered error: {s}\n", .{ self.id, @errorName(err) });

9
src/jetzig/mail.zig Normal file
View File

@ -0,0 +1,9 @@
const std = @import("std");
pub const Mail = @import("mail/Mail.zig");
pub const SMTPConfig = @import("mail/SMTPConfig.zig");
pub const MailParams = @import("mail/MailParams.zig");
pub const DefaultMailParams = MailParams.DefaultMailParams;
pub const components = @import("mail/components.zig");
pub const Job = @import("mail/Job.zig");
pub const MailerDefinition = @import("mail/MailerDefinition.zig");

159
src/jetzig/mail/Job.zig Normal file
View File

@ -0,0 +1,159 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
/// Default Mail Job. Send an email with the given params in the background.
pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig.jobs.JobEnv) !void {
const mailer_name = if (params.get("mailer_name")) |param| switch (param.*) {
.Null => null,
.string => |string| string.value,
else => null,
} else null;
if (mailer_name == null) {
try env.logger.ERROR("Missing mailer name parameter", .{});
return error.JetzigMissingMailerName;
}
const mailer = findMailer(mailer_name.?, env) orelse {
try env.logger.ERROR("Unknown mailer: `{s}`", .{mailer_name.?});
return error.JetzigUnknownMailerName;
};
const subject = params.get("subject");
const from = params.get("from");
const html = params.get("html");
const text = params.get("text");
const to = try resolveTo(allocator, params);
var mail_params = jetzig.mail.MailParams{
.subject = resolveSubject(subject),
.from = resolveFrom(from),
.to = to,
.defaults = mailer.defaults,
};
try mailer.deliverFn(allocator, &mail_params, params, env);
const mail = jetzig.mail.Mail.init(allocator, .{
.subject = mail_params.get(.subject) orelse "(No subject)",
.from = mail_params.get(.from) orelse return error.JetzigMailerMissingFromAddress,
.to = mail_params.get(.to) orelse return error.JetzigMailerMissingToAddress,
.html = mail_params.get(.html) orelse try resolveHtml(allocator, mailer, html, params),
.text = mail_params.get(.text) orelse try resolveText(allocator, mailer, text, params),
});
if (env.environment == .development and !jetzig.config.get(bool, "force_development_email_delivery")) {
try env.logger.INFO(
"Skipping mail delivery in development environment:\n{s}",
.{try mail.generateData()},
);
} else {
try mail.deliver();
try env.logger.INFO("Delivered mail to: {s}", .{
try std.mem.join(allocator, ", ", mail.params.to.?),
});
}
}
fn resolveSubject(subject: ?*const jetzig.data.Value) ?[]const u8 {
if (subject) |capture| {
return switch (capture.*) {
.Null => null,
.string => |string| string.value,
else => unreachable,
};
} else {
return null;
}
}
fn resolveFrom(from: ?*const jetzig.data.Value) ?[]const u8 {
return if (from) |capture| switch (capture.*) {
.Null => null,
.string => |string| string.value,
else => unreachable,
} else null;
}
fn resolveTo(allocator: std.mem.Allocator, params: *const jetzig.data.Value) !?[]const []const u8 {
var to = std.ArrayList([]const u8).init(allocator);
if (params.get("to")) |capture| {
for (capture.items(.array)) |recipient| {
try to.append(recipient.string.value);
}
}
return if (to.items.len > 0) try to.toOwnedSlice() else null;
}
fn resolveText(
allocator: std.mem.Allocator,
mailer: jetzig.mail.MailerDefinition,
text: ?*const jetzig.data.Value,
params: *jetzig.data.Value,
) !?[]const u8 {
if (text) |capture| {
return switch (capture.*) {
.Null => try defaultText(allocator, mailer, params),
.string => |string| string.value,
else => unreachable,
};
} else {
return try defaultText(allocator, mailer, params);
}
}
fn resolveHtml(
allocator: std.mem.Allocator,
mailer: jetzig.mail.MailerDefinition,
text: ?*const jetzig.data.Value,
params: *jetzig.data.Value,
) !?[]const u8 {
if (text) |capture| {
return switch (capture.*) {
.Null => try defaultHtml(allocator, mailer, params),
.string => |string| string.value,
else => unreachable,
};
} else {
return try defaultHtml(allocator, mailer, params);
}
}
fn defaultHtml(
allocator: std.mem.Allocator,
mailer: jetzig.mail.MailerDefinition,
params: *jetzig.data.Value,
) !?[]const u8 {
var data = jetzig.data.Data.init(allocator);
data.value = if (params.get("params")) |capture| capture else try data.createObject();
try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template|
try template.render(&data)
else
null;
}
fn defaultText(
allocator: std.mem.Allocator,
mailer: jetzig.mail.MailerDefinition,
params: *jetzig.data.Value,
) !?[]const u8 {
var data = jetzig.data.Data.init(allocator);
data.value = if (params.get("params")) |capture| capture else try data.createObject();
try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template|
try template.render(&data)
else
null;
}
fn findMailer(name: []const u8, env: jetzig.jobs.JobEnv) ?jetzig.mail.MailerDefinition {
for (env.mailers) |mailer| {
if (std.mem.eql(u8, mailer.name, name)) return mailer;
}
return null;
}

343
src/jetzig/mail/Mail.zig Normal file
View File

@ -0,0 +1,343 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
const smtp = @import("smtp");
allocator: std.mem.Allocator,
config: jetzig.mail.SMTPConfig,
params: jetzig.mail.MailParams,
boundary: u32,
const Mail = @This();
pub fn init(
allocator: std.mem.Allocator,
params: jetzig.mail.MailParams,
) Mail {
return .{
.allocator = allocator,
.config = jetzig.config.get(jetzig.mail.SMTPConfig, "smtp"),
.params = params,
.boundary = std.crypto.random.int(u32),
};
}
pub fn deliver(self: Mail) !void {
const data = try self.generateData();
defer self.allocator.free(data);
try smtp.send(.{
.from = self.params.from.?,
.to = self.params.to.?,
.data = data,
}, self.config.toSMTP(self.allocator));
}
pub fn generateData(self: Mail) ![]const u8 {
try self.validate();
var arena = std.heap.ArenaAllocator.init(self.allocator);
defer arena.deinit();
const allocator = arena.allocator();
var sections = std.ArrayList([]const u8).init(allocator);
try sections.append(try std.fmt.allocPrint(allocator, "From: {s}", .{self.params.get(.from).?}));
try sections.append(try std.fmt.allocPrint(allocator, "Subject: {s}", .{self.params.get(.subject).?}));
if (self.params.get(.cc)) |cc| {
for (cc) |recipient| {
try sections.append(try std.fmt.allocPrint(allocator, "Cc: {s}", .{recipient}));
}
}
const body = try std.mem.concat(allocator, u8, &.{
try self.header(allocator),
try self.textPart(allocator),
if (self.params.get(.html) != null and self.params.get(.text) != null) "\r\n" else "",
try self.htmlPart(allocator),
jetzig.mail.components.footer,
});
try sections.append(body);
return std.mem.join(self.allocator, "\r\n", sections.items);
}
fn validate(self: Mail) !void {
if (self.params.get(.from) == null) return error.JetzigMailMissingFromAddress;
if (self.params.get(.to) == null) return error.JetzigMailMissingFromAddress;
if (self.params.get(.subject) == null) return error.JetzigMailMissingSubject;
}
fn header(self: Mail, allocator: std.mem.Allocator) ![]const u8 {
return try std.fmt.allocPrint(
allocator,
jetzig.mail.components.header,
.{self.boundary},
);
}
fn footer(self: Mail, allocator: std.mem.Allocator) ![]const u8 {
return try std.fmt.allocPrint(
allocator,
jetzig.mail.components.footer,
.{self.boundary},
);
}
fn textPart(self: Mail, allocator: std.mem.Allocator) ![]const u8 {
if (self.params.get(.text)) |content| {
return try std.fmt.allocPrint(
allocator,
jetzig.mail.components.text,
.{ self.boundary, try encode(allocator, content) },
);
} else return "";
}
fn htmlPart(self: Mail, allocator: std.mem.Allocator) ![]const u8 {
if (self.params.get(.html)) |content| {
return try std.fmt.allocPrint(
allocator,
jetzig.mail.components.html,
.{ self.boundary, try encode(allocator, content) },
);
} else return "";
}
fn encode(allocator: std.mem.Allocator, content: []const u8) ![]const u8 {
var buf = std.ArrayList(u8).init(allocator);
const writer = buf.writer();
var line_len: u8 = 0;
for (content) |char| {
const encoded = isEncoded(char);
const encoded_len: u2 = if (encoded) 3 else 1;
if (encoded_len + line_len >= 76) {
try writer.writeAll("=\r\n");
line_len = encoded_len;
} else {
line_len += encoded_len;
}
if (encoded) {
try writer.print("={X:0>2}", .{char});
} else {
try writer.writeByte(char);
}
}
return buf.toOwnedSlice();
}
inline fn isEncoded(char: u8) bool {
return char == '=' or !std.ascii.isPrint(char);
}
test "HTML part only" {
const mail = Mail{
.allocator = std.testing.allocator,
.config = .{},
.boundary = 123456789,
.params = .{
.from = "user@example.com",
.to = &.{"user@example.com"},
.subject = "Test subject",
.html = "<div>Hello</div>",
},
};
const actual = try generateData(mail);
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: user@example.com
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
\\--=_alternative_123456789
\\Content-Type: text/html; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\<div>Hello</div>
\\
\\.
\\
, "\n", "\r\n");
defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, actual);
}
test "text part only" {
const mail = Mail{
.allocator = std.testing.allocator,
.config = .{},
.boundary = 123456789,
.params = .{
.from = "user@example.com",
.to = &.{"user@example.com"},
.subject = "Test subject",
.text = "Hello",
},
};
const actual = try generateData(mail);
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: user@example.com
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
\\--=_alternative_123456789
\\Content-Type: text/plain; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\Hello
\\
\\.
\\
, "\n", "\r\n");
defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, actual);
}
test "HTML and text parts" {
const mail = Mail{
.allocator = std.testing.allocator,
.config = .{},
.boundary = 123456789,
.params = .{
.from = "user@example.com",
.to = &.{"user@example.com"},
.subject = "Test subject",
.html = "<div>Hello</div>",
.text = "Hello",
},
};
const actual = try generateData(mail);
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: user@example.com
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
\\--=_alternative_123456789
\\Content-Type: text/plain; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\Hello
\\
\\--=_alternative_123456789
\\Content-Type: text/html; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\<div>Hello</div>
\\
\\.
\\
, "\n", "\r\n");
defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, actual);
}
test "long content encoding" {
const mail = Mail{
.allocator = std.testing.allocator,
.config = .{},
.boundary = 123456789,
.params = .{
.from = "user@example.com",
.to = &.{"user@example.com"},
.subject = "Test subject",
.html = "<html><body><div style=\"background-color: black; color: #ff00ff;\">Hellooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo!!!</div></body></html>",
.text = "Hello",
},
};
const actual = try generateData(mail);
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: user@example.com
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
\\--=_alternative_123456789
\\Content-Type: text/plain; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\Hello
\\
\\--=_alternative_123456789
\\Content-Type: text/html; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\<html><body><div style=3D"background-color: black; color: #ff00ff;">Hellooo=
\\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo=
\\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo=
\\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo=
\\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo=
\\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo=
\\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo!!!</div></bod=
\\y></html>
\\
\\.
\\
, "\n", "\r\n");
defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, actual);
}
test "non-latin alphabet encoding" {
const mail = Mail{
.allocator = std.testing.allocator,
.config = .{},
.boundary = 123456789,
.params = .{
.from = "user@example.com",
.to = &.{"user@example.com"},
.subject = "Test subject",
.html = "<html><body><div>你爱学习 Zig 吗?</div></body></html>",
.text = "Hello",
},
};
const actual = try generateData(mail);
defer std.testing.allocator.free(actual);
const expected = try std.mem.replaceOwned(u8, std.testing.allocator,
\\From: user@example.com
\\Subject: Test subject
\\MIME-Version: 1.0
\\Content-Type: multipart/alternative; boundary="=_alternative_123456789"
\\--=_alternative_123456789
\\Content-Type: text/plain; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\Hello
\\
\\--=_alternative_123456789
\\Content-Type: text/html; charset="UTF-8"
\\Content-Transfer-Encoding: quoted-printable
\\
\\<html><body><div>=E4=BD=A0=E7=88=B1=E5=AD=A6=E4=B9=A0 Zig =E5=90=97=EF=BC=
\\=9F</div></body></html>
\\
\\.
\\
, "\n", "\r\n");
defer std.testing.allocator.free(expected);
try std.testing.expectEqualStrings(expected, actual);
}

View File

@ -0,0 +1,38 @@
subject: ?[]const u8 = null,
from: ?[]const u8 = null,
to: ?[]const []const u8 = null,
cc: ?[]const []const u8 = null,
bcc: ?[]const []const u8 = null, // TODO
html: ?[]const u8 = null,
text: ?[]const u8 = null,
defaults: ?DefaultMailParams = null,
pub const DefaultMailParams = struct {
subject: ?[]const u8 = null,
from: ?[]const u8 = null,
to: ?[]const []const u8 = null,
cc: ?[]const []const u8 = null,
bcc: ?[]const []const u8 = null, // TODO
html: ?[]const u8 = null,
text: ?[]const u8 = null,
};
const MailParams = @This();
pub fn get(
self: MailParams,
comptime field: enum { subject, from, to, cc, bcc, html, text },
) ?switch (field) {
.subject => []const u8,
.from => []const u8,
.to => []const []const u8,
.cc => []const []const u8,
.bcc => []const []const u8,
.html => []const u8,
.text => []const u8,
} {
return @field(self, @tagName(field)) orelse if (self.defaults) |defaults|
@field(defaults, @tagName(field))
else
null;
}

View File

@ -0,0 +1,15 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
pub const DeliverFn = *const fn (
std.mem.Allocator,
*jetzig.mail.MailParams,
*jetzig.data.Value,
jetzig.jobs.JobEnv,
) anyerror!void;
name: []const u8,
deliverFn: DeliverFn,
defaults: ?jetzig.mail.DefaultMailParams,
text_template: []const u8,
html_template: []const u8,

View File

@ -0,0 +1,31 @@
const std = @import("std");
const smtp = @import("smtp");
port: u16 = 25,
encryption: enum { none, insecure, tls, start_tls } = .none,
host: []const u8 = "localhost",
username: ?[]const u8 = null,
password: ?[]const u8 = null,
const SMTPConfig = @This();
pub fn toSMTP(self: SMTPConfig, allocator: std.mem.Allocator) smtp.Config {
return smtp.Config{
.allocator = allocator,
.port = self.port,
.encryption = self.getEncryption(),
.host = self.host,
.username = self.username,
.password = self.password,
};
}
fn getEncryption(self: SMTPConfig) smtp.Encryption {
return switch (self.encryption) {
.none => smtp.Encryption.none,
.insecure => smtp.Encryption.insecure,
.tls => smtp.Encryption.tls,
.start_tls => smtp.Encryption.start_tls,
};
}

View File

@ -0,0 +1,18 @@
pub const header =
"MIME-Version: 1.0\r\n" ++
"Content-Type: multipart/alternative; boundary=\"=_alternative_{0}\"\r\n";
pub const footer =
"\r\n.\r\n";
pub const text =
"--=_alternative_{0}\r\n" ++
"Content-Type: text/plain; charset=\"UTF-8\"\r\n" ++
"Content-Transfer-Encoding: quoted-printable\r\n\r\n" ++
"{1s}\r\n";
pub const html =
"--=_alternative_{0}\r\n" ++
"Content-Type: text/html; charset=\"UTF-8\"\r\n" ++
"Content-Transfer-Encoding: quoted-printable\r\n\r\n" ++
"{1s}\r\n";

View File

@ -2,11 +2,11 @@ const std = @import("std");
const jetzig = @import("../../jetzig.zig");
const Self = @This();
const Route = @This();
pub const Action = enum { index, get, post, put, patch, delete };
pub const RenderFn = *const fn (Self, *jetzig.http.Request) anyerror!jetzig.views.View;
pub const RenderStaticFn = *const fn (Self, *jetzig.http.StaticRequest) anyerror!jetzig.views.View;
pub const RenderFn = *const fn (Route, *jetzig.http.Request) anyerror!jetzig.views.View;
pub const RenderStaticFn = *const fn (Route, *jetzig.http.StaticRequest) anyerror!jetzig.views.View;
const ViewWithoutId = *const fn (*jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
const ViewWithId = *const fn (id: []const u8, *jetzig.http.Request, *jetzig.data.Data) anyerror!jetzig.views.View;
@ -52,7 +52,7 @@ params: std.ArrayList(*jetzig.data.Data) = undefined,
/// Initializes a route's static params on server launch. Converts static params (JSON strings)
/// to `jetzig.data.Data` values. Memory is owned by caller (`App.start()`).
pub fn initParams(self: *Self, allocator: std.mem.Allocator) !void {
pub fn initParams(self: *Route, allocator: std.mem.Allocator) !void {
self.params = std.ArrayList(*jetzig.data.Data).init(allocator);
for (self.json_params) |params| {
var data = try allocator.create(jetzig.data.Data);
@ -62,7 +62,7 @@ pub fn initParams(self: *Self, allocator: std.mem.Allocator) !void {
}
}
pub fn deinitParams(self: *const Self) void {
pub fn deinitParams(self: *const Route) void {
for (self.params.items) |data| {
data.deinit();
data._allocator.destroy(data);
@ -70,7 +70,7 @@ pub fn deinitParams(self: *const Self) void {
self.params.deinit();
}
fn renderFn(self: Self, request: *jetzig.http.Request) anyerror!jetzig.views.View {
fn renderFn(self: Route, request: *jetzig.http.Request) anyerror!jetzig.views.View {
switch (self.view.?) {
.dynamic => {},
// We only end up here if a static route is defined but its output is not found in the
@ -89,7 +89,7 @@ fn renderFn(self: Self, request: *jetzig.http.Request) anyerror!jetzig.views.Vie
}
}
fn renderStaticFn(self: Self, request: *jetzig.http.StaticRequest) anyerror!jetzig.views.View {
fn renderStaticFn(self: Route, request: *jetzig.http.StaticRequest) anyerror!jetzig.views.View {
request.response_data.* = jetzig.data.Data.init(request.allocator);
switch (self.view.?.static) {

View File

@ -4,4 +4,5 @@ test {
_ = @import("jetzig/http/Cookies.zig");
_ = @import("jetzig/http/Path.zig");
_ = @import("jetzig/jobs/Job.zig");
_ = @import("jetzig/mail/Mail.zig");
}