From 9e4a81aa19b9676d20e2a9433130cf9e129e3105 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Thu, 17 Oct 2024 22:06:29 +0100 Subject: [PATCH] Closes #105: Configure SMTP from environment variables Fall back to hardcoded values if each `JETZIG_SMTP_*` variable is not present. --- build.zig | 1 + demo/src/app/jobs/example.zig | 1 + demo/src/main.zig | 7 +++ src/jetzig.zig | 7 +-- src/jetzig/App.zig | 19 ++++---- src/jetzig/Environment.zig | 87 +++++++++++++++++++++++++++------- src/jetzig/http/Request.zig | 9 +++- src/jetzig/http/Server.zig | 34 +++++-------- src/jetzig/jobs/Job.zig | 2 + src/jetzig/mail/Job.zig | 2 +- src/jetzig/mail/Mail.zig | 39 ++++++++++++++- src/jetzig/mail/SMTPConfig.zig | 19 +++++--- src/jetzig/testing/App.zig | 8 +++- 13 files changed, 171 insertions(+), 64 deletions(-) diff --git a/build.zig b/build.zig index d204e56..2c06e3b 100644 --- a/build.zig +++ b/build.zig @@ -95,6 +95,7 @@ pub fn build(b: *std.Build) !void { main_tests.root_module.addImport("zmpl", zmpl_dep.module("zmpl")); main_tests.root_module.addImport("jetkv", jetkv_dep.module("jetkv")); main_tests.root_module.addImport("httpz", httpz_dep.module("httpz")); + main_tests.root_module.addImport("smtp", smtp_client_dep.module("smtp_client")); const run_main_tests = b.addRunArtifact(main_tests); const test_step = b.step("test", "Run library tests"); diff --git a/demo/src/app/jobs/example.zig b/demo/src/app/jobs/example.zig index 49ed4bf..1e9f818 100644 --- a/demo/src/app/jobs/example.zig +++ b/demo/src/app/jobs/example.zig @@ -9,6 +9,7 @@ pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig const mail = jetzig.mail.Mail.init( allocator, + env, .{ .subject = "Hello!!!", .from = "bob@jetzig.dev", diff --git a/demo/src/main.zig b/demo/src/main.zig index 0d83d30..06fb87d 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -116,6 +116,13 @@ pub const jetzig_options = struct { /// SMTP configuration for Jetzig Mail. It is recommended to use a local SMTP relay, /// e.g.: https://github.com/juanluisbaptiste/docker-postfix + /// + /// Each configuration option can be overridden with environment variables: + /// `JETZIG_SMTP_PORT` + /// `JETZIG_SMTP_ENCRYPTION` + /// `JETZIG_SMTP_HOST` + /// `JETZIG_SMTP_USERNAME` + /// `JETZIG_SMTP_PASSWORD` // pub const smtp: jetzig.mail.SMTPConfig = .{ // .port = 25, // .encryption = .none, // .insecure, .none, .tls, .start_tls diff --git a/src/jetzig.zig b/src/jetzig.zig index 8bcd644..779b3e2 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -217,13 +217,10 @@ pub const initHook: ?*const fn (*App) anyerror!void = if (@hasDecl(root, "init") /// Initialize a new Jetzig app. Call this from `src/main.zig` and then call /// `start(@import("routes").routes)` on the returned value. pub fn init(allocator: std.mem.Allocator) !App { - const args = try std.process.argsAlloc(allocator); - defer std.process.argsFree(allocator, args); - - const environment = Environment.init(allocator); + const env = try Environment.init(allocator); return .{ - .environment = environment, + .env = env, .allocator = allocator, .custom_routes = std.ArrayList(views.Route).init(allocator), .initHook = initHook, diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index 1db5696..3e9f36a 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -7,7 +7,7 @@ const mime_types = @import("mime_types").mime_types; // Generated at build time. const App = @This(); -environment: jetzig.Environment, +env: jetzig.Environment, allocator: std.mem.Allocator, custom_routes: std.ArrayList(jetzig.views.Route), initHook: ?*const fn (*App) anyerror!void, @@ -26,6 +26,8 @@ const AppOptions = struct {}; pub fn start(self: *const App, routes_module: type, options: AppOptions) !void { _ = options; // See `AppOptions` + defer self.env.deinit(); + if (self.initHook) |hook| try hook(@constCast(self)); var mime_map = jetzig.http.mime.MimeMap.init(self.allocator); @@ -61,18 +63,14 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void { ); defer cache.deinit(); - const server_options = try self.environment.getServerOptions(); - defer self.allocator.free(server_options.bind); - defer self.allocator.free(server_options.secret); - var log_thread = try std.Thread.spawn( .{ .allocator = self.allocator }, jetzig.loggers.LogQueue.Reader.publish, - .{ &server_options.log_queue.reader, .{} }, + .{ &self.env.log_queue.reader, .{} }, ); defer log_thread.join(); - if (server_options.detach) { + if (self.env.detach) { const argv = try std.process.argsAlloc(self.allocator); defer std.process.argsFree(self.allocator, argv); var child_argv = std.ArrayList([]const u8).init(self.allocator); @@ -89,7 +87,7 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void { var server = jetzig.http.Server.init( self.allocator, - server_options, + self.env, routes, self.custom_routes.items, &routes_module.jobs, @@ -106,7 +104,8 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void { &job_queue, .{ .logger = server.logger, - .environment = server.options.environment, + .vars = self.env.vars, + .environment = self.env.environment, .routes = routes, .jobs = &routes_module.jobs, .mailers = &routes_module.mailers, @@ -127,7 +126,7 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void { error.AddressInUse => { try server.logger.ERROR( "Socket unavailable: {s}:{} - unable to start server.\n", - .{ server_options.bind, server_options.port }, + .{ self.env.bind, self.env.port }, ); return; }, diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig index fb2fd63..34a239f 100644 --- a/src/jetzig/Environment.zig +++ b/src/jetzig/Environment.zig @@ -7,8 +7,54 @@ const jetzig = @import("../jetzig.zig"); const Environment = @This(); allocator: std.mem.Allocator, +logger: jetzig.loggers.Logger, +bind: []const u8, +port: u16, +secret: []const u8, +detach: bool, +environment: jetzig.Environment.EnvironmentName, +vars: jetzig.Environment.Vars, +log_queue: *jetzig.loggers.LogQueue, pub const EnvironmentName = enum { development, production, testing }; +pub const Vars = struct { + env_map: std.process.EnvMap, + + pub fn get(self: Vars, key: []const u8) ?[]const u8 { + return self.env_map.get(key); + } + + pub fn getT(self: Vars, T: type, key: []const u8) !switch (@typeInfo(T)) { + .bool => T, + else => ?T, + } { + const value = self.env_map.get(key) orelse return if (@typeInfo(T) == .bool) + false + else + null; + + return switch (@typeInfo(T)) { + .int => try std.fmt.parseInt(T, value, 10), + .bool => if (std.mem.eql(u8, value, "1")) + true + else if (std.mem.eql(u8, value, "0")) + false + else + error.JetzigInvalidEnvironmentVariableBooleanValue, + .@"enum" => parseEnum(T, value), + else => @compileError("Unsupported environment value type: `" ++ @typeName(T) ++ "`"), + }; + } + + pub fn deinit(self: Vars) void { + var env_map = self.env_map; + env_map.deinit(); + } + + fn parseEnum(E: type, value: []const u8) ?E { + return std.meta.stringToEnum(E, value); + } +}; const Options = struct { help: bool = false, @@ -54,17 +100,12 @@ const Options = struct { }; }; -pub fn init(allocator: std.mem.Allocator) Environment { - return .{ .allocator = allocator }; -} - -/// Generate server initialization options using command line args with defaults. -pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { - const options = try args.parseForCurrentProcess(Options, self.allocator, .print); +pub fn init(allocator: std.mem.Allocator) !Environment { + const options = try args.parseForCurrentProcess(Options, allocator, .print); defer options.deinit(); - const log_queue = try self.allocator.create(jetzig.loggers.LogQueue); - log_queue.* = jetzig.loggers.LogQueue.init(self.allocator); + const log_queue = try allocator.create(jetzig.loggers.LogQueue); + log_queue.* = jetzig.loggers.LogQueue.init(allocator); try log_queue.setFiles( try getLogFile(.stdout, options.options), try getLogFile(.stderr, options.options), @@ -77,18 +118,19 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { } const environment = options.options.environment; + const vars = Vars{ .env_map = try std.process.getEnvMap(allocator) }; var logger = switch (options.options.@"log-format") { .development => jetzig.loggers.Logger{ .development_logger = jetzig.loggers.DevelopmentLogger.init( - self.allocator, + allocator, resolveLogLevel(options.options.@"log-level", environment), log_queue, ), }, .json => jetzig.loggers.Logger{ .json_logger = jetzig.loggers.JsonLogger.init( - self.allocator, + allocator, resolveLogLevel(options.options.@"log-level", environment), log_queue, ), @@ -101,7 +143,7 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { } const secret_len = jetzig.http.Session.Cipher.key_length; - const secret = (try self.getSecret(&logger, secret_len, environment))[0..secret_len]; + const secret = (try getSecret(allocator, &logger, secret_len, environment))[0..secret_len]; if (secret.len != secret_len) { try logger.ERROR("Expected secret length: {}, found: {}.", .{ secret_len, secret.len }); @@ -110,16 +152,24 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { } return .{ + .allocator = allocator, .logger = logger, .secret = secret, - .bind = try self.allocator.dupe(u8, options.options.bind), + .bind = try allocator.dupe(u8, options.options.bind), .port = options.options.port, .detach = options.options.detach, .environment = environment, + .vars = vars, .log_queue = log_queue, }; } +pub fn deinit(self: Environment) void { + self.vars.deinit(); + self.allocator.free(self.bind); + self.allocator.free(self.secret); +} + fn getLogFile(stream: enum { stdout, stderr }, options: Options) !std.fs.File { const path = switch (stream) { .stdout => options.log, @@ -139,10 +189,15 @@ fn getLogFile(stream: enum { stdout, stderr }, options: Options) !std.fs.File { return file; } -fn getSecret(self: Environment, logger: *jetzig.loggers.Logger, comptime len: u10, environment: EnvironmentName) ![]const u8 { +fn getSecret( + allocator: std.mem.Allocator, + logger: *jetzig.loggers.Logger, + comptime len: u10, + environment: EnvironmentName, +) ![]const u8 { const env_var = "JETZIG_SECRET"; - return std.process.getEnvVarOwned(self.allocator, env_var) catch |err| { + return std.process.getEnvVarOwned(allocator, env_var) catch |err| { switch (err) { error.EnvironmentVariableNotFound => { if (environment != .development) { @@ -151,7 +206,7 @@ fn getSecret(self: Environment, logger: *jetzig.loggers.Logger, comptime len: u1 std.process.exit(1); } - const secret = try jetzig.util.generateSecret(self.allocator, len); + const secret = try jetzig.util.generateSecret(allocator, len); try logger.WARN( "Running in development mode, using auto-generated cookie encryption key: {s}", .{secret}, diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 6ee9c21..be7bfa9 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -393,7 +393,11 @@ pub fn session(self: *Request) !*jetzig.http.Session { if (self._session) |capture| return capture; const local_session = try self.allocator.create(jetzig.http.Session); - local_session.* = jetzig.http.Session.init(self.allocator, try self.cookies(), self.server.options.secret); + local_session.* = jetzig.http.Session.init( + self.allocator, + try self.cookies(), + self.server.env.secret, + ); local_session.parse() catch |err| { switch (err) { error.JetzigInvalidSessionCookie => { @@ -477,7 +481,8 @@ const RequestMail = struct { self.request.allocator, mail_job.params, jetzig.jobs.JobEnv{ - .environment = self.request.server.options.environment, + .vars = self.request.server.env.vars, + .environment = self.request.server.env.environment, .logger = self.request.server.logger, .routes = self.request.server.routes, .mailers = self.request.server.mailer_definitions, diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index 40a7875..98a7fbc 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -6,19 +6,9 @@ const zmpl = @import("zmpl"); const zmd = @import("zmd"); const httpz = @import("httpz"); -pub const ServerOptions = struct { - logger: jetzig.loggers.Logger, - bind: []const u8, - port: u16, - secret: []const u8, - detach: bool, - environment: jetzig.Environment.EnvironmentName, - log_queue: *jetzig.loggers.LogQueue, -}; - allocator: std.mem.Allocator, logger: jetzig.loggers.Logger, -options: ServerOptions, +env: jetzig.Environment, routes: []*jetzig.views.Route, custom_routes: []jetzig.views.Route, job_definitions: []const jetzig.JobDefinition, @@ -35,7 +25,7 @@ const Server = @This(); pub fn init( allocator: std.mem.Allocator, - options: ServerOptions, + env: jetzig.Environment, routes: []*jetzig.views.Route, custom_routes: []jetzig.views.Route, job_definitions: []const jetzig.JobDefinition, @@ -47,8 +37,8 @@ pub fn init( ) Server { return .{ .allocator = allocator, - .logger = options.logger, - .options = options, + .logger = env.logger, + .env = env, .routes = routes, .custom_routes = custom_routes, .job_definitions = job_definitions, @@ -62,8 +52,8 @@ pub fn init( pub fn deinit(self: *Server) void { if (self.initialized) self.std_net_server.deinit(); - self.allocator.free(self.options.secret); - self.allocator.free(self.options.bind); + self.allocator.free(self.env.secret); + self.allocator.free(self.env.bind); } const Dispatcher = struct { @@ -82,8 +72,8 @@ pub fn listen(self: *Server) !void { var httpz_server = try httpz.Server(Dispatcher).init( self.allocator, .{ - .port = self.options.port, - .address = self.options.bind, + .port = self.env.port, + .address = self.env.bind, .thread_pool = .{ .count = jetzig.config.get(?u16, "thread_count") orelse @intCast(try std.Thread.getCpuCount()), .buffer_size = jetzig.config.get(usize, "buffer_size"), @@ -102,9 +92,9 @@ pub fn listen(self: *Server) !void { defer httpz_server.deinit(); try self.logger.INFO("Listening on http://{s}:{} [{s}]", .{ - self.options.bind, - self.options.port, - @tagName(self.options.environment), + self.env.bind, + self.env.port, + @tagName(self.env.environment), }); self.initialized = true; @@ -262,7 +252,7 @@ fn renderJSON( if (data.value) |_| {} else _ = try data.object(); - rendered.content = if (self.options.environment == .development) + rendered.content = if (self.env.environment == .development) try data.toJsonOptions(.{ .pretty = true, .color = false }) else try data.toJson(); diff --git a/src/jetzig/jobs/Job.zig b/src/jetzig/jobs/Job.zig index 1c0e80a..711e154 100644 --- a/src/jetzig/jobs/Job.zig +++ b/src/jetzig/jobs/Job.zig @@ -13,6 +13,8 @@ pub const JobEnv = struct { logger: jetzig.loggers.Logger, /// The current server environment, `enum { development, production }` environment: jetzig.Environment.EnvironmentName, + /// Environment configured at server launch + vars: jetzig.Environment.Vars, /// All routes detected by Jetzig on startup routes: []*const jetzig.Route, /// All mailers detected by Jetzig on startup diff --git a/src/jetzig/mail/Job.zig b/src/jetzig/mail/Job.zig index 88c03fa..f9580e6 100644 --- a/src/jetzig/mail/Job.zig +++ b/src/jetzig/mail/Job.zig @@ -38,7 +38,7 @@ pub fn run(allocator: std.mem.Allocator, params: *jetzig.data.Value, env: jetzig try mailer.deliverFn(allocator, &mail_params, &data, params.get("params").?, env); - const mail = jetzig.mail.Mail.init(allocator, .{ + const mail = jetzig.mail.Mail.init(allocator, env, .{ .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, diff --git a/src/jetzig/mail/Mail.zig b/src/jetzig/mail/Mail.zig index 4cdde7c..da5efca 100644 --- a/src/jetzig/mail/Mail.zig +++ b/src/jetzig/mail/Mail.zig @@ -8,16 +8,19 @@ allocator: std.mem.Allocator, config: jetzig.mail.SMTPConfig, params: jetzig.mail.MailParams, boundary: u32, +env: jetzig.jobs.JobEnv, const Mail = @This(); pub fn init( allocator: std.mem.Allocator, + env: jetzig.jobs.JobEnv, params: jetzig.mail.MailParams, ) Mail { return .{ .allocator = allocator, .config = jetzig.config.get(jetzig.mail.SMTPConfig, "smtp"), + .env = env, .params = params, .boundary = std.crypto.random.int(u32), }; @@ -31,7 +34,7 @@ pub fn deliver(self: Mail) !void { .from = self.params.from.?, .to = self.params.to.?, .data = data, - }, self.config.toSMTP(self.allocator)); + }, try self.config.toSMTP(self.allocator, self.env)); } pub fn generateData(self: Mail) ![]const u8 { @@ -140,6 +143,7 @@ inline fn isEncoded(char: u8) bool { test "HTML part only" { const mail = Mail{ .allocator = std.testing.allocator, + .env = undefined, .config = .{}, .boundary = 123456789, .params = .{ @@ -175,6 +179,7 @@ test "HTML part only" { test "text part only" { const mail = Mail{ .allocator = std.testing.allocator, + .env = undefined, .config = .{}, .boundary = 123456789, .params = .{ @@ -210,6 +215,7 @@ test "text part only" { test "HTML and text parts" { const mail = Mail{ .allocator = std.testing.allocator, + .env = undefined, .config = .{}, .boundary = 123456789, .params = .{ @@ -252,6 +258,7 @@ test "HTML and text parts" { test "long content encoding" { const mail = Mail{ .allocator = std.testing.allocator, + .env = undefined, .config = .{}, .boundary = 123456789, .params = .{ @@ -301,6 +308,7 @@ test "long content encoding" { test "non-latin alphabet encoding" { const mail = Mail{ .allocator = std.testing.allocator, + .env = undefined, .config = .{}, .boundary = 123456789, .params = .{ @@ -341,3 +349,32 @@ test "non-latin alphabet encoding" { try std.testing.expectEqualStrings(expected, actual); } + +test "environment SMTP config" { + var env: jetzig.jobs.JobEnv = undefined; + var env_map = std.process.EnvMap.init(std.testing.allocator); + defer env_map.deinit(); + + try env_map.put("JETZIG_SMTP_PORT", "999"); + try env_map.put("JETZIG_SMTP_ENCRYPTION", "start_tls"); + try env_map.put("JETZIG_SMTP_HOST", "smtp.example.com"); + try env_map.put("JETZIG_SMTP_USERNAME", "example-username"); + try env_map.put("JETZIG_SMTP_PASSWORD", "example-password"); + + env.vars = jetzig.Environment.Vars{ .env_map = env_map }; + + const mail = Mail{ + .allocator = undefined, + .env = undefined, + .config = .{}, + .boundary = undefined, + .params = undefined, + }; + + const config = try mail.config.toSMTP(std.testing.allocator, env); + try std.testing.expect(config.port == 999); + try std.testing.expect(config.encryption == .start_tls); + try std.testing.expectEqualStrings("smtp.example.com", config.host); + try std.testing.expectEqualStrings("example-username", config.username.?); + try std.testing.expectEqualStrings("example-password", config.password.?); +} diff --git a/src/jetzig/mail/SMTPConfig.zig b/src/jetzig/mail/SMTPConfig.zig index 44fb502..4dd96cf 100644 --- a/src/jetzig/mail/SMTPConfig.zig +++ b/src/jetzig/mail/SMTPConfig.zig @@ -2,6 +2,8 @@ const std = @import("std"); const smtp = @import("smtp"); +const jetzig = @import("../../jetzig.zig"); + port: u16 = 25, encryption: enum { none, insecure, tls, start_tls } = .none, host: []const u8 = "localhost", @@ -10,14 +12,19 @@ password: ?[]const u8 = null, const SMTPConfig = @This(); -pub fn toSMTP(self: SMTPConfig, allocator: std.mem.Allocator) smtp.Config { +pub fn toSMTP( + self: SMTPConfig, + allocator: std.mem.Allocator, + env: jetzig.jobs.JobEnv, +) !smtp.Config { return smtp.Config{ .allocator = allocator, - .port = self.port, - .encryption = self.getEncryption(), - .host = self.host, - .username = self.username, - .password = self.password, + .port = try env.vars.getT(u16, "JETZIG_SMTP_PORT") orelse self.port, + .encryption = try env.vars.getT(smtp.Encryption, "JETZIG_SMTP_ENCRYPTION") orelse + self.getEncryption(), + .host = env.vars.get("JETZIG_SMTP_HOST") orelse self.host, + .username = env.vars.get("JETZIG_SMTP_USERNAME") orelse self.username, + .password = env.vars.get("JETZIG_SMTP_PASSWORD") orelse self.password, }; } diff --git a/src/jetzig/testing/App.zig b/src/jetzig/testing/App.zig index e329c30..b27271d 100644 --- a/src/jetzig/testing/App.zig +++ b/src/jetzig/testing/App.zig @@ -87,10 +87,16 @@ pub fn request( const logger = jetzig.loggers.Logger{ .test_logger = jetzig.loggers.TestLogger{} }; var log_queue = jetzig.loggers.LogQueue.init(allocator); + // We init the `std.process.EnvMap` directly here (instead of calling `std.process.getEnvMap` + // to ensure that tests run in a clean environment. Users can manually add items to the + // environment within a test if required. + const vars = jetzig.Environment.Vars{ .env_map = std.process.EnvMap.init(allocator) }; var server = jetzig.http.Server{ .allocator = allocator, .logger = logger, - .options = .{ + .env = .{ + .allocator = allocator, + .vars = vars, .logger = logger, .bind = undefined, .port = undefined,