Configuration

Gateway

Example: gateway.yaml

global: executor/thread limits

rest_listener: host/port/certs for http

rest_output: http endpoint of router and host for output chain

smtp_output: rest host endpoints for the router to send to

smtp_listener: host/port/certs, specify which rest_output host to use

logging: logging.dictConfig

Router

Example: router.yaml

global: executor/thread limits

rest_listener: host/port/certs for http

sender: principals/roles for sending messages, cf below

endpoint: output chains, cf below

rest_endpoint: list of endpoints referenced by recipient router filter destination (cf below)

endpoint.name: referenced by recipient routing destination endpoint

endpoint.endpoint: url

endpoint.sender: must match endpoint url path

endpoint.tag: sender tag to send upstream

endpoint.options: cf recipient router filter (below)

modules: user plugins: output chain filters, recipient routing policies

storage: sqlalchemy db url

logging: logging.dictConfig

Senders

name: rest url path /senders/<name>/transactions

output_chain: how to handle messages for this sender

retry: output_chain to use the parameters from the output chain or null to disable

notification: cf retry

upstream_sender/upstream_tag: exploder rewrites sender/tag to this

tag: per-tag parameter overrides

Output Chains

name: referenced by senders

msa: true if this is terminating smtp msa port 587 from clients injecting messages for the first time, enables store&forward in more circumstances, further details in internals/exploder

output_handler:

retry_params:

max_attempts: maximum number of retries

min_attempt_time: minimum time from completion of previous attempt to start of next attempt, seconds

max_attempt_time: maximum time from completion of previous attempt to start of next attempt, seconds

backoff_factor: exponential backoff factor

deadline: maximum wall clock time to retry message

notification:

sender/tag to inject notifications/DSNs

chain: list of filters

The output chain is linear. recipient_router_filter routes on recipient by setting fields in the transaction to influence the http endpoint that rest_output sends to and if that is the smtp gateway, what destination the gateway sends to after that.

A typical ingress config would accept local domains and reject everything else. Whereas an egress config might short-circuit local domains back to ingress and then send everything else to the RHS of the address.

Output Chain Filters

remote_host

resolves tx.remote_host ip to name

RemoteHostFilterOutput.match() yaml

fcrdns: bool forward-confirmed reverse dns for remote_host

ehlo_alignment: bool ehlo aligns to rdns of remote_host

message_builder

renders/serializes tx.body message builder request json to rfc822 (for sending)

message_parser

parses rfc822 tx.body to message builder json (for receiving)

received_header

prepends Received: header to tx.body

dkim_sign

domain-keys identified mail message signing

filter yaml:

key: private key

domain: domain to sign as d=

selector: selector to sign as s=

dns_resolution

replaces tx.resolution containing a hostname with one containing a list of IP addresses for the gateway to attempt in order

router

RecipientRouterFilter populates tx.rest_upstream_sender which controls where RestEndpoint sends it and if that is the gateway, tx.resolution controls where the gateway sends it

filter yaml:

policy: routing policy. If unspecified, routes all addresses to destination if present or rejects otherwise.

policy.name: dest_domain | address_list or pluggable via modules.recipient_router_policy

dest_domain is used for submission/egress to send to the rhs of the address

address_list is used for ingress to enumerate endpoints for local addresses

policy.endpoint: keys into top-level rest_endpoint

policy.options: updates rest_endpoint options.

policy.options.receive_parsing: required for message_parser to parse the message for rest receivers

policy.options.send_filter_output: enables sending tx.filter_output (cf Signals&Policies below) to receivers. Default false. Rest receivers may or may not want this but it should be disabled for the smtp gateway and short-circuiting.

Note: address_list_policy lists are also available with a TransactionMatcher interface matcher: address_list for use with policy_action.

exploder

filter_yaml:

output_chain configures the upstream chain

msa: if true allows store&forward in a few more situations that clients expect vs ingress where there is a previous hop to retry.

rest_output

RestEndpoint actually sends the message somewhere via http/rest

policy_action

cf Signals&Policies below

spf_check

sender policy framework host verification

filter yaml:

domains: list of additional domains to check. Some inbound gateway setups use spf to publish/discover egress IPs.

SpfCheckFilterOutput.match() yaml

mail_from_result: temperror | spf_pass | permerror | fail | softfail | none | neutral

extra_domain: domain from filter yaml domains

extra_domain_result: same values as mail_from_result

dkim_check

domain-keys identified mail signature verification

DkimCheckFilterOutput.results is a list of DkimCheckFilterOutput.Result which contains details on each dkim signature in the message in order

DkimCheckFilterOutput.match() matches if any signature matches the given criteria

matcher yaml:

status: temp_err | dkim_pass (default) | fail | unknown_algo

alignment: domain | same_sld (default) | other applied inclusively i.e. same_sld also matches domain

domains: mutually exclusive with alignment. List of specific domains to check.

message_validation

parses the message and reports rfc822/mime problems in FilterOutput

MessageValidationFilterResult.match() yaml

validity_threshold : NONE | BASIC | MEDIUM | HIGH

matches if the status is at least as good as this. MEDIUM is the suggested default for ingress and HIGH for submission. MEDIUM requires that the headers are reasonably well-formed and contain exactly 1 from, date, message-id. HIGH requires no defects reported by email.parser.

max_received_headers: matches if the message has more than this many received headers

Signals&Policies

Koukan provides a simple yet powerful system for taking exceptional actions on transactions matching specific criteria.

A signal is a piece of information such as: “the smtp client EHLO matched their reverse dns.” A policy consists of a match expression to identify messages which is a logical combination of signals and an action to take on messages that match the expression. For example: “for transactions where the EHLO didn’t match, return an smtp 550 response.”

This separation between signals and policies allows uniform implementation of actions in one place rather than scattering them across every signal. It also allows policies to include arbitrary logical combinations of signals often for allowlisting: “reject tx where the EHLO didn’t match unless it is from the ip subnet of my known inbound gateway thing.”

Output chain filters emit signals to tx.filter_output. This is typically a filter-specific subclass of FilterOutput. FilterOutput.match(yaml) returns whether the output matches criteria specified in yaml. MatcherResult includes PRECONDITION_UNMET if e.g. the matcher is being run at tx.mail_from but the signal depends on the body, etc. This no-ops all policy_action invocations with the same tag for the remainder of the current FilterChain.update() cycle.

Simple matchers can also be loaded via modules.transaction_matcher similar to recipient_router_filter.RoutingPolicy. A simple matcher is just Callable[[yaml, TransactionMetadata], MatcherResult]

Policies are specified with an invocation of policy_action filter. Filter yaml:

match: match expression, can be an individual signal matcher

invocation or a logical expression all/any/not. An empty/unpopulated match expression always matches for use as the catchall/fallthrough at the end of a group.

match expression examples:

match:
  matcher: koukan.remote_host_filter.RemoteHostFilter
  # ...

 match:  # (not x) or (y and z)
   any:
   - not:
     matcher: x
   - all:
     - matcher: y
       y_matcher_arg: 1
     - matcher: z
       z_matcher_arg: "q"

tag: group/tag name

name: rule name (defaults to tag if not present)

mode: PER_RCPT or unspecified. If mode is PER_RCPT, the policy is invoked once for each recipient and the action is applied only to that rcpt_response.

action: REJECT | LOG | MATCH or list of [weight, action] for percent experiments. The denominator is the sum of the weights.

code: smtp response code for REJECT (default 550)

message: smtp response message for REJECT (default: ‘5.6.0 message rejected’ along with the matching rule name)

action examples:

action:
- [0.9: LOG]
- [0.1: REJECT]

Full policy_action example:

endpoint:
- name: ingress
  # ...
  chain:
  - filter: remote_host
  - filter: policy_action
    match:
      any:
      - matcher: koukan.remote_host_filter.RemoteHostFilter
        fcrdns: false
      - matcher: koukan.remote_host_filter.RemoteHostFilter
        ehlo_alignment: false
    action:
    - [9, LOG]
    - [1, REJECT]
    tag: remote_host

This means: if RemoteHostFilter either returned false for fcrdns or ehlo alignment, LOG 90% of the time and REJECT 10% of the time.

The policy action filter has its own filter_output object which contains 2 sets: matched_tags and matched_rules.

PolicyActionFilter first checks the rule’s tag and rule name against PolicyActionFilterOutput; if either is present, the rule is treated as a no-op. Then it evaluates the match expression. If it returns PRECONDITION_UNMET, all policy invocations with the same tag are no-op’d for the rest of the current FilterChain.update() invocation.

If the expression matches, PolicyActionFilter applies the action:

  • REJECT: sets the responses to code from yaml (default: 550)

  • MATCH: retires rule and tag

  • LOG: retires rule only. This is useful to dark-launch multiple reject rules to see all the ones that match rather than stopping after the first.

PolicyActionFilter’s output object is itself a matcher! This means you can match on the results of previous policy_action invocations so you can, for example, write a single rule to match an allowlist signal to reuse in multiple reject rules:

chain:
- filter: policy_action
  match:
    matcher: network_address
    cidr: 192.168.1.0/24
  name: my_inbound_gw
- filter: message_validation
- filter: policy_action
  match:
    all:
      - not:
          matcher: koukan.policy_action_filter.PolicyActionFilter
          rule: my_inbound_gw
      - matcher: koukan.message_validation_filter.MessageValidationFilter
        validity_threshold: HIGH
   tag: validation
   action: REJECT

The router can optionally send filter_output to rest receivers router.yaml:

rest_endpoint:
- name: receiver
  options:
    send_filter_output: true

This defaults to false. If enabled, the router will send HTTP PATCH to the transaction endpoint to populate filter_output.

Signals

The following built-in filters populate filter_output, cf individual filter descriptions (above):

In addition several simple matchers are available in koukan.transaction_matchers:

network_address: transaction_matchers.match_network_address remote_host cidr:

match:
  matcher: network_address
  cidr: 192.168.1.0/24

tls: transaction_matchers.match_tls was the transaction received via smtp with tls?

address_list: matches lists of email addresses using the same specification as the routing policy AddressListPolicy

smtp_auth: matches if the message was received with smtp authentication. Replaces relay_auth filter

num_rcpts: matches if the number of rcpts prior to this rcpt with a success response is >= yaml max_rcpts

invalid_mail_from: matches if the mail_from mailbox is in invalid

invalid_rcpt_to: PER_RCPT matcher that matches if the rcpt_to mailbox is invalid

Cluster/k8s

All replicas share the same underlying database.

The router implementation currently buffers data through the local filesystem but this does not need to be durable across restarts; emptyDir is fine. This is local to each router process; the router and gateway do not share data through the filesystem.

Koukan may return http redirects if the transaction is leased/active on a different replica; native rest clients must be prepared to follow these.

For both router and gateway, configure rest_listener.session_uri to point to the dns alias or ip of the individual pod/replica. For router, configure rest_listener.service_uri to the router service dns alias.