aboutsummaryrefslogtreecommitdiff
path: root/README.md
blob: e4960e3653ef5dad74e6740e96129f9d4bf48ebb (plain)
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)}`);
    }
  },
};
```