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"}
]
endQuickstart
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.IncomingAdapterFor 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:
Incoming.Queue.Disk(default) — durable, fsync-backed, crash-recoverable. Suitable for production.Incoming.Queue.Memory— ETS-backed, zero-dependency. Messages are lost on restart. Suitable for development, testing, or ephemeral workloads.
Disk Queue Layout
Messages are stored on disk in:
incoming/<id>/(staging during enqueue; atomically moved intocommitted/when complete)committed/<id>/raw.emlcommitted/<id>/meta.jsonprocessing/<id>/(in-flight delivery)dead/<id>/dead.json(rejected)
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:
-
Moves any
processing/<id>/entries back tocommitted/for re-delivery. -
Finalizes crash leftovers by promoting
raw.tmp/meta.tmptoraw.eml/meta.jsonwhere possible. -
Dead-letters incomplete or invalid entries (for example, missing
raw.emlormeta.json, or file entries where a directory is expected) by moving them intodead/<id>/with adead.jsonreason.
Telemetry (Early)
Events emitted:
[:incoming, :message, :queued]measurements:%{count: 1}, metadata:%{id, size, queue_depth}[:incoming, :message, :enqueue_error]measurements:%{count: 1}, metadata:%{id, reason}(may includeattempted_size)[:incoming, :delivery, :result]measurements:%{count: 1}, metadata:%{id, outcome, reason}[:incoming, :session, :connect]measurements:%{count: 1}, metadata:%{peer}[:incoming, :session, :accepted]measurements:%{count: 1}, metadata:%{id}[:incoming, :session, :rejected]measurements:%{count: 1}, metadata:%{reason}[:incoming, :queue, :depth]measurements:%{count}, metadata:%{}
SMTP Behavior Notes
-
Command ordering is enforced:
RCPTrequires a priorMAIL, andDATArequires at least oneRCPT(otherwise503 Bad sequence of commands). -
After
DATAcompletes, the envelope is reset so the next transaction must start with a newMAIL. -
Rejected
MAIL/RCPTcommands are not retained in the envelope (so rejected recipients do not count towardmax_recipients). -
RFC 5322 header folding is supported: continuation lines (starting with a space or tab) are unfolded, and duplicate headers are joined with
,.
Safety Limits
Incoming includes several safeguards intended for production traffic:
max_depth(queue backpressure): when exceeded,DATAresponds421 Try again later.max_connections_per_ip: when exceeded, new connections are rejected with421 Too many connections.max_commands/max_errors: sessions that exceed limits are disconnected with421.
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.IncomingAdapterReturn values:
:ok-> message acked{:retry, reason}-> message requeued with backoff{:reject, reason}-> message moved to dead-letter
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
-
SMTP DATA is fully buffered in memory by
gen_smtpbefore we write to disk. - Large messages can consume significant memory; streaming support is planned.
Memory, Limits, and Sizing Notes
Incoming currently relies on gen_smtp for SMTP parsing and DATA receive. This has two important implications:
-
DATA is received incrementally, but it is still accumulated and flattened into a single binary before the session
DATAcallback is invoked. -
Message size is still bounded:
gen_smtpenforcesmax_message_sizeboth:-
at
MAIL FROMtime when the client providesSIZE=<n>(rejects early with552if the estimate exceeds the limit) -
during DATA receive (aborts and returns
552 Message too largeonce the limit is exceeded)
-
at
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
-
Keep
max_message_sizeconservative unless you are confident in your VM memory headroom. -
Bound concurrency using listener options:
max_connections(Ranch connection cap)num_acceptors(acceptor concurrency)
-
If you need "never buffer the full message in memory", that requires a different SMTP DATA receive strategy than
gen_smtpcurrently provides (true streaming support is planned).
Testing
mix testTLS (Early)
Four TLS modes are supported:
:disabled— plaintext only (default):optional— advertises STARTTLS; clients may upgrade:required— advertises STARTTLS; policies can enforce upgrade beforeMAIL:implicit— connection starts in TLS (port 465 style); STARTTLS is not advertised
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:
- Repro steps
- Expected vs actual behavior
- Logs or traces if applicable