1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
|
# 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 mail-ingest.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.
```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: [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
```sh
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)
```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)}`);
}
},
};
```
|