From 47c35632b5faeb66073708a380610f9d2e123e5e Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Mon, 15 Apr 2024 22:28:27 +0100 Subject: [PATCH] Email framework Create mailers with `jetzig generate mailer `. 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. --- build.zig | 72 ++-- build.zig.zon | 8 +- cli/commands/generate.zig | 56 +-- cli/commands/generate/job.zig | 18 +- cli/commands/generate/mailer.zig | 146 ++++++++ demo/build.zig | 2 +- demo/src/app/jobs/example.zig | 23 +- demo/src/app/mailers/welcome.zig | 36 ++ demo/src/app/mailers/welcome/html.zmpl | 1 + demo/src/app/mailers/welcome/text.zmpl | 1 + demo/src/app/views/background_jobs.zig | 4 +- demo/src/app/views/mail.zig | 20 ++ demo/src/app/views/mail/index.zmpl | 3 + demo/src/app/views/mailers/welcome.html.zmpl | 1 + demo/src/app/views/mailers/welcome.text.zmpl | 1 + demo/src/app/views/welcome.html.zmpl | 1 + demo/src/main.zig | 13 + src/Routes.zig | 87 ++++- src/compile_static_routes.zig | 6 +- src/jetzig.zig | 16 + src/jetzig/App.zig | 10 +- src/jetzig/Environment.zig | 19 +- src/jetzig/http/Query.zig | 27 +- src/jetzig/http/Request.zig | 59 ++++ src/jetzig/http/Server.zig | 9 +- src/jetzig/jobs.zig | 2 + src/jetzig/jobs/Job.zig | 47 ++- src/jetzig/jobs/Pool.zig | 12 +- src/jetzig/jobs/Worker.zig | 15 +- src/jetzig/mail.zig | 9 + src/jetzig/mail/Job.zig | 159 +++++++++ src/jetzig/mail/Mail.zig | 343 +++++++++++++++++++ src/jetzig/mail/MailParams.zig | 38 ++ src/jetzig/mail/MailerDefinition.zig | 15 + src/jetzig/mail/SMTPConfig.zig | 31 ++ src/jetzig/mail/components.zig | 18 + src/jetzig/views/Route.zig | 14 +- src/tests.zig | 1 + 38 files changed, 1195 insertions(+), 148 deletions(-) create mode 100644 cli/commands/generate/mailer.zig create mode 100644 demo/src/app/mailers/welcome.zig create mode 100644 demo/src/app/mailers/welcome/html.zmpl create mode 100644 demo/src/app/mailers/welcome/text.zmpl create mode 100644 demo/src/app/views/mail.zig create mode 100644 demo/src/app/views/mail/index.zmpl create mode 100644 demo/src/app/views/mailers/welcome.html.zmpl create mode 100644 demo/src/app/views/mailers/welcome.text.zmpl create mode 100644 demo/src/app/views/welcome.html.zmpl create mode 100644 src/jetzig/mail.zig create mode 100644 src/jetzig/mail/Job.zig create mode 100644 src/jetzig/mail/Mail.zig create mode 100644 src/jetzig/mail/MailParams.zig create mode 100644 src/jetzig/mail/MailerDefinition.zig create mode 100644 src/jetzig/mail/SMTPConfig.zig create mode 100644 src/jetzig/mail/components.zig diff --git a/build.zig b/build.zig index 01adb5a..bee5b61 100644 --- a/build.zig +++ b/build.zig @@ -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); diff --git a/build.zig.zon b/build.zig.zon index 80e7829..4bcec26 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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 = .{ diff --git a/cli/commands/generate.zig b/cli/commands/generate.zig index 1b46861..059599d 100644 --- a/cli/commands/generate.zig +++ b/cli/commands/generate.zig @@ -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; } } diff --git a/cli/commands/generate/job.zig b/cli/commands/generate/job.zig index 0da0079..a58bcbe 100644 --- a/cli/commands/generate/job.zig +++ b/cli/commands/generate/job.zig @@ -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.", .{}); \\} \\ ); diff --git a/cli/commands/generate/mailer.zig b/cli/commands/generate/mailer.zig new file mode 100644 index 0000000..aa963aa --- /dev/null +++ b/cli/commands/generate/mailer.zig @@ -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("", .{ .to = &.{"user@example.com"} }); + \\// try mail.deliver(.background, .{}); + \\// ``` + \\// A mailer can provide two Zmpl templates for rendering email content: + \\// * `src/app/mailers//html.zmpl + \\// * `src/app/mailers//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( + \\
HTML content goes here
+ \\ + ); + 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}); + } +} diff --git a/demo/build.zig b/demo/build.zig index c6eaaec..8f2e0ae 100644 --- a/demo/build.zig +++ b/demo/build.zig @@ -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); diff --git a/demo/src/app/jobs/example.zig b/demo/src/app/jobs/example.zig index df06e1e..49ed4bf 100644 --- a/demo/src/app/jobs/example.zig +++ b/demo/src/app/jobs/example.zig @@ -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 = "
Hello!
", + .text = "Hello!", + }, + ); + + try mail.deliver(); } diff --git a/demo/src/app/mailers/welcome.zig b/demo/src/app/mailers/welcome.zig new file mode 100644 index 0000000..e4dc1d0 --- /dev/null +++ b/demo/src/app/mailers/welcome.zig @@ -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//html.zmpl +// * `src/app/mailers//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)}); +} diff --git a/demo/src/app/mailers/welcome/html.zmpl b/demo/src/app/mailers/welcome/html.zmpl new file mode 100644 index 0000000..dc4dd40 --- /dev/null +++ b/demo/src/app/mailers/welcome/html.zmpl @@ -0,0 +1 @@ +
{{.message}}
diff --git a/demo/src/app/mailers/welcome/text.zmpl b/demo/src/app/mailers/welcome/text.zmpl new file mode 100644 index 0000000..f9f2e47 --- /dev/null +++ b/demo/src/app/mailers/welcome/text.zmpl @@ -0,0 +1 @@ +{{.message}} diff --git a/demo/src/app/views/background_jobs.zig b/demo/src/app/views/background_jobs.zig index 763440f..9707dc3 100644 --- a/demo/src/app/views/background_jobs.zig +++ b/demo/src/app/views/background_jobs.zig @@ -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. diff --git a/demo/src/app/views/mail.zig b/demo/src/app/views/mail.zig new file mode 100644 index 0000000..24b93ce --- /dev/null +++ b/demo/src/app/views/mail.zig @@ -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); +} diff --git a/demo/src/app/views/mail/index.zmpl b/demo/src/app/views/mail/index.zmpl new file mode 100644 index 0000000..9483c0e --- /dev/null +++ b/demo/src/app/views/mail/index.zmpl @@ -0,0 +1,3 @@ +
+ Your email has been sent! +
diff --git a/demo/src/app/views/mailers/welcome.html.zmpl b/demo/src/app/views/mailers/welcome.html.zmpl new file mode 100644 index 0000000..0ca781b --- /dev/null +++ b/demo/src/app/views/mailers/welcome.html.zmpl @@ -0,0 +1 @@ +
{{.data.test}}
diff --git a/demo/src/app/views/mailers/welcome.text.zmpl b/demo/src/app/views/mailers/welcome.text.zmpl new file mode 100644 index 0000000..d0bbaba --- /dev/null +++ b/demo/src/app/views/mailers/welcome.text.zmpl @@ -0,0 +1 @@ +{{.data.test}} diff --git a/demo/src/app/views/welcome.html.zmpl b/demo/src/app/views/welcome.html.zmpl new file mode 100644 index 0000000..0fd9beb --- /dev/null +++ b/demo/src/app/views/welcome.html.zmpl @@ -0,0 +1 @@ +
Hello
diff --git a/demo/src/main.zig b/demo/src/main.zig index 9756a6e..943e849 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -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 { diff --git a/src/Routes.zig b/src/Routes.zig index 45c9f1f..3540911 100644 --- a/src/Routes.zig +++ b/src/Routes.zig @@ -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| { diff --git a/src/compile_static_routes.zig b/src/compile_static_routes.zig index 0327090..99e7c86 100644 --- a/src/compile_static_routes.zig +++ b/src/compile_static_routes.zig @@ -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}); diff --git a/src/jetzig.zig b/src/jetzig.zig index d90c5cc..893b58d 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -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(); diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index 1d9f098..3a9f449 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -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(); diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig index 85ac264..9f89f2b 100644 --- a/src/jetzig/Environment.zig +++ b/src/jetzig/Environment.zig @@ -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, + }; +} diff --git a/src/jetzig/http/Query.zig b/src/jetzig/http/Query.zig index 4959d6e..0e59da1 100644 --- a/src/jetzig/http/Query.zig +++ b/src/jetzig/http/Query.zig @@ -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")).?, + ); +} diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 84919c4..a8d5dee 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -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")) { diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index a2efd44..c0c979d 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -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}); diff --git a/src/jetzig/jobs.zig b/src/jetzig/jobs.zig index ead45f3..2df8087 100644 --- a/src/jetzig/jobs.zig +++ b/src/jetzig/jobs.zig @@ -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"); diff --git a/src/jetzig/jobs/Job.zig b/src/jetzig/jobs/Job.zig index 472f463..6bf8376 100644 --- a/src/jetzig/jobs/Job.zig +++ b/src/jetzig/jobs/Job.zig @@ -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 :) diff --git a/src/jetzig/jobs/Pool.zig b/src/jetzig/jobs/Pool.zig index 2fdd3ec..d40fd52 100644 --- a/src/jetzig/jobs/Pool.zig +++ b/src/jetzig/jobs/Pool.zig @@ -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); diff --git a/src/jetzig/jobs/Worker.zig b/src/jetzig/jobs/Worker.zig index 0b0f68c..5455ef1 100644 --- a/src/jetzig/jobs/Worker.zig +++ b/src/jetzig/jobs/Worker.zig @@ -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) }); diff --git a/src/jetzig/mail.zig b/src/jetzig/mail.zig new file mode 100644 index 0000000..5fd7c6c --- /dev/null +++ b/src/jetzig/mail.zig @@ -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"); diff --git a/src/jetzig/mail/Job.zig b/src/jetzig/mail/Job.zig new file mode 100644 index 0000000..820b26b --- /dev/null +++ b/src/jetzig/mail/Job.zig @@ -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; +} diff --git a/src/jetzig/mail/Mail.zig b/src/jetzig/mail/Mail.zig new file mode 100644 index 0000000..4cdde7c --- /dev/null +++ b/src/jetzig/mail/Mail.zig @@ -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 = "
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/html; 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 "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 = "
Hello
", + .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 + \\ + \\
Hello
+ \\ + \\. + \\ + , "\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 = "
Hellooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo!!!
", + .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 + \\ + \\
Hellooo= + \\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo= + \\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo= + \\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo= + \\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo= + \\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo= + \\ooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo!!!
+ \\ + \\. + \\ + , "\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 = "
你爱学习 Zig 吗?
", + + .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 + \\ + \\
=E4=BD=A0=E7=88=B1=E5=AD=A6=E4=B9=A0 Zig =E5=90=97=EF=BC= + \\=9F
+ \\ + \\. + \\ + , "\n", "\r\n"); + defer std.testing.allocator.free(expected); + + try std.testing.expectEqualStrings(expected, actual); +} diff --git a/src/jetzig/mail/MailParams.zig b/src/jetzig/mail/MailParams.zig new file mode 100644 index 0000000..deed9e9 --- /dev/null +++ b/src/jetzig/mail/MailParams.zig @@ -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; +} diff --git a/src/jetzig/mail/MailerDefinition.zig b/src/jetzig/mail/MailerDefinition.zig new file mode 100644 index 0000000..2331428 --- /dev/null +++ b/src/jetzig/mail/MailerDefinition.zig @@ -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, diff --git a/src/jetzig/mail/SMTPConfig.zig b/src/jetzig/mail/SMTPConfig.zig new file mode 100644 index 0000000..44fb502 --- /dev/null +++ b/src/jetzig/mail/SMTPConfig.zig @@ -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, + }; +} diff --git a/src/jetzig/mail/components.zig b/src/jetzig/mail/components.zig new file mode 100644 index 0000000..e1916de --- /dev/null +++ b/src/jetzig/mail/components.zig @@ -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"; diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index eed3e3e..38aceef 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -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) { diff --git a/src/tests.zig b/src/tests.zig index 67f293f..5ac3f98 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -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"); }