GET /smtp, PUT /smtp, DELETE /smtp and POST /smtp/test
All four require manage-tenant. DELETE removes the tenant's own row,
so its mail falls back to the deployment's ODUDU_SMTP_* sender and then
the log-only adapter — the one way back from a configuration PUT can
only replace. 204 on success, 404 when there was nothing to remove. GET reports configured: false and
password_set: false for a tenant with no row, rather than 404 — the
endpoint always exists, it is the configuration that may not. PUT
replaces the whole configuration except the password, which GET never
hands back for a caller to resend: omitting password keeps the stored
one, and "password": null clears it. A kept password stays with the relay
and account it was entered for — omitting it while host, port or
username changes is refused with 400 naming password, since the next
test send would otherwise authenticate to whoever runs the new host. GET and PUT also report
effective — whose relay the tenant's mail actually goes through: tenant
for its own row, deployment for the deployment's ODUDU_SMTP_* sender,
none when there is neither and mail is only logged. The stored
password wraps through the same envelope a signing key's private half does
(wrapSecret/unwrapSecret, @odudu/crypto) — the column never carries
plaintext.
Two refusals bound what may be stored and what may be dialled.
A configuration that authenticates requires TLS. A username or a
password — sent, or kept from the stored row — with starttls anything
but true is 400: secure: false
with STARTTLS unenforced puts those credentials on the wire in cleartext
(CWE-319). The transport requires TLS whenever credentials are present
whatever the row says, so this refusal is what keeps the stored row honest
about what will happen, rather than being the only thing between a
password and the network.
The host is bounded before any connection is opened, by the rules ADR
0028 puts on a client-supplied jwks_uri: the addresses host resolves to
are checked in the numeric domain, and loopback, link-local, private,
unspecified, multicast, broadcast and the reserved ranges are refused with
400 naming the address and why. The connection is then opened to an
address that passed, never by resolving host a second time; the tenant's
hostname travels as the TLS server name, so certificate verification still
names the host they configured. Without it, POST /smtp/test is a port
scanner — a manage-tenant admin stores any host and port, and the
transport's own error answers back whether something is listening.
ODUDU_ALLOW_PRIVATE_SMTP_HOSTS re-admits the private ranges for a
deployment whose relay genuinely is internal, the same escape hatch
ODUDU_ALLOW_PRIVATE_CLIENT_URLS gives that fetcher; loopback and
link-local stay refused either way.
GET and PUT answer an ETag over the configuration as they answer it. PUT
honours If-Match when it is sent, taken under a lock on the row it replaces,
and refuses a stale one with 412; it does not require one, since a tenant with
no row has nothing another writer could have changed under it. In etags-demo,
which had no SMTP row — the GET, a PUT with an If-Match that matches
nothing, and the same PUT under the GET's own:
curl -sS -D - -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:3080/admin/tenants/etags-demo/smtp
curl -sS -D - -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-H 'If-Match: "0000"' \
-d '{"host":"smtp.example.test","port":587,"from_address":"noreply@etags.example"}' \
http://localhost:3080/admin/tenants/etags-demo/smtp
curl -sS -D - -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-H 'If-Match: "eb513a27e14a9d51bb5c40764b9054c6c4b4ff6de3ce14b3eb4cf4745589d891"' \
-d '{"host":"smtp.example.test","port":587,"from_address":"noreply@etags.example"}' \
http://localhost:3080/admin/tenants/etags-demo/smtp
HTTP/1.1 200 OK
etag: "eb513a27e14a9d51bb5c40764b9054c6c4b4ff6de3ce14b3eb4cf4745589d891"
{"configured":false,"host":null,"port":null,"from_address":null,"username":null,"password_set":false,"starttls":null,"effective":"none"}
HTTP/1.1 412 Precondition Failed
{"type":"about:blank","title":"Precondition Failed","status":412,"detail":"If-Match no longer matches","instance":"01a0ea60-0a48-75a8-a2e6-34274b488d20"}
HTTP/1.1 200 OK
etag: "f9ba93780f589e0cc3b6f2606f67d0d7b01c5a2ed5d04880f94052f64d4e1d80"
{"configured":true,"host":"smtp.example.test","port":587,"from_address":"noreply@etags.example","username":null,"password_set":false,"starttls":false,"effective":"tenant"}
Resolution order when this tenant's mail is actually sent: this row first, then
the deployment's own ODUDU_SMTP_* sender, then the log-only adapter. ADR 0015
is unaffected — it governs where a deployment's own credentials live, and this
is a credential the deployment itself never holds.
In the transcripts below demo has no SMTP row until this section's PUTs.
curl -sS -X PUT \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"host": "smtp.example.test", "port": 587, "from_address": "noreply@demo.example", "password": "hunter2"}' \
http://localhost:3080/admin/tenants/demo/smtp
GET before that PUT, then the PUT's own answer. The unconfigured read
is 200 with every field null, not 404; the PUT is refused, because
it carries a password and does not ask for TLS:
{"configured":false,"host":null,"port":null,"from_address":null,"username":null,"password_set":false,"starttls":null,"effective":"none"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"starttls must be true when a username or password is configured","errors":[{"path":"starttls","message":"must be true when a username or password is configured"}],"instance":"01a0ea60-0a9a-74fe-9c48-4efba484a5eb"}
The same body with "starttls": true, then GET again:
{"configured":true,"host":"smtp.example.test","port":587,"from_address":"noreply@demo.example","username":null,"password_set":true,"starttls":true,"effective":"tenant"}
{"configured":true,"host":"smtp.example.test","port":587,"from_address":"noreply@demo.example","username":null,"password_set":true,"starttls":true,"effective":"tenant"}
password_set is how the password is reported; the value itself is never
in any of these.
A kept password
In a tenant smtp-keep-demo created for it with no row: the GET; a PUT with
a password; a PUT changing only from_address and leaving password out,
which keeps it; the same with starttls off, refused for the password it would
keep; a PUT moving the host and leaving password out, refused naming
password; and a PUT sending "password": null without a username, which
clears it. None of these shows deployment: this server sets no
ODUDU_SMTP_HOST, so a tenant with no row is on none.
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:3080/admin/tenants/smtp-keep-demo/smtp
curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"host":"smtp.example.test","port":587,"from_address":"noreply@keep.example","username":"mailer","password":"hunter2","starttls":true}' \
http://localhost:3080/admin/tenants/smtp-keep-demo/smtp
curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"host":"smtp.example.test","port":587,"from_address":"alerts@keep.example","username":"mailer","starttls":true}' \
http://localhost:3080/admin/tenants/smtp-keep-demo/smtp
curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"host":"smtp.example.test","port":587,"from_address":"alerts@keep.example","username":"mailer","starttls":false}' \
http://localhost:3080/admin/tenants/smtp-keep-demo/smtp
curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"host":"relay.elsewhere.example","port":587,"from_address":"alerts@keep.example","username":"mailer","starttls":true}' \
http://localhost:3080/admin/tenants/smtp-keep-demo/smtp
curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"host":"smtp.example.test","port":587,"from_address":"alerts@keep.example","password":null}' \
http://localhost:3080/admin/tenants/smtp-keep-demo/smtp
{"configured":false,"host":null,"port":null,"from_address":null,"username":null,"password_set":false,"starttls":null,"effective":"none"}
{"configured":true,"host":"smtp.example.test","port":587,"from_address":"noreply@keep.example","username":"mailer","password_set":true,"starttls":true,"effective":"tenant"}
{"configured":true,"host":"smtp.example.test","port":587,"from_address":"alerts@keep.example","username":"mailer","password_set":true,"starttls":true,"effective":"tenant"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"starttls must be true when a username or password is configured","errors":[{"path":"starttls","message":"must be true when a username or password is configured"}],"instance":"01a0ea60-3e66-7f14-9ce6-838c942d3084"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"password must be sent again when host, port or username changes","errors":[{"path":"password","message":"must be sent again when host, port or username changes"}],"instance":"01a0ea60-3e7a-7ef9-a02e-531e6b7e4326"}
{"configured":true,"host":"smtp.example.test","port":587,"from_address":"alerts@keep.example","username":null,"password_set":false,"starttls":false,"effective":"tenant"}
POST /smtp/test sends one message to the given address synchronously and
reports the transport's own failure as 502, rather than an operator
discovering a bad configuration only when a user's verification mail
silently fails. That detail is still returned verbatim, now that the
destination check above has taken away what it was an oracle for: with only
public addresses reachable, the failure tells an operator about their own
relay rather than about this server's neighbourhood. 400 for a tenant
with no SMTP configuration at all, and 400 for a host this server will
not connect to.
curl -sS -X POST \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"to": "ops@demo.example"}' \
http://localhost:3080/admin/tenants/demo/smtp/test
Against demo before it had a row, then against the smtp.example.test
row the PUT above stored, then after its host was re-PUT as
127.0.0.1, with the password sent again as a changed host requires — the
probe this endpoint would otherwise be:
{"type":"about:blank","title":"Bad Request","status":400,"detail":"this tenant has no SMTP configuration","instance":"01a0ea60-0a74-7100-90b4-6e49dcb91251"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"this server will not connect to smtp.example.test: it resolves to no address","instance":"01a0ea60-0ad1-7952-b75a-bc336444b582"}
{"type":"about:blank","title":"Bad Request","status":400,"detail":"this server will not connect to 127.0.0.1: address 127.0.0.1 is a loopback address","instance":"01a0ea60-0b2b-7f39-b3aa-85c093b26882"}
No 502 is shown: this installation has no mail server and no host it is
willing to dial, so nothing here reaches a transport for one to be reported
from.
Every test send is recorded as tenant.smtp_test, whatever it came to:
allowed when the relay accepted the message, refused for no configuration or
a destination this server will not dial, failed for a transport error, with
detail.result naming which. The recipient is not recorded, and the password
never is. In a tenant smtp-audit-demo given a relay at 127.0.0.1 with a
password, then sent to ops@demo.example:
curl -sS -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" -d '{"to":"ops@demo.example"}' \
http://localhost:3082/admin/tenants/smtp-audit-demo/smtp/test | jq -c '{status,detail}'
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
"http://localhost:3082/admin/tenants/smtp-audit-demo/audit?action=tenant.smtp_test" \
| jq -c '.items[] | {action,outcome,resource_type,detail}'
{"status":400,"detail":"this server will not connect to 127.0.0.1: address 127.0.0.1 is a loopback address"}
{"action":"tenant.smtp_test","outcome":"refused","resource_type":"tenant","detail":{"result":"refused_destination"}}
DELETE, in a tenant smtp-delete-demo created for it with no row, then after
a PUT had stored one. 404 first, 204 second, and a GET afterwards
showing the tenant back on the deployment's own sender — none here, since this
installation has none:
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3080/admin/tenants/smtp-delete-demo/smtp
curl -sS -X PUT -H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"host": "smtp.example.test", "port": 587, "from_address": "noreply@demo.example"}' \
http://localhost:3080/admin/tenants/smtp-delete-demo/smtp
curl -sS -D - -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3080/admin/tenants/smtp-delete-demo/smtp
curl -sS -H "Authorization: Bearer $ADMIN_TOKEN" \
http://localhost:3080/admin/tenants/smtp-delete-demo/smtp
HTTP/1.1 404 Not Found
x-request-id: 01a0ea60-3ea1-7d44-8d33-cb1d7be0cf91
cache-control: no-store
content-type: application/problem+json; charset=utf-8
content-length: 154
Date: Mon, 28 Sep 2026 23:36:11 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"type":"about:blank","title":"Not Found","status":404,"detail":"this tenant has no SMTP configuration","instance":"01a0ea60-3ea1-7d44-8d33-cb1d7be0cf91"}
{"configured":true,"host":"smtp.example.test","port":587,"from_address":"noreply@demo.example","username":null,"password_set":false,"starttls":false,"effective":"tenant"}
HTTP/1.1 204 No Content
x-request-id: 01a0ea60-3ecb-7f6f-9a7d-400d877a394e
cache-control: no-store
Date: Mon, 28 Sep 2026 23:36:11 GMT
Connection: keep-alive
Keep-Alive: timeout=72
{"configured":false,"host":null,"port":null,"from_address":null,"username":null,"password_set":false,"starttls":null,"effective":"none"}