CAUTION: AI VIBE CODED PRODUCTEXPERIMENTAL - WORK IN PROGRESS

Please report weaknesses, flaws, edge cases, or unexpected behavior via GitHub Issues or Discussions.

Incoming

Production-grade inbound SMTP server library for Elixir. Inbound-only, OTP-native, and designed to replace Postfix for controlled environments.

Status

Early implementation, actively evolving. Core SMTP, queue, delivery, policies, TLS, and telemetry are in place; streaming DATA is not yet implemented.

Installation

Add incoming to your list of dependencies in mix.exs:

def deps do
  [
    {:incoming, "~> 0.5.0"}
  ]
end

Quickstart

config :incoming,
  listeners: [
    %{
      name: :default,
      port: 2525,
      tls: :disabled
    }
  ],
  queue: Incoming.Queue.Disk,
  queue_opts: [
    path: "/tmp/incoming",
    fsync: true,
    max_depth: 100_000,
    cleanup_interval_ms: 60_000,
    dead_ttl_seconds: 7 * 24 * 60 * 60
  ],
  delivery: MyApp.IncomingAdapter

For a lightweight or ephemeral setup (no disk persistence):

config :incoming,
  queue: Incoming.Queue.Memory,
  queue_opts: []

Then start your application and point a test SMTP client at localhost:2525.

Policies (Early)

You can configure simple policies in config.exs:

config :incoming,
  policies: [
    Incoming.Policy.HelloRequired,
    Incoming.Policy.SizeLimit,
    Incoming.Policy.MaxRecipients,
    Incoming.Policy.TlsRequired,
    Incoming.Policy.RateLimiter
  ]

Rate Limiter

The rate limiter uses a sliding time window. Counters reset automatically when the window expires, and a background sweeper periodically cleans up stale entries.

config :incoming,
  rate_limit: 5,                       # max requests per window (default: 5)
  rate_limit_window: 60,               # window size in seconds (default: 60)
  rate_limit_max_entries: 100_000,     # cap ETS growth (default: 100_000)
  rate_limit_sweep_interval: 60_000    # sweep interval in ms (default: 60_000)

Queue Backends

Two queue backends are available:

Disk Queue Layout

Messages are stored on disk in:

Retries move messages back to committed/. Rejects move to dead/.

meta.json includes an attempts counter used to enforce max_attempts across restarts.

Recovery Notes

On application start, the queue runs a recovery pass that:

Telemetry (Early)

Events emitted:

SMTP Behavior Notes

Safety Limits

Incoming includes several safeguards intended for production traffic:

These are configured via queue_opts, listener config, and session_opts respectively.

Graceful Shutdown / Drain

Use Incoming.Control to stop accepting new connections, drain active sessions, and optionally force close stragglers:

Incoming.Control.shutdown(timeout_ms: 5_000)

Delivery Adapter (Early)

Implement Incoming.DeliveryAdapter and configure it:

config :incoming, delivery: MyApp.IncomingAdapter

Return values:

Delivery options (defaults shown):

config :incoming,
  delivery_opts: [
    workers: 1,
    poll_interval: 1_000,
    max_attempts: 5,
    base_backoff: 1_000,
    max_backoff: 5_000
  ]

Limitations

Memory, Limits, and Sizing Notes

Incoming currently relies on gen_smtp for SMTP parsing and DATA receive. This has two important implications:

  1. DATA is received incrementally, but it is still accumulated and flattened into a single binary before the session DATA callback is invoked.
  2. Message size is still bounded: gen_smtp enforces max_message_size both:
    • at MAIL FROM time when the client provides SIZE=<n> (rejects early with 552 if the estimate exceeds the limit)
    • during DATA receive (aborts and returns 552 Message too large once the limit is exceeded)

Capacity Math (Rule Of Thumb)

Because DATA is accumulated and then flattened, peak memory per in-flight DATA transaction can be roughly:

~ 2 * max_message_size (plus overhead, especially for many small chunks).

This means total worst-case transient memory is approximately:

concurrent_DATA_sessions * 2 * max_message_size.

In practice you should treat max_message_size and concurrency limits as a pair.

Practical Recommendations

Testing

mix test

TLS (Early)

Four TLS modes are supported:

STARTTLS (ports 25/587)

config :incoming,
  listeners: [
    %{
      name: :default,
      port: 2525,
      tls: :required,
      tls_opts: [
        certfile: "test/fixtures/test-cert.pem",
        keyfile: "test/fixtures/test-key.pem"
      ]
    }
  ]

Implicit TLS (port 465)

config :incoming,
  listeners: [
    %{
      name: :smtps,
      port: 465,
      tls: :implicit,
      tls_opts: [
        certfile: "priv/cert.pem",
        keyfile: "priv/key.pem"
      ]
    }
  ]

Feedback

If you find problems or have suggestions, please open an issue and include: