Koukan Implementation

Overview

Koukan is designed around the premise that in 2025, network availability is pretty good most of the time. So in the common case, we should be able to get a final upstream response quickly and can return that to the sender immediately. This is known as cut-through delivery. Contrast with the classical store&forward model where you spool to disk and deal with output “later,” sometimes much later. Store&forward entails sending a bounce message after the fact if the message couldn’t be delivered upstream which may never get back to the sender.

Koukan consists of two components: the router and the SMTP gateway. The router implements the rest api, durable storage, stateful retries, etc. The SMTP gateway is a stateless adapter to proxy SMTP client and server connections with the rest api.

Router

Within the router, there are two main flows. RestHandler terminates http endpoints/fastapi routes and is a thin adapter to storage. OutputHandler consumes data from storage to drive the output filter chain which always terminates in RestEndpoint to send the message to the destination.

The OutputHandler passes the transaction through a filter chain on the way to RestEndpoint. Filters can make arbitrary transformations of the transaction including modifying the message, routing on destination address, etc.

Transaction Model

The in-process representation of the Transaction resource is TransactionMetadata which is usually abbreviated tx in the code.

Many operations are defined in terms of taking a difference or delta between a previous snapshot of the transaction and the current state or applying a delta to a transaction.

Filter Chain

Filters transform the transaction. In the simplest case, they conservatively extend the transaction by adding fields. In more complex cases, they may implement an arbitrary transformation by proxying between the upstream and downstream side (ProxyFilter).

When the router starts an OutputHandler, it constructs a sequence of filter subclasses per the yaml. FilterChain is the execution engine to propagate the transaction through the sequence of filters.

The chain is always terminated by a filter that sends the transaction “somewhere else”. This is typically either

  • RestEndpoint to send it over http

  • Exploder to write it to a new upstream transaction via StorageWriterFilter.

Storage

The Koukan router stores all durable data in a SQL database which it accesses via SQLAlchemy Core. Blob data is stored in a single field in Blob.content. This can be referenced from multiple transactions via TransactionBlobRefs.

Koukan uses lightweight in-process synchronization to coordinate multiple readers/writers of a given transaction: VersionCache. OutputHandler waits for new downstream data, feeds it to the upstream chain, and writes upstream responses. RestHandler writes new downstream data and then waits for upstream responses.

Exploder

The router output flow generally assumes single-recipient. SMTP has always supported multi-recipient transactions and some old MTAs may not gracefully handle the server rejecting additional recipients. To support this in Koukan, the smtp gateway uses a private dialect of the rest api to build up the transaction incrementally. To bridge the gap with the single-recipient output flow, smtp-facing endpoints are terminated by the Exploder. The Exploder starts a separate upstream transaction for each recipient of the smtp transaction. The body storage is refcounted in the database.

Whereas in the native rest case we have:

application -> RestHandler -> StorageWriterFilter -> db
db -> OutputHandler -> ... -> RestEndpoint -> gateway

with SMTP we have:

gateway -> RestHandler -> StorageWriterFilter -> db
db -> OutputHandler -> Exploder -> StorageWriterFilter -> db
db -> OutputHandler -> ... -> RestEndpoint...

While SMTP supports multi-recipient transactions, it does not support returning a different final response for each recipient. Koukan’s goal here is to return an authoritative upstream response synchronously and avoid accept-and-bounce to the greatest extent possible.

The Exploder accomplishes this by attempting opportunistic cut-through and falling back to store&forward if this is not possible. It waits for a short time (relative to an smtp command timeout, say 10-30s) for an upstream response. Then if all upstream data responses are the same, that is returned directly. Otherwise, retry/bounce is enabled on the upstream transactions that didn’t already succeed. At this point, the downstream smtp transaction is durable and it’s safe to return a 250 data/final response.

There is a further distinction between submission vs ingress. Submission stores-and-forwards temporary upstream errors since the downstream is a client that usually expects us “the server” to retry. Whereas ingress returns temporary errors downstream directly since the client is another MTA that is prepared to retry.

submission

ingress

recipient

timeout: upgrade to 250

temp/4xx: upgrade to 250

perm/5xx: verbatim

timeout: 450

temp/4xx: verbatim

perm/5xx: verbatim

data/final

timeout: upgrade to 250

temp/4xx: upgrade to 250

perm/5xx: verbatim

all errors: verbatim

Gateway

There is no intrinsic connection between the smtp server and client sides of the gateway; you can run separate instances for each function for isolation, etc.

Smtp Server

aiosmtpd → SmtpHandler → RestEndpoint

Smtp Client

RestHandler → SyncFilterAdapter → SmtpEndpoint

SyncFilterAdapter implements the same AsyncFilter abc as StorageWriterFilter.