http-smtp-rele 0.13.0

Minimal, secure HTTP-to-SMTP submission relay
Documentation
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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
# Architecture

## 1. System Architecture

The relay is an HTTP submission gate between application clients and a local SMTP server.
TLS termination and external access control are delegated to the reverse proxy layer.

```mermaid
flowchart LR
    Client["External Client<br/>HTTP-only sender"]
    Internet["Internet / External Network"]
    RP["Reverse Proxy / TLS Endpoint<br/>relayd / httpd / nginx / Caddy"]

    subgraph Host["Mail Server Host / OpenBSD Host"]
        Rele["http-smtp-rele<br/>Rust / Axum / Tokio"]
        SMTP["OpenSMTPD / SMTP Server<br/>localhost:25"]
        Queue["SMTP Queue<br/>retry / delivery lifecycle"]
        Rspamd["Rspamd / Mail Filters<br/>(optional existing stack)"]
        Dovecot["Dovecot / Mailbox Stack<br/>(out of scope for sending API)"]
    end

    Recipient["Recipient Mail Server"]

    Client -->|"HTTPS POST /v1/send"| Internet
    Internet --> RP
    RP -->|"HTTP localhost<br/>127.0.0.1:8080"| Rele
    Rele -->|"SMTP submit<br/>127.0.0.1:25"| SMTP
    SMTP --> Rspamd
    SMTP --> Queue
    Queue -->|"SMTP delivery"| Recipient
    SMTP -. "existing mail stack" .-> Dovecot
```

`http-smtp-rele` is not a replacement for OpenSMTPD — it adds an authenticated, validated
HTTP submission path to an existing SMTP infrastructure. Queue management and delivery retry
remain with the MTA.

---

## 2. Runtime Component Architecture

```mermaid
flowchart TB
    subgraph Runtime["http-smtp-rele Runtime"]
        Router["API Router<br/>Axum routes"]
        Context["Request Context<br/>request_id / client_ip / key_id"]
        BodyLimit["Body Limit<br/>max_request_body_bytes"]
        Access["Access Control<br/>source CIDR / trusted proxy"]
        Auth["Authentication<br/>Bearer token / API key"]
        Rate["Rate Limit<br/>global / IP / key"]
        Validate["Validation<br/>JSON / address / size / policy"]
        Sanitize["Sanitization<br/>CRLF rejection / control chars"]
        MailBuild["Mail Builder<br/>plain text lettre::Message"]
        SmtpTransport["SMTP Transport<br/>lettre SmtpTransport"]
        ErrorMap["Error Mapping<br/>AppError -> JSON response"]
        Audit["Audit Logging<br/>tracing / redaction"]
    end

    subgraph ConfigArea["Loaded Configuration"]
        Config["AppConfig"]
        ServerCfg["ServerConfig"]
        SecCfg["SecurityConfig"]
        MailCfg["MailConfig"]
        SmtpCfg["SmtpConfig"]
        KeyCfg["ApiKeyConfig[]"]
        RateCfg["RateLimitConfig"]
    end

    Request["HTTP Request"] --> Router
    Router --> Context
    Context --> BodyLimit
    BodyLimit --> Access
    Access --> Auth
    Auth --> Rate
    Rate --> Validate
    Validate --> Sanitize
    Sanitize --> MailBuild
    MailBuild --> SmtpTransport
    SmtpTransport --> Response["HTTP JSON Response"]
    ErrorMap --> Response

    Config --> ServerCfg & SecCfg & MailCfg & SmtpCfg & KeyCfg & RateCfg
    ServerCfg --> Router
    SecCfg --> Access
    KeyCfg --> Auth
    RateCfg --> Rate
    MailCfg --> Validate & MailBuild
    SmtpCfg --> SmtpTransport
    Context --> Audit
    Auth & Rate & Validate & SmtpTransport & ErrorMap --> Audit
```

> **Implementation note:** In the current codebase, the "Access Control" step (source CIDR
> allowlist) and "Authentication" are combined inside the `AuthContext` Axum extractor
> (`src/auth.rs`), not as separate Tower middleware layers. The diagram shows the logical
> responsibility split; the physical split is auth extractor handles both.

---

## 3. Security Boundary Architecture

```mermaid
flowchart LR
    subgraph Untrusted["Untrusted Zone"]
        Client["External Client"]
        SpoofedHeaders["Potentially spoofed headers<br/>X-Forwarded-For etc."]
    end

    subgraph Edge["Edge / Reverse Proxy Zone"]
        TLS["TLS Termination"]
        ProxyAccess["Optional IP Allowlist<br/>mTLS / method limit"]
        Forwarded["Trusted Forwarded Headers"]
    end

    subgraph AppBoundary["http-smtp-rele Trust Boundary"]
        ResolveIP["Client IP Resolution"]
        Allowlist["Source CIDR Allowlist"]
        Auth["API Key Authentication"]
        Limit["Rate Limit"]
        Validate["Strict Validation"]
        Reject["Reject Unsafe Input"]
        Build["Safe Message Construction"]
    end

    subgraph LocalTrusted["Local Trusted Mail Zone"]
        SMTP["OpenSMTPD localhost:25"]
        Queue["SMTP Queue"]
    end

    Client --> TLS
    SpoofedHeaders -. "ignored unless proxy is trusted" .-> ResolveIP
    TLS --> ProxyAccess --> Forwarded --> ResolveIP
    ResolveIP --> Allowlist --> Auth --> Limit --> Validate
    Validate --> Reject
    Validate --> Build --> SMTP --> Queue
    Reject --> Error["Safe JSON Error<br/>no secret / no body"]
```

`X-Forwarded-For` is trusted only when the socket peer IP is in `security.trusted_source_cidrs`.

---

## 4. OpenBSD Hardening Architecture

```mermaid
flowchart TB
    subgraph OS["OpenBSD Host"]
        User["_http_smtp_rele<br/>non-root user"]

        subgraph App["http-smtp-rele process"]
            Startup["Startup Phase<br/>read config / bind socket"]
            Runtime["Runtime Phase<br/>serve HTTP / submit SMTP"]
            Pledge["pledge(stdio inet)<br/>runtime syscall restriction"]
            Unveil["unveil(NULL NULL)<br/>filesystem visibility restriction"]
        end

        Config["/etc/http-smtp-rele.toml<br/>read at startup"]
        Binary["/usr/local/bin/http-smtp-rele"]
        SMTP["127.0.0.1:25<br/>OpenSMTPD"]
        RcD["/etc/rc.d/http_smtp_rele<br/>rcctl integration"]
    end

    RcD --> User --> Startup
    Startup --> Config & Binary & Unveil & Pledge
    Pledge --> Runtime
    Unveil --> Runtime
    Runtime --> SMTP
```

`unveil` is applied before `pledge`. After `unveil(NULL, NULL)`, no filesystem access is
possible. The config is fully loaded before this point. `smtp.host` must be an IP address
(`127.0.0.1`), not a hostname, because the `dns` pledge promise is not included.

---

## 5. Request Processing Flow

```mermaid
sequenceDiagram
    autonumber
    participant C as External Client
    participant P as Reverse Proxy
    participant A as http-smtp-rele
    participant S as SMTP / OpenSMTPD
    participant L as Audit Log

    C->>P: HTTPS POST /v1/send
    P->>A: HTTP POST /v1/send

    A->>A: Generate request_id
    A->>A: Check Content-Type
    A->>A: Enforce body size limit
    A->>A: Resolve client IP
    A->>A: Check source allowlist
    A->>A: Authenticate API key
    A->>A: Apply global/IP/key rate limits
    A->>A: Parse strict JSON
    A->>A: Validate fields
    A->>A: Reject CR/LF in header-bound fields
    A->>A: Build plain text mail message

    A->>S: SMTP submit
    alt SMTP accepted
        S-->>A: Accepted
        A->>L: event=smtp_submitted
        A-->>C: 202 Accepted + request_id
    else SMTP unavailable/rejected
        S-->>A: Error
        A->>L: event=smtp_failure
        A-->>C: 502 JSON error + request_id
    end
```

`request_id` is generated at step 1 and included in all subsequent log events, the success
response, and all error responses.

---

## 6. Domain Concept Model

Reflects the confirmed MVP schema agreed with the architect.

```mermaid
classDiagram
    class AppConfig {
        ServerConfig server
        SecurityConfig security
        RateLimitConfig rate_limit
        MailConfig mail
        SmtpConfig smtp
        LoggingConfig logging
    }

    class ServerConfig {
        String bind_address
        usize max_request_body_bytes
        u64 request_timeout_seconds
        u64 shutdown_timeout_seconds
    }

    class SecurityConfig {
        bool require_auth
        bool trust_proxy_headers
        CIDR[] trusted_source_cidrs
        CIDR[] allowed_source_cidrs
        ApiKeyConfig[] api_keys
    }

    class RateLimitConfig {
        u32 global_per_min
        u32 per_ip_per_min
        u32 burst_size
    }

    class MailConfig {
        String default_from
        String default_from_name
        Domain[] allowed_recipient_domains
        usize max_subject_chars
        usize max_body_bytes
    }

    class SmtpConfig {
        String mode
        String host
        u16 port
        u64 connect_timeout_seconds
        u64 submission_timeout_seconds
    }

    class ApiKeyConfig {
        String id
        SecretString secret
        bool enabled
        Domain[] allowed_recipient_domains
        u32 rate_limit_per_min
    }

    class RequestContext {
        RequestId request_id
        IpAddr client_ip
        String key_id
        Instant started_at
    }

    class MailRequest {
        String to
        String subject
        String body
        String from_name
        String reply_to
        Object metadata
    }

    class ValidatedMailRequest {
        String to
        String subject
        String body
        String from_name
        String reply_to
        String client_request_id
    }

    AppConfig *-- ServerConfig
    AppConfig *-- SecurityConfig
    AppConfig *-- RateLimitConfig
    AppConfig *-- MailConfig
    AppConfig *-- SmtpConfig
    AppConfig *-- ApiKeyConfig

    ApiKeyConfig --> RequestContext : id is emitted as key_id in logs
    note for LoggingConfig "format, level, mask_recipient"
    MailRequest --> ValidatedMailRequest : validate_mail_request()
    ValidatedMailRequest --> "lettre::Message" : mail::build_message()
    "lettre::Message" --> "202 Accepted" : smtp::submit()
```

**`trusted_source_cidrs` vs. `allowed_source_cidrs`:**
- `trusted_source_cidrs` — CIDRs whose `X-Forwarded-For` headers may be trusted for client
  IP resolution. Applies only when `trust_proxy_headers = true`.
- `allowed_source_cidrs` — CIDRs from which connections are permitted at all (empty = allow
  all source IPs). Applied after IP resolution; independent of proxy header trust.

**`id` vs. `key_id`:**
In TOML the field is `id` (scoped under `[[api_keys]]`). In logs and `RequestContext` the
same value is emitted as `key_id` to avoid ambiguity in log output.

**Conceptual types in `ValidatedMailRequest`:**
Fields are `String` in the implementation, with type safety enforced by a private constructor
in `validation.rs`. `ValidatedMailRequest` can only be produced by `validate_mail_request()`.

**`smtp.mode`:**
Only `"smtp"` is supported in MVP. `"pipe"` is a reserved value that causes immediate startup
failure (RFC 064 deferred). Do not use `"pipe"` until a future RFC implements it.

---

## 7. Data Lifecycle State Machine

```mermaid
stateDiagram-v2
    [*] --> RawHttpRequest

    RawHttpRequest --> Rejected: body too large / bad content-type
    RawHttpRequest --> AuthenticatedRequest: source allowed + auth ok

    AuthenticatedRequest --> Rejected: auth failed / access denied / rate limited
    AuthenticatedRequest --> ParsedMailRequest: JSON parsed

    ParsedMailRequest --> Rejected: invalid JSON / unknown fields
    ParsedMailRequest --> ValidatedMailRequest: validation ok

    ValidatedMailRequest --> Rejected: invalid address / CRLF / policy denied
    ValidatedMailRequest --> MailMessage: safe mail construction

    MailMessage --> SmtpAccepted: SMTP accepted
    MailMessage --> SmtpFailed: SMTP unavailable / rejected / timeout

    SmtpAccepted --> AcceptedResponse: 202 Accepted
    SmtpFailed --> ErrorResponse: 502 Error
    Rejected --> ErrorResponse: 4xx Error

    AcceptedResponse --> [*]
    ErrorResponse --> [*]
```

The system is **fail-closed**: any unsafe, unknown, unauthenticated, over-limit, or invalid
condition stops processing before SMTP contact.

---

## 8. RFC Lifecycle

```mermaid
flowchart LR
    subgraph RFCRepo["rfcs/"]
        README["README.md<br/>RFC Index"]
        Proposed["proposed/<br/>review target"]
        Done["done/<br/>implemented record"]
        Archive["archive/<br/>withdrawn / superseded"]
    end

    Plan["Development Plan<br/>M0–M12"]
    RFC["RFC NNN<br/>design + impl plan + test plan"]
    Impl["Implementation"]
    Tests["Tests<br/>unit / integration / security"]
    Release["Release / main"]

    Plan --> RFC --> Proposed --> Impl --> Tests --> Release --> Done
    Proposed --> Archive

    Archive --> README
    Done --> README
    Proposed --> README

    README -. "integrity check (scripts/check-rfcs.sh)" .-> Proposed & Done & Archive
```

---

## 9. Test Architecture

```mermaid
flowchart TB
    subgraph TestTargets["Test Targets"]
        Config["Config Parser / Validator"]
        Auth["Auth / Access Control"]
        Validation["Validation / Sanitization"]
        Mail["Mail Builder"]
        SMTP["SMTP Transport"]
        Rate["Rate Limiter"]
        Logs["Audit Logging"]
        API["HTTP API"]
        OpenBSD["OpenBSD Hardening"]
    end

    subgraph TestTypes["Test Types"]
        Unit["Unit Tests<br/>(src/**/#[cfg(test)])"]
        APIIT["API Integration Tests<br/>(v0.2 — tests/)"]
        SMTPIT["SMTP Integration Tests<br/>Fake SMTP (v0.2)"]
        Sec["Security Regression Tests<br/>SEC-001–017"]
        Platform["Platform / Manual Tests<br/>(OpenBSD only)"]
    end

    Unit --> Config & Auth & Validation & Rate & Mail & Logs
    APIIT --> API & Auth & Validation & Rate
    SMTPIT --> SMTP & Mail
    Sec --> Auth & Validation & Logs & API
    Platform --> OpenBSD
```

Security regression tests (`SEC-001` through `SEC-017`) are permanent fixtures — they cover
every named security control. See [testing.md](testing.md) for the full list.

---

## Module Map

```
src/
├── main.rs          — CLI arg parsing; startup sequence
├── lib.rs           — AppState; module declarations
│
├── config.rs        — TOML config schema, loading, fail-fast validation
├── error.rs         — AppError enum with IntoResponse; all HTTP error mapping
├── logging.rs       — tracing-subscriber initialization
├── security.rs      — OpenBSD pledge/unveil wrappers (no-op on other platforms)
│
├── auth.rs          — API key extraction, constant-time comparison, AuthContext extractor
├── policy.rs        — Recipient domain policy lookup helpers
├── sanitize.rs      — CR/LF detection (contains_header_injection)
├── validation.rs    — validate_mail_request → ValidatedMailRequest
│
├── rate_limit.rs    — Three-tier token bucket rate limiter
├── mail.rs          — build_message (lettre typed builder, never raw strings)
├── smtp.rs          — SMTP transport init, submit, TCP probe
│
├── api/
│   ├── mod.rs       — Router construction, Tower middleware layers
│   ├── send.rs      — POST /v1/send handler; per-key rate limit; pipeline wiring
│   └── health.rs    — GET /healthz and /readyz handlers
│
└── tests.rs         — Integration test stubs (expanded in v0.2)
```


---

## Design Review Notes

All discrepancies between the architect's initial diagrams and the implementation have been
resolved. The following table is the final record of each decision.

| ID | Item | Resolution |
|----|------|-----------|
| D-01 | CIDR placement | `trusted_source_cidrs` and `allowed_source_cidrs` both live in `[security]`. `[server]` has no CIDR fields. |
| D-02 | `concurrency_limit` | Deferred to v0.2. Not in MVP schema. |
| D-03 | `reject_raw_headers`, `allow_multiple_recipients` | Not config fields. Header rejection is always-on (RFC 051); multi-recipient deferred (RFC 064 scope). |
| D-04 | Proxy header flag name | `trust_proxy_headers` confirmed. |
| D-05 | Rate limit burst granularity | Shared `burst_size` confirmed for MVP. Per-tier burst deferred to v0.2. |
| D-06 | Key identifier naming | TOML field: `id`. Log/context field: `key_id`. Both correct. |
| D-07 | Per-address recipient allowlist | Deferred to v0.2. MVP: domain-level only. |
| D-08 | Per-key burst override | Deferred with D-05. |
| D-09 | `SmtpMode` enum | `String` field confirmed. `"pipe"` fails at startup (RFC 064). |
| D-10 | Access Control / Auth split | Logical separation correct in diagram. Physical implementation combines them in `AuthContext` extractor. |
| D-11 | `ValidatedMailRequest` types | `String` fields with private constructor confirmed (RFC 050). |