Closes #105: Configure SMTP from environment variables

Fall back to hardcoded values if each `JETZIG_SMTP_*` variable is not
present.
This commit is contained in:
Bob Farrell 2024-10-17 22:06:29 +01:00
parent bf6c595d64
commit 9e4a81aa19
13 changed files with 171 additions and 64 deletions

View File

@ -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");

View File

@ -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",

View File

@ -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

View File

@ -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,

View File

@ -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;
},

View File

@ -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},

View File

@ -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,

View File

@ -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();

View File

@ -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

View File

@ -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,

View File

@ -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.?);
}

View File

@ -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,
};
}

View File

@ -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,