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)}`);
}
},
};
