41 checks per scan34 blacklistsSPF, DKIM, DMARC, MTA-STS, BIMINo signup
← The AcademyAdvanced · 9h · free

KumoMTA: High-Volume Sending Engine

12-module KumoMTA course: Lua config, listeners, queues, IP warming, DKIM/SPF/DMARC alignment, bounces, FBLs, ClickHouse analytics, and HA sharding.

12Modules
25Lessons
9hEstimated
$0Cost

Module index

Module 013 lessons

Introduction to KumoMTA

What KumoMTA is, who built it, and where it fits in the modern sending stack.

01

What is KumoMTA?

KumoMTA is an open-source, high-volume Message Transfer Agent (MTA) built specifically for outbound mail at scale. Think millions of messages per hour from a single node, with the kind of throttling, traffic shaping, and observability that big senders pay PowerMTA $50k+/year for.

Created by ex-Port25 engineers (the team behind PowerMTA), KumoMTA was launched in 2023 under Apache 2.0. It is not a general-purpose mail server — you don't put mailboxes on it. It exists to send mail efficiently and to honor every nuance of how Gmail, Yahoo, and Microsoft want to receive bulk traffic.

Core differentiators:

Feature KumoMTA Postfix PowerMTA
Per-destination throttling First-class, Lua-driven Plugin / hard First-class
Modern Lua config ✅ ❌ ❌
Built-in ClickHouse hooks ✅ ❌ Partial
FBL & DMARC report ingestion ✅ ❌ ✅
Open source ✅ Apache 2.0 ✅ IBM Public ❌ Commercial
Throughput per node 1M+ msgs/hr ~100k msgs/hr 1M+ msgs/hr

When to choose KumoMTA: ESPs, marketing platforms, transactional senders at >100k msgs/day, any team currently outgrowing Postfix or paying for PowerMTA.

Key takeaways
  • KumoMTA is a dedicated outbound MTA — no mailboxes, no IMAP
  • Built by ex-Port25 (PowerMTA) engineers, open source under Apache 2.0
  • First-class per-destination throttling — the killer feature vs Postfix
02

The MTA landscape and where Kumo fits

Three families of MTAs in 2026:

1. General-purpose (Postfix, Exim, Sendmail). Receive, queue, deliver. Fine for a few thousand emails/day. Hard to tune for huge volume — no native per-destination concurrency, weak observability.

2. Sending engines (KumoMTA, PowerMTA, Halon, MailerQ). Built for outbound only. Per-destination queues, IP pools, warmup curves, bounce processing, FBL ingestion. This is the bulk-sender category.

3. Cloud APIs (SendGrid, Postmark, Amazon SES). You hand off via HTTPS or SMTP-relay. They handle MTA + IPs + reputation. Easy at low scale, expensive at high scale, less control.

Where Kumo wins:

  • You're shipping >100k msgs/day and SendGrid pricing hurts.
  • You need per-domain throttling that respects Gmail's 250/sec or Yahoo's 100/sec.
  • You want to own your IPs and reputation.
  • You want hot-reload config without restarts.

Where you should NOT use Kumo:

  • You ship <10k msgs/day. Use SES or a Postfix relay; the operational overhead isn't worth it.
  • You also need to receive mail. Pair with Postfix or just don't.
Key takeaways
  • Three categories: general-purpose, sending engines, cloud APIs
  • KumoMTA is in category 2 — for teams who need to own outbound at scale
  • <10k msgs/day? Use SES or a managed service. KumoMTA earns its keep above that
03

Architecture at a glance

KumoMTA is a single Rust binary with Lua scripting for all runtime configuration. The same binary handles SMTP listeners, queues, delivery, logging, and HTTP introspection.

Process model:

                  ┌─ Listener (SMTP)
                  ├─ Listener (HTTP API)
[ kumod ] ──┐    │
            ├──► ├─ Queue (per scheduled queue: tenant × destination)
            │    │
            │    ├─ Egress sources (IP pools)
            │    └─ Logging hooks → JSON / ClickHouse / S3
            │
            └─ Lua VM (config + per-event policy)

Key concepts you'll meet:

Term Meaning
Tenant Logical sender (often a customer of an ESP)
Egress source A bound source IP for outbound connections
Egress pool A group of sources to rotate across
Scheduled queue Per-tenant × per-destination queue
Throttle Rate limit (msgs/sec or connections) per key
Hook Lua callback for events: smtp_server_message_received, smtp_client_message_delivered, etc.

Everything plugs together via Lua. There's no XML, no INI, no main.cf. Read init.lua and you know what the MTA does.

Key takeaways
  • Single Rust binary, configured entirely in Lua
  • Per-tenant × per-destination scheduled queues are the core abstraction
  • Egress pools = your IP rotation strategy, expressed in code
Module 022 lessons

Installation, Lua Primer & systemd

Get KumoMTA running and learn just enough Lua to navigate the config.

04

Installing KumoMTA

Supported OS: Debian 12, Ubuntu 22.04/24.04, Rocky/Alma 9. Don't try Alpine — musl libc breaks DNS resolution under load.

Install via the official repo (Debian/Ubuntu):

curl https://openrepo.kumomta.com/files/kumomta-debian.sources \
  | sudo tee /etc/apt/sources.list.d/kumomta.sources
apt update
apt install kumomta

Default paths:

Path Purpose
/opt/kumomta/sbin/kumod The daemon binary
/opt/kumomta/etc/policy/init.lua Main config
/var/spool/kumomta/ Queue spool
/var/log/kumomta/ JSON event logs
/etc/systemd/system/kumomta.service systemd unit

Hello world via systemd:

systemctl enable --now kumomta
journalctl -u kumomta -f

The default init.lua listens on 2525 (not 25 — port 25 binding requires CAP_NET_BIND_SERVICE in the unit file).

🎯 In production, bind 25 only on relay-from-trusted hosts. Submission goes on 587/465 with AUTH.

Key takeaways
  • Debian 12 is the most-tested target — start there
  • Default listener is 2525 — explicitly enable 25/587/465 in init.lua
  • Logs are JSON in /var/log/kumomta/ — pipe to your SIEM from day one
05

Just-enough Lua for KumoMTA

KumoMTA's config is Lua. You don't need to be a Lua dev — you need to read it and copy patterns.

The five constructs you'll see everywhere:

1. Module + function:

local kumo = require 'kumo'

kumo.on('init', function()
  kumo.start_esmtp_listener { listen = '0.0.0.0:25' }
end)

2. Tables (KumoMTA's "config blocks"):

kumo.on('init', function()
  kumo.define_spool { name='data', path='/var/spool/kumomta/data', kind='RocksDB' }
  kumo.define_spool { name='meta', path='/var/spool/kumomta/meta', kind='RocksDB' }
end)

3. Event hooks:

kumo.on('smtp_server_message_received', function(msg)
  -- inspect/modify the message
  msg:set_meta('tenant', 'acme-marketing')
end)

4. Pattern-based throttles:

kumo.on('get_queue_config', function(domain, tenant, campaign)
  return kumo.make_queue_config {
    max_connection_rate = '10/s',
    max_message_rate = '500/s',
  }
end)

5. Pattern matching for destination tuning:

if domain == 'gmail.com' then ...
elseif domain:match('%.protection%.outlook%.com$') then ...
end

That's 90% of what you'll write. The docs at docs.kumomta.com mirror these patterns relentlessly.

Key takeaways
  • Five Lua patterns cover almost every KumoMTA config you'll write
  • Event hooks (kumo.on) are how you customize behavior — no plugins, just functions
  • Table-as-arguments style — every API takes a single table of named fields
Module 032 lessons

Listeners, ESMTP, AUTH & TLS

Configure SMTP listeners, authentication, and TLS termination.

06

Defining listeners

KumoMTA exposes one or more SMTP listeners. Each can have its own ports, TLS, AUTH, and accept rules.

kumo.on('init', function()
  -- Inbound relay (from your application)
  kumo.start_esmtp_listener {
    listen = '0.0.0.0:587',
    relay_hosts = { '127.0.0.1', '10.0.0.0/8' },
    tls_certificate = '/etc/ssl/kumo/fullchain.pem',
    tls_private_key  = '/etc/ssl/kumo/privkey.pem',
  }

  -- Submission with AUTH from public clients
  kumo.start_esmtp_listener {
    listen = '0.0.0.0:465',
    tls_certificate = '/etc/ssl/kumo/fullchain.pem',
    tls_private_key  = '/etc/ssl/kumo/privkey.pem',
    -- Implicit TLS:
    invocation = 'implicit_tls',
  }
end)

relay_hosts is the trusted-network shortcut. Hosts in this list can send to any recipient without AUTH. For public-facing submission, leave it empty and require AUTH.

Multiple listeners are normal — usually one for application traffic (no auth, IP-restricted) and one for external submission (auth required).

Key takeaways
  • Multiple listeners on different ports is normal and idiomatic
  • relay_hosts = IP allow-list; anything else needs AUTH
  • invocation = 'implicit_tls' for port 465; default is STARTTLS
07

SMTP AUTH and rate limiting at the listener

Wire up SASL PLAIN/LOGIN auth:

kumo.on('smtp_server_auth_plain', function(authz, authc, password, conn_meta)
  -- Look up credentials in your DB / file / Vault
  local ok = verify_password(authc, password)
  if ok then
    conn_meta:set_meta('tenant', tenant_for_user(authc))
    return true
  end
  return false
end)

You decide where credentials live — passwd-file, MySQL, Vault, HTTP API. KumoMTA just calls your function.

Per-listener rate limit to slow brute-force:

kumo.start_esmtp_listener {
  listen = '0.0.0.0:587',
  max_messages_per_connection = 1000,
  max_recipients_per_message  = 100,
  data_buffer_size           = '32MB',
  max_message_rate           = '50/s',  -- per source IP
}

Bind tenant metadata at AUTH time. Everything downstream — throttles, IP pool, queue selection — reads conn_meta for tenant. Get this right and the rest of the config writes itself.

Key takeaways
  • AUTH is a Lua callback — you own the credential lookup
  • Set tenant in conn_meta at AUTH time — downstream uses it everywhere
  • Per-listener rate limits stop credential-stuffing without affecting throughput
Module 042 lessons

Queues, Scheduling & Traffic Shaping

Per-destination throttling, retry schedules, and queue priorities.

08

Scheduled queues = tenant × destination

Every accepted message lands in a scheduled queue keyed by (tenant, campaign, destination_domain). Each queue has its own throttle, retry schedule, and source IP pool.

Define queue config dynamically:

kumo.on('get_queue_config', function(domain, tenant, campaign, routing_domain)
  -- Defaults
  local cfg = {
    max_age = '24h',
    retry_interval = '20m',
    max_retry_interval = '4h',
    max_connection_rate = '5/s',
  }

  -- Gmail tightening
  if domain == 'gmail.com' or domain:match('%.googlemail%.com$') then
    cfg.max_connection_rate = '20/s'
    cfg.max_message_rate    = '250/s'
  end

  -- Microsoft 365 tightening
  if domain:match('%.protection%.outlook%.com$') then
    cfg.max_connection_rate = '4/s'
    cfg.max_message_rate    = '40/s'
  end

  return kumo.make_queue_config(cfg)
end)

Retry curves are exponential by default. Tune retry_interval (first retry) and max_retry_interval (cap). Production rule of thumb: 5 min → 30 min → 2 h → 4 h, max age 24–48 h.

Key takeaways
  • Scheduled queues key on (tenant, campaign, destination)
  • Tune max_connection_rate per major destination — Gmail/Yahoo/M365 each different
  • Default retry curve is exponential; cap at 4 h and 24–48 h max_age
09

Traffic shaping with throttles

Beyond queue defaults, KumoMTA's shaping module is where you encode per-destination wisdom (Gmail wants 250/sec from a warm IP, Yahoo wants 100/sec, AOL is finicky on Sundays).

Use the bundled shaping library:

local shaping = require 'policy-extras.shaping'

kumo.on('get_egress_path_config', shaping:setup({'/opt/kumomta/etc/policy/shaping.toml'}))

shaping.toml snippet:

["gmail.com"]
connection_limit = 20
max_connection_rate = "20/s"
max_message_rate = "250/s"
idle_timeout = "60s"

["yahoo.com"]
connection_limit = 10
max_message_rate = "100/s"

["*.protection.outlook.com"]
connection_limit = 4
max_message_rate = "40/s"

Why TOML for shaping but Lua for everything else? Shaping changes often (especially during IP warming) and TOML is operator-friendly. Hot-reload with kcli reload — no restart.

KumoMTA community ships a maintained shaping.toml with sane defaults for the top 50 destinations. Use it as your baseline.

Key takeaways
  • shaping.toml is hot-reloadable — change rates without restart
  • Community shaping.toml is a great starting baseline for top destinations
  • Per-destination rates beat global rates every time
Module 052 lessons

IP Warming Strategies & Pools

Stand up new IPs without burning their reputation.

10

Why warming exists

Fresh IPs send tiny volumes (a few hundred/day) and slowly scale up. Send 100k from day-one and Gmail's spam filter slams you for a month.

Warming curves (rough industry consensus):

Day Daily volume per IP Notes
1 50 All to most-engaged recipients
2 100
3 500
5 1,000
7 2,500
14 10,000
21 50,000
30 100,000+ Fully warm for most ESPs

During warming you MUST:

  • Send to your most engaged segment first.
  • Maintain bounce rate <2% and complaint rate <0.1%.
  • Send consistent volumes — gaps look like signs of compromise.
  • Sign with DKIM, align SPF + From, publish DMARC.

KumoMTA's job: enforce the daily caps and rotate through the warming pool so no single IP exceeds its bucket.

Key takeaways
  • Warming is mandatory for new IPs; expect 3–4 weeks to full volume
  • Send to your most engaged audience first — opens and clicks build reputation
  • Bounce rate <2%, complaint <0.1% throughout warming or restart the curve
11

Egress sources and pools in code

Define sources (one per outbound IP):

kumo.on('init', function()
  for i = 1, 8 do
    kumo.define_egress_source {
      name = 'ip-' .. i,
      source_address = '198.51.100.' .. (10 + i),
      ehlo_domain    = 'mail' .. i .. '.example.com',
    }
  end

  kumo.define_egress_pool {
    name = 'warming-pool',
    entries = {
      { name = 'ip-1', weight = 1 },
      { name = 'ip-2', weight = 1 },
      -- equal-weight rotation
    }
  }

  kumo.define_egress_pool {
    name = 'production-pool',
    entries = {
      { name = 'ip-3', weight = 5 },
      { name = 'ip-4', weight = 5 },
      { name = 'ip-5', weight = 3 },
    }
  }
end)

Select pool per-message:

kumo.on('get_queue_config', function(domain, tenant, campaign)
  local pool = 'production-pool'
  if tenant == 'new-customer' then pool = 'warming-pool' end
  return kumo.make_queue_config { egress_pool = pool }
end)

Enforce daily caps with the throttles module — a per-source message bucket reset at midnight. This is your warming enforcer.

Key takeaways
  • One egress_source per outbound IP, with matching ehlo_domain + PTR
  • Pools group sources — weight determines rotation share
  • Select pool via get_queue_config based on tenant/campaign
Module 062 lessons

DKIM Signing, SPF & DMARC Alignment

Get authentication right so your mail isn't filtered before it's read.

12

DKIM signing in KumoMTA

Define a signer in init.lua:

local signer = kumo.dkim.rsa_sha256_signer {
  domain   = 'example.com',
  selector = 's2026',
  headers  = { 'From', 'To', 'Subject', 'Date', 'Message-ID', 'List-Unsubscribe' },
  key      = '/etc/kumomta/dkim/example.com/s2026.key',
}

kumo.on('smtp_server_message_received', function(msg)
  msg:dkim_sign(signer)
end)

Publish public key at s2026._domainkey.example.com IN TXT:

v=DKIM1; k=rsa; p=MIIBIjANBg...

Multi-tenant signing:

kumo.on('smtp_server_message_received', function(msg)
  local domain = msg:from_header_address():match('@(.+)$'):lower()
  local signer_for_domain = signers_by_domain[domain]
  if signer_for_domain then msg:dkim_sign(signer_for_domain) end
end)

Always 2048-bit RSA. Rotate selectors yearly. Always sign List-Unsubscribe — it's how Gmail's one-click unsub validates.

Key takeaways
  • DKIM signing is one Lua call per message — easy to make per-tenant
  • Always include List-Unsubscribe in signed headers
  • 2048-bit RSA, yearly selector rotation
13

Alignment is the only thing that matters

SPF check is on MAIL FROM (envelope/Return-Path). DKIM check is on the d= tag in the signature. DMARC enforces that at least one of those matches the visible From: header domain.

Practical rule: for full DMARC alignment, set:

  • Return-Path to a subdomain you control (bounce.example.com) that's authorized in your SPF.
  • d= in DKIM signature to your root or organizational domain (example.com).

KumoMTA controls Return-Path via:

kumo.on('smtp_server_message_received', function(msg)
  msg:set_meta('queue', msg:recipient():host())
  msg:set_meta('routing_domain', msg:recipient():host())
  -- Use VERP for bounce attribution
  local vrp = 'bounce+' .. msg:id() .. '@bounce.example.com'
  msg:set_meta('return_path', vrp)
end)

Publish:

example.com         IN TXT "v=spf1 include:_spf.example.com -all"
_spf.example.com    IN TXT "v=spf1 ip4:198.51.100.0/29 -all"
bounce.example.com  IN TXT "v=spf1 ip4:198.51.100.0/29 -all"

example.com         IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc@example.com; sp=reject;"

Without alignment, you fail DMARC even with passing SPF+DKIM. This is the #1 misconfiguration for new senders.

Key takeaways
  • DMARC needs the From-header domain to align with SPF Return-Path OR DKIM d=
  • Use VERP Return-Path on a subdomain you authorize in SPF
  • DKIM d= should be your org domain, not a subdomain
Module 072 lessons

Bounce Processing & FBL Handling

Parse bounces, classify codes, and ingest feedback loops.

14

How KumoMTA classifies bounces

When a remote server rejects your message, KumoMTA captures:

  • SMTP code (4xx, 5xx).
  • Enhanced status code (5.1.1, 5.7.1, etc.).
  • Diagnostic text from the remote.

It runs the diagnostic through its bounce classifier (ships with patterns for Gmail, Yahoo, MS, ProtonMail, etc.) and assigns a category:

Category Action
InvalidRecipient Remove from list immediately
ContentRejected Review content, suppress only if persistent
PolicyRejected IP/domain reputation issue — investigate, don't blame the list
Reputation You're being filtered — warming or blocklist issue
SpamRelated Suspect content/sender
Quotas Mailbox full — soft, retry later
Authentication DKIM/SPF/DMARC misalignment
Throttled Slow down (4xx) — retry per backoff

Hook to react in code:

kumo.on('smtp_client_message_delivered', function(msg, response) end)

kumo.on('smtp_client_message_bounced', function(msg, response)
  local cat = response:bounce_classification()
  if cat == 'InvalidRecipient' then
    suppress(msg:recipient())
  end
end)
Key takeaways
  • Bounce classifier turns raw SMTP into actionable categories
  • InvalidRecipient = suppress immediately; Throttled = let queue retry
  • Don't auto-suppress on ContentRejected — investigate first
15

Feedback Loops (FBL) ingestion

When Gmail/Yahoo/etc. flag a complaint, they send an ARF report to a mailbox you register. You must suppress that recipient immediately or your sender reputation craters.

Architecture:

  1. Register FBL with each provider:
    • Microsoft: SNDS portal → JMRP enrollment.
    • Yahoo: requires DKIM, register at sender.yahooinc.com.
    • Gmail: no traditional FBL — use Postmaster Tools complaints metric.
  2. Point the abuse mailbox at a parser (your own, or KumoMTA's built-in ARF parser).
  3. Pipe parsed complaints into your suppression list.

KumoMTA's ARF helper:

local arf = require 'policy-extras.arf'

kumo.on('smtp_server_message_received', function(msg)
  if msg:recipient():user() == 'abuse' then
    local report = arf.parse(msg)
    if report then suppress(report.original_recipient) end
    -- Don't deliver — it was just a report
    msg:set_disposition('drop')
  end
end)

🎯 Complaint rate over 0.3% triggers Gmail filtering. Over 0.5% gets you blocklisted. Treat FBL processing as a Sev-1 path.

Key takeaways
  • Register FBLs with Microsoft, Yahoo (no Gmail FBL — use Postmaster Tools)
  • KumoMTA's ARF parser handles the standard report format
  • Complaint rate >0.3% is a real fire — suppress on every FBL hit
Module 082 lessons

Logging, Hooks & ClickHouse

Stream every event into a queryable warehouse for ops and reporting.

16

KumoMTA's event log structure

Every message generates structured JSON events: Received, Delivered, Bounced, TransientFailure, Expiration, FeedbackReport.

Sample log line:

{
  "type": "Delivered",
  "id": "01HC8X...",
  "timestamp": 1748908800.123,
  "sender": "newsletter@example.com",
  "recipient": "alice@gmail.com",
  "domain": "gmail.com",
  "tenant": "acme-marketing",
  "campaign": "blackfriday-2026",
  "response": { "code": 250, "enhanced_code": "2.0.0", "content": "OK" },
  "queue": "gmail.com.acme-marketing.blackfriday-2026",
  "egress_source": "ip-3",
  "session_id": "abc...",
  "headers": { "Message-ID": "<...>" }
}

Default log destinations:

  • /var/log/kumomta/kumod.log — rotated
  • Webhook hooks for streaming

Recommended pipeline: KumoMTA → Vector → ClickHouse / S3 / Loki. Vector handles batching, retries, and schema enforcement.

Key takeaways
  • Every event is structured JSON — easy to ship anywhere
  • Vector is the recommended log shipper for KumoMTA
  • ClickHouse is the analytics destination of choice for high-volume senders
17

ClickHouse for delivery analytics

ClickHouse turns billions of log rows into sub-second analytics. KumoMTA documents the schema upstream — most ESPs run with it from day one.

Sample table:

CREATE TABLE kumomta_events (
  ts DateTime,
  type LowCardinality(String),
  tenant LowCardinality(String),
  campaign LowCardinality(String),
  domain LowCardinality(String),
  egress_source LowCardinality(String),
  response_code UInt16,
  enhanced_code LowCardinality(String),
  bounce_category LowCardinality(String),
  recipient String,
  message_id String
) ENGINE = MergeTree
ORDER BY (ts, tenant, domain);

Bread-and-butter queries:

-- Delivery rate per destination, last hour
SELECT domain,
       countIf(type='Delivered') / countIf(type IN ('Delivered','Bounced')) AS delivery_rate
FROM kumomta_events
WHERE ts > now() - INTERVAL 1 HOUR
GROUP BY domain ORDER BY delivery_rate DESC;

-- Top bounce categories per tenant
SELECT tenant, bounce_category, count() AS n
FROM kumomta_events WHERE type='Bounced' AND ts > today()
GROUP BY tenant, bounce_category ORDER BY n DESC LIMIT 50;

This is where deliverability stops being guesswork. You can answer "is our Yahoo delivery degrading?" in seconds.

Key takeaways
  • ClickHouse + KumoMTA = real-time delivery analytics at billions of events
  • Order keys by (ts, tenant, domain) for typical access patterns
  • Delivery rate per domain over time is your #1 health metric
Module 092 lessons

Throttles per Destination (Gmail / Yahoo / M365)

Operator-tuned defaults for the biggest mailbox providers.

18

Gmail's expectations in 2026

Gmail accepts huge volumes from senders it trusts. From new IPs, it's brutal.

What Gmail wants:

  • Connection rate: 20/s sustained for warm IPs (drop to 5/s during warming).
  • Message rate: 250/s per IP for warm; 50/s warming.
  • Connection reuse — pipeline many messages per TCP session (idle_timeout = 60s).
  • DKIM signing with 2048-bit key, aligned with From.
  • DMARC p=quarantine or p=reject for bulk senders sending >5000/day (Yahoo+Gmail joint requirement).
  • One-click unsubscribe (RFC 8058) on every bulk message.
  • Maintain complaint rate <0.3% (visible in Postmaster Tools).

KumoMTA shape:

["gmail.com"]
connection_limit = 20
max_connection_rate = "20/s"
max_message_rate = "250/s"
idle_timeout = "60s"
ehlo_domain = "mail.example.com"

🎯 Postmaster Tools is non-optional. Sign up, prove the domain, and check the IP/Domain Reputation panels weekly.

Key takeaways
  • Gmail: 20 conn/s, 250 msg/s for warm IPs — long pipelines are good
  • DMARC enforcement + one-click unsub are required for >5k/day senders
  • Postmaster Tools is the only Gmail-side visibility you get
19

Yahoo and Microsoft 365 quirks

Yahoo:

  • Connection rate: 10/s.
  • Message rate: 100/s per IP.
  • Strict on DKIM alignment — d= must match From or strict subdomain.
  • Yahoo Sender Hub (sender.yahooinc.com) for FBL registration.
  • Bulk requirements mirror Gmail's 2024 requirements.

Microsoft 365 (*.protection.outlook.com):

  • Very low concurrency: 3–5 connections per IP.
  • Sensitive to bursty traffic — pace evenly.
  • SNDS + JMRP enrollment is mandatory for visibility.
  • Often returns vague 5.7.1 Service unavailable — usually means IP reputation.
  • Will throttle silently — watch for queue buildup on *.protection.outlook.com even when no bounces appear.

Shape table:

["yahoo.com"]
connection_limit = 10
max_message_rate = "100/s"
idle_timeout = "60s"

["*.protection.outlook.com"]
connection_limit = 4
max_message_rate = "40/s"
idle_timeout = "30s"

ProtonMail, Apple iCloud, regional providers: start conservative (2 conn / 10 msg/s) and ramp based on observed behavior.

Key takeaways
  • Yahoo: 10 conn/s, 100 msg/s — sign up at Sender Hub for FBL
  • M365: low concurrency (3–5), bursty traffic is the kiss of death
  • Watch silent throttling via queue depth, not just bounce rate
Module 102 lessons

Suppression Lists & Compliance

Don't send to people who said no — and prove it on demand.

20

What belongs on the suppression list

Suppression isn't just unsubscribes. Six categories:

Category Source Retention
Hard bounces KumoMTA bounce classifier Forever
Unsubscribes List-Unsubscribe handler + web form Forever
Spam complaints (FBL) ARF reports Forever
Manual complaints (support@) Human review Forever
Role accounts (postmaster@, abuse@) Pre-list filter Forever
Region opt-outs (GDPR, CASL) Compliance API Per regulation

Storage: a single suppression service (PostgreSQL or Redis) queried by KumoMTA on every smtp_server_message_received. Reject at SMTP time so senders learn fast.

kumo.on('smtp_server_message_received', function(msg)
  if suppression:contains(msg:recipient()) then
    kumo.reject(550, "5.7.1 Recipient is on suppression list")
  end
end)
Key takeaways
  • Suppression covers 6 categories — not just unsubscribes
  • Centralize in PostgreSQL/Redis, query on every message
  • Reject at SMTP time so upstream senders learn immediately
21

One-click unsubscribe (RFC 8058)

Since Feb 2024 Gmail and Yahoo require bulk senders (>5000/day) to support List-Unsubscribe-Post: List-Unsubscribe=One-Click. KumoMTA doesn't generate this for you — your application does — but Kumo must sign it with DKIM.

Headers your sender must add:

List-Unsubscribe: <https://unsub.example.com/u/{token}>, <mailto:unsub@example.com?subject={token}>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

Your unsub endpoint must:

  • Accept POST with body List-Unsubscribe=One-Click.
  • Suppress immediately (no confirmation page, no login).
  • Return 200 in <2 seconds.

Include in DKIM signed headers list:

local signer = kumo.dkim.rsa_sha256_signer {
  headers = { 'From', 'To', 'Subject', 'Date', 'Message-ID', 'List-Unsubscribe', 'List-Unsubscribe-Post' },
  ...
}

Missing one-click on bulk mail = silent filtering at both Gmail and Yahoo. Non-negotiable.

Key takeaways
  • One-click unsub is mandatory for >5k/day senders since Feb 2024
  • Endpoint must POST-suppress with no friction, <2 s response
  • Sign List-Unsubscribe + List-Unsubscribe-Post via DKIM
Module 112 lessons

HA Architecture & Sharding

Scale beyond a single KumoMTA node without dropping queue state.

22

Sharding by tenant

KumoMTA scales horizontally by running multiple independent nodes. There's no built-in cluster — you shard at the upstream by hashing on tenant or campaign.

Typical layout:

[ Application / API ]
        │
   shard router (HAProxy with consistent hash on header)
        │
   ┌────┼────┬────┬────┐
[node1] [node2] [node3] [node4]
   │      │      │      │
  IPs    IPs    IPs    IPs

Why hash by tenant: all of a tenant's queues live on one node → consistent throttling and warming behavior. If a tenant grows large, move them to a dedicated pair.

HAProxy snippet:

backend kumo_pool
  balance hdr(X-Kumo-Tenant)
  hash-type consistent
  server node1 10.0.0.11:587 check
  server node2 10.0.0.12:587 check
  server node3 10.0.0.13:587 check

Application sets X-Kumo-Tenant header at SMTP time (via XCLIENT or custom AUTH metadata).

Key takeaways
  • No built-in clustering — shard upstream with consistent hashing
  • Hash on tenant so throttles and warming stay consistent per tenant
  • Move whales to dedicated pairs as they grow
23

Spool durability and node failover

KumoMTA uses RocksDB for the queue spool. Survives crashes and reboots, but a dead node's queues are stuck on that node's disk.

Two patterns for resilience:

1. Pair of nodes per shard with shared NVMe / DRBD storage. If node A dies, node B mounts the spool and resumes. Fastest failover but ops-heavy.

2. Active/active with brief duplicate-send tolerance. Spool stays local. If a node dies, route new mail to surviving nodes. Mail already on the dead spool waits for recovery (usually <30 min on modern infra). Tolerate brief delays for resilience simplicity.

Most ESPs choose #2. Plus they maintain hot spares ready to mount a snapshot from object storage.

Backup the spool: RocksDB checkpoints can be copied to S3 hourly. Restore-on-new-node tested quarterly.

Key takeaways
  • RocksDB spool survives crashes but is local — node death = stuck queue
  • Most ESPs run active/active and tolerate brief recovery delays
  • Snapshot RocksDB to S3 hourly for disaster recovery
Module 122 lessons

Observability, Alerting & Deliverability Tuning

What to measure, what to alert on, and how to keep delivery healthy.

24

Metrics that actually matter

Track these per (tenant, destination, day):

Metric Warning Critical
Delivery rate <95% <90%
Bounce rate (hard) >2% >5%
Complaint rate (FBL) >0.1% >0.3%
Throttle hits (4xx) >5% >20%
Queue depth per destination growing >1h backlog
Auth failures (DKIM/DMARC) >0.1% >1%
Open rate (where measurable) declining <10%

KumoMTA exposes Prometheus metrics on /metrics endpoint (enabled via init.lua HTTP listener). Scrape every 15s.

Sample Grafana panels:

  • Stacked bar of delivery vs bounce vs deferred per destination.
  • Heatmap of complaint rate per tenant per day.
  • Queue depth time series with annotations on shaping changes.

🎯 The metric you should obsess over is complaint rate. Above 0.3% you're in soft-filter territory; above 0.5% you're being blocklisted.

Key takeaways
  • Per-destination + per-tenant slicing is non-negotiable
  • Complaint rate is the most important single metric to monitor
  • Prometheus + Grafana via /metrics is the standard observability stack
25

Continuous deliverability tuning

Deliverability isn't a one-time setup — it's a weekly review cycle.

Weekly checklist:

  1. Pull Gmail Postmaster Tools — domain reputation, IP reputation, complaint rate.
  2. Pull Microsoft SNDS — IP color (green/yellow/red), filter results.
  3. Pull MailVitals (or competitor) for inbox placement seed test.
  4. Review top bounce categories per tenant — adjust suppression aggressiveness.
  5. Adjust shaping.toml for destinations that throttled.
  6. Rotate DKIM selector if >12 months old.
  7. Re-check DMARC enforcement: still p=reject? aggregate reports clean?

Quarterly:

  • Warm a fresh IP pair as cold-standby (so you have warm spares for emergencies).
  • Audit shaping.toml against actual queue behavior — old rules go stale.
  • Run inbox placement tests across all major destinations.

Annually:

  • Rotate DKIM keys (new selector, retire old after 30 days).
  • Re-evaluate IP pool sizes vs sending volume.
  • Renegotiate IP block leases with hosting provider if needed.

🌍 Deliverability is the long game. Process beats heroics.

Key takeaways
  • Weekly review of Postmaster Tools + SNDS + bounce categories
  • Always keep warm spare IPs ready — emergencies happen
  • Rotate DKIM selectors annually; never let keys age forever

Now check it against your own domain

The course explains the theory. The scan tells you which parts you have actually got right.

Run a free scan