# 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 </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 mail-ingest.service ## Protocol `POST /inject` | Header | Required | Description | |---|---|---| | `authorization` | yes | `Bearer ` | | `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. ```sh 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: alice@example.net" \ -H "x-envelope-to: bob@example.com" \ --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 `. 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 ```sh INGEST_TOKEN=$(openssl rand -base64 32) LMTP_SOCK=/var/run/dovecot/lmtp LISTEN_ADDR=127.0.0.1:1234 LMTP_RCPT=you@example.com RUST_LOG=http2lmtp=info ``` ## Example client (cloudflare workers) ```ts 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)}`); } }, }; ```