|
2 | 2 |
|
3 | 3 | ## Project Overview |
4 | 4 |
|
5 | | -This is a Postfix socketmap adapter for the [Userli](https://github.com/systemli/userli) email user management system. It provides TCP-based lookup services for Postfix to query virtual aliases, domains, mailboxes, and sender login maps from Userli's REST API. |
| 5 | +Postfix adapter for [Userli](https://github.com/systemli/userli) email management. Provides two TCP servers: |
6 | 6 |
|
7 | | -## Architecture |
8 | | - |
9 | | -### Core Components |
10 | | - |
11 | | -- **Socketmap Server** (`server.go`): TCP server implementing Postfix's socketmap protocol on port 10001 |
12 | | -- **Userli Client** (`userli.go`): REST API client for querying Userli backend |
13 | | -- **Metrics Server** (`prometheus.go`): Prometheus metrics endpoint on port 10002 |
14 | | -- **Adapter Logic** (`adapter.go`): Request routing and response formatting for four map types (alias, domain, mailbox, senders) |
15 | | - |
16 | | -### Request Flow |
17 | | - |
18 | | -1. Postfix sends socketmap query via TCP (format: `<netstring>MAP_NAME <SP> KEY`) |
19 | | -2. Socketmap server parses request and routes to appropriate handler in adapter |
20 | | -3. Adapter queries Userli REST API (`/api/postfix/{map_type}?query={key}`) |
21 | | -4. Response converted back to socketmap netstring format |
22 | | -5. Metrics updated for observability |
23 | | - |
24 | | -## Development Workflow |
| 7 | +- **Lookup Server** (`:10001`): Lookups for aliases, domains, mailboxes, senders |
| 8 | +- **Policy Server** (`:10003`): Rate limiting via Postfix SMTP Access Policy Delegation |
25 | 9 |
|
26 | | -### Local Setup |
| 10 | +## Architecture |
27 | 11 |
|
28 | | -```bash |
29 | | -# Copy environment template |
30 | | -cp .env.dist .env |
| 12 | +``` |
| 13 | +┌─────────┐ ┌──────────────────┐ ┌────────────┐ |
| 14 | +│ Postfix │────▶│ tcpserver.go │────▶│ Userli API │ |
| 15 | +└─────────┘ │ (shared infra) │ └────────────┘ |
| 16 | + ├──────────────────┤ |
| 17 | + │ lookup.go │ ← ConnectionHandler interface |
| 18 | + │ policy.go │ ← ConnectionHandler interface |
| 19 | + └──────────────────┘ |
| 20 | +``` |
31 | 21 |
|
32 | | -# Edit .env and set USERLI_TOKEN (required) |
33 | | -# Token can be created in Userli: Settings -> Api Tokens |
| 22 | +### Key Pattern: ConnectionHandler Interface |
34 | 23 |
|
35 | | -# Start full stack (adapter + postfix + userli + mariadb + mailcatcher) |
36 | | -docker-compose up |
| 24 | +Both servers implement `ConnectionHandler` from `tcpserver.go`: |
37 | 25 |
|
38 | | -# Adapter runs on :10001 (socketmap) and :10002 (metrics) |
| 26 | +```go |
| 27 | +type ConnectionHandler interface { |
| 28 | + HandleConnection(ctx context.Context, conn net.Conn) |
| 29 | +} |
39 | 30 | ``` |
40 | 31 |
|
41 | | -### Testing Postfix Integration |
| 32 | +`StartTCPServer()` provides shared infrastructure: connection pooling (semaphore), graceful shutdown, TCP keep-alive, metrics hooks. |
42 | 33 |
|
43 | | -```bash |
44 | | -# Test socketmap queries directly |
45 | | -echo -e "10:alias test" | nc localhost 10001 |
| 34 | +### File Structure |
46 | 35 |
|
47 | | -# Test via Postfix container |
48 | | -docker-compose exec postfix postmap -q "user@example.org" socketmap:inet:adapter:10001:alias |
49 | | -docker-compose exec postfix postmap -q "example.org" socketmap:inet:adapter:10001:domain |
50 | | -docker-compose exec postfix postmap -q "user@example.org" socketmap:inet:adapter:10001:mailbox |
51 | | -docker-compose exec postfix postmap -q "user@example.org" socketmap:inet:adapter:10001:senders |
52 | | - |
53 | | -# View caught test emails |
54 | | -open http://localhost:1080 # Mailcatcher web UI |
55 | | -``` |
| 36 | +| File | Purpose | |
| 37 | +| --------------- | -------------------------------------------------------------------- | |
| 38 | +| `tcpserver.go` | Shared TCP server with connection pooling, graceful shutdown | |
| 39 | +| `lookup.go` | Socketmap protocol + `LookupServer` (implements `ConnectionHandler`) | |
| 40 | +| `policy.go` | Policy protocol + `PolicyServer` + rate limit logic | |
| 41 | +| `ratelimit.go` | Sliding window rate limiter (in-memory, per-sender) | |
| 42 | +| `userli.go` | HTTP client for Userli API with Bearer auth | |
| 43 | +| `prometheus.go` | Metrics server + all metric definitions | |
| 44 | +| `config.go` | Environment variable configuration | |
56 | 45 |
|
57 | | -### Building & Testing |
| 46 | +## Development |
58 | 47 |
|
59 | 48 | ```bash |
60 | | -# Run tests with coverage |
61 | | -go test ./... |
| 49 | +cp .env.dist .env # Set USERLI_TOKEN |
| 50 | +docker-compose up # Full stack: adapter + postfix + userli + mariadb + mailcatcher |
62 | 51 |
|
63 | | -# Build binary |
64 | | -go build -o userli-postfix-adapter |
| 52 | +# Test lookup (via socketmap protocol) |
| 53 | +docker-compose exec postfix postmap -q "example.org" socketmap:inet:adapter:10001:domain |
65 | 54 |
|
66 | | -# Build Docker image |
67 | | -docker build -t systemli/userli-postfix-adapter . |
| 55 | +# Test policy (sends raw policy request) |
| 56 | +echo -e "request=smtpd_access_policy\nprotocol_state=END-OF-MESSAGE\nsender=test@example.org\n\n" | nc localhost 10003 |
68 | 57 | ``` |
69 | 58 |
|
70 | 59 | ## Code Conventions |
71 | 60 |
|
72 | | -### Configuration Pattern |
| 61 | +### Context Propagation |
73 | 62 |
|
74 | | -- Use environment variables exclusively (no config files) |
75 | | -- **Required**: `USERLI_TOKEN` - application will fatal if missing |
76 | | -- Defaults defined in `config.go:NewConfig()`: |
77 | | - - `USERLI_BASE_URL`: `http://localhost:8000` |
78 | | - - `SOCKETMAP_LISTEN_ADDR`: `:10001` |
79 | | - - `METRICS_LISTEN_ADDR`: `:10002` |
80 | | - - `LOG_LEVEL`: `info` |
81 | | - - `LOG_FORMAT`: `text` (or `json`) |
| 63 | +- Never store `context.Context` in structs - pass through function parameters |
| 64 | +- Use parent context for timeouts: `ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)` |
82 | 65 |
|
83 | 66 | ### Error Handling |
84 | 67 |
|
85 | | -- Use `logrus` for structured logging: `log.WithField("key", value).Error()` |
86 | | -- Socketmap protocol requires specific error responses: |
87 | | - - `"TEMP "` - temporary failure (HTTP errors, network issues) |
88 | | - - `"PERM "` - permanent failure (404 not found) |
89 | | - - `"NOTFOUND "` - valid query but no result |
90 | | - - `"OK <value>"` - successful lookup |
91 | | -- Fatal errors only for startup issues (missing token, port bind failures) |
92 | | -- Network/API errors are logged but return TEMP to Postfix for retry |
| 68 | +- **Socketmap responses**: `OK <data>`, `NOTFOUND`, `TEMP <msg>`, `PERM <msg>` |
| 69 | +- **Policy responses**: `action=DUNNO\n\n` (allow) or `action=REJECT <msg>\n\n` |
| 70 | +- **Fail-open**: API errors return DUNNO/allow, never block mail on failures |
93 | 71 |
|
94 | | -### Adapter Response Pattern |
| 72 | +### Metrics (prometheus.go) |
95 | 73 |
|
96 | | -The adapter in `adapter.go` follows this flow: |
| 74 | +- No PII in labels - aggregate counters only, no email addresses |
| 75 | +- Metrics defined as package-level vars, registered in `StartMetricsServer()` |
97 | 76 |
|
98 | | -```go |
99 | | -// 1. Parse socketmap request (map name and key) |
100 | | -// 2. Query Userli API: GET /api/postfix/{mapName}?query={key} |
101 | | -// 3. Parse JSON response structure: {"exists": bool, "result": string} |
102 | | -// 4. Return formatted response: "OK result" or "NOTFOUND " |
103 | | -``` |
| 77 | +### Testing |
104 | 78 |
|
105 | | -### Socketmap Protocol Implementation |
| 79 | +- Mocks generated via mockery (`.mockery.yml`) - regenerate with `mockery` |
| 80 | +- Use `context.Background()` in tests for handlers |
106 | 81 |
|
107 | | -- Request format: `<length>:<data>,` (netstring format) |
108 | | -- Data format: `<mapName> <key>` |
109 | | -- Responses must end with space and newline per Postfix spec |
110 | | -- See `server.go:handleConnection()` for full protocol details |
| 82 | +## Protocols |
111 | 83 |
|
112 | | -### Testing with Mocks |
| 84 | +### Socketmap (RFC-like netstring) |
113 | 85 |
|
114 | | -- Mock interfaces generated with `mockery` (see `mock_UserliService.go`) |
115 | | -- Test files follow `*_test.go` naming convention |
116 | | -- Use table-driven tests for multiple scenarios |
117 | | - |
118 | | -## Key Files |
| 86 | +``` |
| 87 | +Request: <len>:<mapname> <key>, e.g., "18:domain example.org," |
| 88 | +Response: <len>:<status> <data>, e.g., "4:OK 1," |
| 89 | +``` |
119 | 90 |
|
120 | | -- `main.go` - Entry point, initializes config and starts servers |
121 | | -- `server.go` - TCP server and socketmap protocol implementation |
122 | | -- `adapter.go` - Request routing and Userli API interaction |
123 | | -- `userli.go` - HTTP client with Bearer token authentication |
124 | | -- `config.go` - Environment-based configuration |
125 | | -- `prometheus.go` - Metrics instrumentation |
126 | | -- `docker-compose.yml` - Full test environment with Postfix, Userli, MariaDB, and Mailcatcher |
| 91 | +### Policy Delegation (Postfix SMTPD) |
127 | 92 |
|
128 | | -## External Dependencies |
| 93 | +``` |
| 94 | +Request: name=value\n pairs, empty line terminates |
| 95 | +Response: action=ACTION\n\n |
| 96 | +``` |
129 | 97 |
|
130 | | -- **Userli API**: REST endpoints at `/api/postfix/{alias,domain,mailbox,senders}?query={key}` |
131 | | - - Returns JSON: `{"exists": true/false, "result": "value"}` |
132 | | - - Requires Bearer token authentication |
133 | | -- **Postfix Configuration**: Uses `socketmap:inet:adapter:10001:{mapName}` in virtual\_\*\_maps directives |
134 | | -- **Prometheus**: Scrapes metrics from `:10002/metrics` |
| 98 | +Only process at `protocol_state=END-OF-MESSAGE` for accurate counting. |
135 | 99 |
|
136 | 100 | ## Common Pitfalls |
137 | 101 |
|
138 | | -- Forgetting to set `USERLI_TOKEN` in `.env` causes immediate startup failure |
139 | | -- Map names in Postfix config must exactly match: `alias`, `domain`, `mailbox`, `senders` |
140 | | -- Netstring format is strict: must include length prefix and comma suffix |
141 | | -- Empty API responses (exists=false) should return "NOTFOUND ", not an error |
142 | | -- All socketmap responses must end with space + newline for Postfix compatibility |
| 102 | +- `USERLI_TOKEN` is required - app exits immediately if missing |
| 103 | +- Rate limiter cleanup runs every 5 minutes in background goroutine |
| 104 | +- Map names must match exactly: `alias`, `domain`, `mailbox`, `senders` |
0 commit comments