local mod = {} local kumo = require 'kumo' local utils = require 'policy-extras.policy_utils' local typing = require 'policy-extras.typing' --[[ Usage: Create a `/opt/kumomta/etc/queue.toml` with contents like: ```toml # Allow optional scheduled sends based on this header # https://docs.kumomta.com/reference/message/import_scheduling_header scheduling_header = "X-Schedule" # Set the tenant from this header tenant_header = "X-Tenant" remove_tenant_header = true # Set the campaign from this header campaign_header = "X-Campaign" remove_campaign_header = true # The tenant to use if no tenant_header is present default_tenant = "default-tenant" [tenant.'default-tenant'] egress_pool = 'pool-0' [tenant.'mytenant'] # Which pool should be used for this tenant egress_pool = 'pool-1' # Override age based on tenant; this overrides # settings at the domain level max_age = '10 hours' # Only the authorized identities are allowed to use this tenant # via the tenant_header require_authz = ["daniel"] # The default set of parameters [queue.default] max_age = '24 hours' # Base settings for a given destination domain. # These are overridden by more specific settings # in a tenant or more specific queue [queue.'gmail.com'] max_age = '22 hours' retry_interval = '17 mins' [queue.'gmail.com'.'mytenant'] # options here for domain=gmail.com AND tenant=mytenant for any unmatched campaign [queue.'gmail.com'.'mytenant'.'welcome-campaign'] # options here for domain=gmail.com, tenant=mytenant, and campaign='welcome-campaign' ``` --- local queue_module = require 'policy-extras.queue' local queue_helper = queue_module:setup({'/opt/kumomta/etc/queue.toml'}) kumo.on('smtp_server_message_received', function(msg) queue_helper:apply(msg) -- do your dkim signing here end) --- ]] -- Return true if the `name` and `value` pair provided -- is a valid queue configuration option. -- It does this by asking the internal rust code to validate it. local function is_queue_config_option(name, value) local p = { [name] = value } local status, err = pcall(kumo.make_queue_config, p) if not status then local err = typing.extract_deserialize_error(err) if tostring(err):find 'invalid type' then return false, err end return false, nil end return status end mod.is_queue_config_option = is_queue_config_option local Bool, List, Map, Option, Record, String, Variant = typing.boolean, typing.list, typing.map, typing.option, typing.record, typing.string, typing.variant local CampaignConfig = Record('CampaignConfig', { _dynamic = is_queue_config_option, overall_max_message_rate = Option(String), routing_domain = Option(String), }) local TenantWithCampaignConfig = Record('TenantWithCampaignConfig', { _dynamic = is_queue_config_option, overall_max_message_rate = Option(String), routing_domain = Option(String), campaigns = Option(Map(String, CampaignConfig)), }) local DomainConfig = Record('DomainConfig', { _dynamic = is_queue_config_option, overall_max_message_rate = Option(String), routing_domain = Option(String), tenants = Option(Map(String, TenantWithCampaignConfig)), }) local TenantConfig = Record('TenantConfig', { campaigns = Option(Map(String, CampaignConfig)), _dynamic = is_queue_config_option, require_authz = Option(List(String)), overall_max_message_rate = Option(String), }) local QueueHelperConfig = Record('QueueHelperConfig', { scheduling_header = Option(String), tenant_header = Option(String), remove_tenant_header = Option(Bool), campaign_header = Option(String), remove_campaign_header = Option(Bool), default_tenant = Option(String), tenants = Option(Map(String, TenantConfig)), queues = Option(Map(String, DomainConfig)), }) local QueueHelperSetup = Record('QueueHelperSetup', { file_names = List(Variant(String, QueueHelperConfig)), skip_queue_config_hook = Option(Bool), -- This passed through to the queue_helper_data memoize cache setup invalidate_with_epoch = Option(Bool), }) local function parse_tenant_with_campaign(data) local tenant = TenantWithCampaignConfig { campaigns = {}, } -- print('parse_tenant_with_campaign', kumo.serde.json_encode_pretty(data)) for k, v in pairs(data) do -- Since we allow an arbitrary key to map to a campaign, and a mixture -- of known-to-lua and known-to-rust options, short of replicating -- the logic in the typing module here, the best option is just to -- try to assign it, and if it raises an error, then it must be -- a campaign value. local status, result = pcall(function() tenant[k] = v end) if not status then tenant.campaigns[k] = v end end return tenant end local function parse_domain(data) local domain = DomainConfig { tenants = {}, } -- print('parse_domain', kumo.serde.json_encode_pretty(data)) for k, v in pairs(data) do -- Since we allow an arbitrary key to map to a tenant, and a mixture -- of known-to-lua and known-to-rust options, short of replicating -- the logic in the typing module here, the best option is just to -- try to assign it, and if it raises an error, then it must be -- a tenant value. local status, result = pcall(function() domain[k] = v end) if not status then domain.tenants[k] = parse_tenant_with_campaign(v) end end return domain end local function parse_config(data) local result = QueueHelperConfig { tenants = {}, queues = {}, } for k, v in pairs(data) do if k == 'tenant' then for tenant_name, tenant_options in pairs(v) do local tenant = result.tenants[tenant_name] or {} utils.merge_into(tenant_options, tenant) result.tenants[tenant_name] = tenant end elseif k == 'queue' then for domain_name, queue_options in pairs(v) do local domain_options = result.queues[domain_name] or {} utils.recursive_merge_into( parse_domain(queue_options), domain_options ) result.queues[domain_name] = domain_options end else result[k] = v end end return result end local function merge_data(loaded_files, no_compile) local result = QueueHelperConfig {} for _, data in ipairs(loaded_files) do utils.recursive_merge_into(parse_config(data), result) end -- print(kumo.json_encode_pretty(result)) if not no_compile then result.queues = kumo.domain_map.new(result.queues) end return result end local function load_queue_config(file_names, no_compile) local data = {} for _, file_name in ipairs(file_names) do table.insert(data, utils.load_json_or_toml_file(file_name)) end return merge_data(data, no_compile) end -- Resolve the merged value of the config that matches the provided -- domain, tenant and campaign. -- If allow_all is true, all fields are merged and returned. -- Otherwise (the default), only valid make_queue_config option values -- are returned. local function resolve_config(data, domain, tenant, campaign, allow_all) -- print('resolve_config', domain, tenant, campaign) local params = { refresh_strategy = 'Epoch', } local default_config = data.queues.default if default_config then for k, v in pairs(default_config) do if allow_all or is_queue_config_option(k, v) then params[k] = v end end end local domain_config = data.queues[domain] if domain_config then for k, v in pairs(domain_config) do if allow_all or is_queue_config_option(k, v) then params[k] = v end end end local tenant_definition = data.tenants[tenant] if tenant_definition then for k, v in pairs(tenant_definition) do if allow_all or is_queue_config_option(k, v) then params[k] = v end end end if domain_config then local tenant_config = domain_config.tenants[tenant] if tenant_config then for k, v in pairs(tenant_config) do if allow_all or is_queue_config_option(k, v) then params[k] = v end end local campaign = tenant_config.campaigns[campaign] if campaign then for k, v in pairs(campaign) do if allow_all or is_queue_config_option(k, v) then params[k] = v end end end end end if utils.table_is_empty(params) then return nil end -- print(kumo.json_encode_pretty(params)) return params end local function resolve_overall_throttle_specs( data, tenant_name, campaign_name ) local results = {} local tenant = data.tenants[tenant_name] if tenant then local rate = tenant.overall_max_message_rate if rate then results.tenant = rate end if tenant.campaigns then local campaign = tenant.campaigns[campaign_name] if campaign then results.campaign = campaign.overall_max_message_rate end end end return results end local function apply_impl(msg, data) if data.scheduling_header then msg:import_scheduling_header(data.scheduling_header, true) end if data.campaign_header then local campaign = msg:get_first_named_header_value(data.campaign_header) if campaign then msg:set_meta('campaign', campaign) if data.remove_campaign_header then msg:remove_all_named_headers(data.campaign_header) end end end local tenant = nil local tenant_source = nil if data.tenant_header then tenant = msg:get_first_named_header_value(data.tenant_header) if tenant then tenant_source = string.format("'%s' header", data.tenant_header) if data.remove_tenant_header then msg:remove_all_named_headers(data.tenant_header) end end end if not tenant and data.default_tenant then tenant = data.default_tenant tenant_source = 'default_tenant option' end if tenant then local tenant_config = data.tenants[tenant] if not tenant_config then kumo.reject( 500, string.format( "tenant '%s' specified by %s is not defined in your queue config", tenant, tenant_source ) ) end if tenant_config.require_authz then local my_auth = msg:get_meta 'authz_id' if not my_auth then kumo.reject( 500, string.format("tenant '%s' requires SMTP AUTH", tenant) ) end if not utils.table_contains(tenant_config.require_authz, my_auth) then kumo.reject( 500, string.format( "authz_id '%s' is not authorized to send as tenant '%s'", my_auth, tenant ) ) end end msg:set_meta('tenant', tenant) end local recip = msg:recipient_list()[1] if recip then local composed = resolve_config( data, recip.domain, msg:get_meta 'tenant', msg:get_meta 'campaign', true -- allow non-queue-config options ) if composed then local routing_domain = composed.routing_domain if routing_domain then msg:set_meta('routing_domain', routing_domain) end end end end function mod:setup(file_names) return self:setup_with_options { skip_queue_config_hook = false, file_names = file_names, } end local function resolve_provider(domain, routing_domain) local is_ok, result = pcall(function() local routing_domain = routing_domain or domain local mx = kumo.dns.lookup_mx(routing_domain) local path_config = kumo.invoke_get_egress_path_config( routing_domain, -- We use unspecified for the source. We don't and cannot -- know the actual source at this point, however, the -- provider is a function of destination so it doesn't -- actually matter which source we request here, so -- long as the defined event handlers populate the -- provider_name for us 'unspecified', mx.site_name ) return path_config.provider_name end) if is_ok then return result end return nil end local cached_resolve_provider = kumo.memoize(resolve_provider, { name = 'queue.lua_resolve_provider', ttl = '1 minute', invalidate_with_epoch = true, capacity = 128, }) -- Given a domain and optional routing_domain (set to nil if you have -- no explicit routing domain), determine the provider_name that would -- be set by the shaping layer by speculatively evaluating the egress -- path configuration for an unspecified source. function mod:resolve_provider(domain, routing_domain) return cached_resolve_provider(domain, routing_domain) end function mod:setup_with_options(options) options = QueueHelperSetup(options) if mod.CONFIGURED then error 'queues module has already been configured' end local cached_load_data = kumo.memoize(load_queue_config, { name = 'queue_helper_data', ttl = '1 minute', capacity = 10, invalidate_with_epoch = options.invalidate_with_epoch, }) local helper = { file_names = options.file_names, } function helper:resolve_config(domain, tenant, campaign) local data = cached_load_data(options.file_names) local params = resolve_config(data, domain, tenant, campaign) return params end mod.CONFIGURED = { options = options, get_data = function() return load_queue_config(options.file_names, true) end, resolve_config = helper.resolve_config, } if not options.skip_queue_config_hook then kumo.on( 'get_queue_config', function(domain, tenant, campaign, routing_domain) local data = cached_load_data(options.file_names) local params = resolve_config(data, domain, tenant, campaign) if params then if params.provider_name == nil then -- Back-propagate the provider_name from the probable -- egress path configuration for this routing domain. params.provider_name = cached_resolve_provider(domain, routing_domain) end return kumo.make_queue_config(params) end end ) end -- Apply any overall_max_message_rate option that may have been -- specified for the tenant or its campaign(s) kumo.on('throttle_insert_ready_queue', function(msg) local tenant_name = msg:get_meta 'tenant' local campaign_name = msg:get_meta 'campaign' if tenant_name or campaign_name then local data = cached_load_data(options.file_names) local throttles = resolve_overall_throttle_specs(data, tenant_name, campaign_name) if throttles.tenant then local throttle = kumo.make_throttle( string.format( 'queue-helper-tenant-overall_max_message_rate-%s', tenant_name ), throttles.tenant ) if throttle:delay_message_if_throttled(msg) then return end end if throttles.campaign then local throttle = kumo.make_throttle( string.format( 'queue-helper-tenant-campaign-overall_max_message_rate-%s-%s', tenant_name, campaign_name ), throttles.tenant ) if throttle:delay_message_if_throttled(msg) then return end end end end) function helper:apply(msg) local data = cached_load_data(options.file_names) apply_impl(msg, data) end return helper end kumo.on('validate_config', function() if not mod.CONFIGURED then return end local data = mod.CONFIGURED.get_data() -- print(kumo.json_encode_pretty(data)) local failed = false function show_context() if failed then return end failed = true kumo.validation_failed() print 'Issues found in the combined set of queue files:' for _, file_name in ipairs(mod.CONFIGURED.options.file_names) do if type(file_name) == 'table' then print ' - (inline table)' else print(string.format(' - %s', file_name)) end end end local monitored = {} for _, path in ipairs(kumo.eval_config_monitor_globs()) do monitored[path] = path end for _, file_name in ipairs(mod.CONFIGURED.options.file_names) do if type(file_name) == 'string' then if not monitored[file_name] then kumo.validation_failed() print( string.format( 'queue.lua: %s is listed as a data file but it is not covered by ' .. 'the globs set by kumo.set_config_monitor_globs(). Changes will not ' .. 'be detected at runtime unless the globs are expanded to cover ' .. 'your full set of data files.', file_name ) ) end end end local sources = require 'policy-extras.sources' if sources.CONFIGURED then local source_data = sources.CONFIGURED.get_data() for tenant, tenant_data in pairs(data.tenants) do if tenant_data.egress_pool then if not source_data.pools[tenant_data.egress_pool] then show_context() print( string.format( "tenant '%s' uses pool '%s' which is not present in your sources helper data", tenant, tenant_data.egress_pool ) ) end end end for domain, domain_data in pairs(data.queues) do if domain_data.egress_pool then if not source_data.pools[domain_data.egress_pool] then show_context() print( string.format( "domain '%s' uses pool '%s' which is not present in your sources helper data", domain, domain_data.egress_pool ) ) end end end end local function stack_contains_src(stack, src) for _, frame in ipairs(stack) do if utils.starts_with(frame, src) then return true end end return false end local reg = kumo.get_event_registrars 'get_queue_config' local my_source = kumo.traceback(1)[1].short_src .. ':' local my_reg = nil for idx, stack in ipairs(reg) do if stack_contains_src(stack, my_source) then my_reg = idx break end end if my_reg ~= #reg then -- Don't use show_context() here, as this error is independent of -- the contents of the queue data files and instead about interactions -- between modules kumo.validation_failed() print [[ queue.lua is in use, but it is not the last module to register for the get_queue_config event. This can cause issues with routing/scheduling, especially if you have a [queue.default] block defined in your queue data. Here are the locations where each of the get_queue_config events are registered: ]] for idx, stack in ipairs(reg) do print(string.format('%d:', idx)) for _, frame in ipairs(stack) do print(string.format(' %s', frame)) end end print [[ You should adjust the initialization order so that queue.lua is last. ]] end end) function mod:test() local base_data = [=[ default_tenant = 'mytenant' [queue.default] max_age = '24 hours' shrink_policy = [{interval="0s", policy="ShrinkData"}] [queue.'my.own.hostname'] routing_domain = '[10.0.0.1]' [tenant.'mytenant'] egress_pool = 'tpool' overall_max_message_rate = "100/s" [tenant.'mytenant'.campaigns.'mycampaign'] overall_max_message_rate = "50/s" [queue.'gmail.com'.'mytenant'] egress_pool = "foo" ]=] local user_data = [=[ [queue.'gmail.com'] max_age = '6 hours' [queue.'gmail.com'.'mytenant'.'campaign'] egress_pool = "bar" ]=] local data = merge_data { kumo.toml_parse(base_data), kumo.toml_parse(user_data), } utils.assert_eq( resolve_config(data, 'foo.com', nil, nil, nil).max_age, '24 hours' ) utils.assert_eq( resolve_config(data, 'gmail.com', nil, nil, nil).max_age, '6 hours' ) utils.assert_eq( resolve_config(data, 'gmail.com', nil, nil, nil).egress_pool, nil ) utils.assert_eq( resolve_config(data, 'example.com', 'mytenant', nil, nil).egress_pool, 'tpool' ) utils.assert_eq( resolve_config(data, 'gmail.com', 'mytenant', nil, nil).egress_pool, 'foo' ) utils.assert_eq( resolve_config(data, 'gmail.com', 'mytenant', 'campaign', nil).egress_pool, 'bar' ) utils.assert_eq(resolve_overall_throttle_specs(data, nil, nil), {}) utils.assert_eq( resolve_overall_throttle_specs(data, 'some-tenant', nil), {} ) utils.assert_eq( resolve_overall_throttle_specs(data, 'mytenant', nil), { tenant = '100/s' } ) utils.assert_eq( resolve_overall_throttle_specs(data, 'mytenant', 'mycampaign'), { tenant = '100/s', campaign = '50/s' } ) local function new_msg(recip) return kumo.make_message( 'sender@example.com', recip, 'Subject: hello\r\n\r\nHi' ) end local msg = new_msg 'recip@example.com' apply_impl(msg, data) utils.assert_eq(msg:get_meta 'tenant', 'mytenant') local msg = new_msg 'recip@example.com' msg:set_recipient { 'a@example.com', 'b@example.com' } apply_impl(msg, data) utils.assert_eq(msg:get_meta 'tenant', 'mytenant') local msg = new_msg 'recip@my.own.hostname' apply_impl(msg, data) utils.assert_eq(msg:get_meta 'tenant', 'mytenant') utils.assert_eq(msg:get_meta 'routing_domain', '[10.0.0.1]') end return mod