aboutsummaryrefslogtreecommitdiff

http2lmtp

Recieve HTTP-enveloped mail and route it as LMTP to local Unix socket.

Cloudflare Email Workers can only reach a cf tunnel over HTTP, so the message has to be wrapped in an HTTP request to get through. On the server side, http2lmtp unwraps it and hands it to an LMTP-capable MDA such as Dovecot.

Quickstart

cat <<EOF >/etc/http2lmtp.env
INGEST_TOKEN=$(uuidgen)
LMTP_SOCK=/var/run/dovecot/lmtp
LISTEN_ADDR=127.0.0.1:1234
RUST_LOG=info
EOF

cargo build --release
cp target/release/http2lmtp /usr/local/bin/
cp http2lmtp.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable --now http2lmtp.service

Protocol

POST /inject

Header Required Description
authorization yes Bearer <INGEST_TOKEN>
content-type no message/rfc822
x-envelope-from no SMTP MAIL FROM address
x-envelope-to no SMTP RCPT TO address, unless LMTP_RCPT is set

The body is the raw RFC 5322 message.

curl -i -X POST http://127.0.0.1:1234/inject \
  -H "authorization: Bearer $INGEST_TOKEN" \
  -H "content-type: message/rfc822" \
  -H "x-envelope-from: [email protected]" \
  -H "x-envelope-to: [email protected]" \
  --data-binary @message.eml
Status Meaning
204 Delivered.
401 Missing or invalid token.
422 LMTP rejected the message permanently (5xx). Do not retry.
503 LMTP unreachable or rejected temporarily (4xx). Retry later.

Environment

All configuration is read from the environment at startup. Missing required variables cause the process to exit before binding.

Variable Required Default Description
INGEST_TOKEN yes Shared secret. Requests must carry Authorization: Bearer <token>. Compared in constant time.
LMTP_SOCK yes Path to the LMTP unix socket, e.g. /var/run/dovecot/lmtp. The process must have write access to it.
LISTEN_ADDR no 127.0.0.1:8080 Address and port for the HTTP listener.
LMTP_RCPT no Deliver every message to this single recipient instead of the address in x-envelope-to. The original recipient is preserved as an X-Original-To: header. Unset: the envelope recipient is passed through as-is.
RUST_LOG no Log filter, e.g. http2lmtp=info. Standard tracing-subscriber syntax.

Example

INGEST_TOKEN=$(openssl rand -base64 32)
LMTP_SOCK=/var/run/dovecot/lmtp
LISTEN_ADDR=127.0.0.1:1234
LMTP_RCPT=[email protected]
RUST_LOG=http2lmtp=info

Example client (cloudflare workers)

export default {
  async email(message, env, ctx) {
    const res = await fetch("https://example.com/inject", {
      method: "POST",
      headers: {
        "authorization": `Bearer ${env.INGEST_TOKEN}`,
        "content-type": "message/rfc822",
        "x-envelope-from": message.from,
        "x-envelope-to": message.to,
      },
      body: message.raw,
    });

    if (!res.ok) {
      const detail = await res.text().catch(() => "");
      throw new Error(`ingest ${res.status} ${detail.slice(0, 200)}`);
    }
  },
};