This is a postfix socketmap adapter for the userli project. It implements the socketmap protocol to provide dynamic lookups for aliases, domains, mailboxes, and senders. It also provides a Dovecot SASL authentication server, allowing Postfix to authenticate SMTP clients against the Userli API without requiring Dovecot.
The adapter is configured via environment variables:
USERLI_TOKEN: The token to authenticate against the userli API.USERLI_BASE_URL: The base URL of the userli API.POSTFIX_RECIPIENT_DELIMITER: The recipient delimiter used in Postfix (e.g.,+). Default: empty.SOCKETMAP_LISTEN_ADDR: The address to listen on for socketmap requests. Default::10001.POLICY_LISTEN_ADDR: The address to listen on for policy requests (rate limiting). Default::10003.SASL_LISTEN_ADDR: The address to listen on for Dovecot SASL authentication. Supports TCP (e.g.,:10006) and UNIX sockets (e.g.,/var/spool/postfix/private/auth). Default: empty (disabled).SASL_SOCKET_MODE: Octal permission mode of the SASL UNIX socket (e.g.0660). Default:0666.SASL_REQUIRE_TLS: Reject SASL authentication in SMTP sessions without TLS (like Dovecot'sdisable_plaintext_auth). Default:false.METRICS_LISTEN_ADDR: The address to listen on for metrics. Default::10002.RATE_LIMIT_MESSAGE: The rejection message returned when a sender exceeds their quota. Default:Rate limit exceeded, please try again later.REDIS_URL: Connection URL for Redis (required). Format followsredis.ParseURL, e.g.redis://[user:password@]host:port/db. Rate-limit state is stored in Redis so it survives restarts.LOOKUP_CACHE_TTL: TTL for cached successful (OK) lookup responses in Redis. Accepts any Go duration (e.g.300s,5m). Default:300s. Set to0to disable the cache.NOTFOUND/TEMP/PERMresponses are never cached.
In Postfix, you can configure the adapter using the socketmap protocol like this:
virtual_alias_maps = socketmap:inet:localhost:10001:alias
virtual_mailbox_domains = socketmap:inet:localhost:10001:domain
virtual_mailbox_maps = socketmap:inet:localhost:10001:mailbox
smtpd_sender_login_maps = socketmap:inet:localhost:10001:senders
The adapter also provides a Postfix SMTP Access Policy Delegation server for outgoing mail. It queries the Userli API for the quota and sending permission of the authenticated sender and enforces them.
Configure in Postfix main.cf:
smtpd_recipient_restrictions =
check_policy_service inet:localhost:10003,
permit_sasl_authenticated,
reject_unauth_destination
smtpd_end_of_data_restrictions = check_policy_service inet:localhost:10003
The policy service must come before permit_sasl_authenticated, otherwise it is never asked for authenticated senders.
The Userli API endpoint /api/postfix/smtp_quota/{email} returns:
{
"per_hour": 100,
"per_day": 1000,
"sending_allowed": true,
"allowed_recipients": []
}If sending_allowed is false, the sender may only send to the addresses in allowed_recipients. All other recipients are rejected. Userli decides when this applies, e.g. for accounts marked as compromised, so that these accounts can still reach support.
The permission is checked for every recipient (RCPT stage) of SASL-authenticated clients. Mail without sasl_username is not checked. Recipients are compared case-insensitively and without the recipient delimiter tag (POSTFIX_RECIPIENT_DELIMITER). Responses without sending_allowed (older Userli versions) allow sending.
Important: This check is fail-closed. If the API is unreachable or answers with an error, recipients are deferred with a temporary error (450), so that mail clients retry later.
The limits are checked at the END-OF-MESSAGE stage. 0 means unlimited. If the API is unreachable, messages are allowed (fail-open).
Rate-limit state is persisted to Redis (REDIS_URL) so it survives restarts. If Redis is unreachable, messages are also allowed (fail-open) and the userli_postfix_adapter_ratelimit_backend_errors_total counter is incremented.
The adapter implements the Dovecot SASL authentication protocol, so Postfix can authenticate SMTP clients via smtpd_sasl_type=dovecot without requiring Dovecot itself. Authentication is performed against the Userli API (/api/postfix/auth). Supported mechanisms: PLAIN and LOGIN.
Important: SASL authentication is fail-closed. If the API is unreachable or slow (requests time out after 5 seconds), authentication is rejected with a temporary error (454), so mail clients retry instead of asking for a new password.
Rejected credentials are always reported as authentication failed, without the reason from the Userli API (e.g. a required password change). Postfix passes the reason to the SMTP client, and the API reports account states before checking the password. The reason is logged by the adapter.
Configure in Postfix main.cf (TCP):
smtpd_sasl_type = dovecot
smtpd_sasl_path = inet:localhost:10006
smtpd_sasl_auth_enable = yes
Or using a UNIX socket (set SASL_LISTEN_ADDR=/var/spool/postfix/private/auth):
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_auth_enable = yes
Security considerations:
- The SASL socket answers any client that can reach it, so it can be used to test passwords without going through Postfix (and its TLS and client restrictions). Bind TCP listeners to
localhostor a private network only, and keep UNIX sockets inside Postfix'private/directory or restrict them withSASL_SOCKET_MODE. - The adapter announces PLAIN and LOGIN as plaintext mechanisms. Set
smtpd_tls_auth_only = yesso Postfix offers AUTH only after STARTTLS. As a second line of defense, setSASL_REQUIRE_TLS=trueto reject AUTH requests that Postfix marks as coming from a session without TLS.
Brute-force protection:
The adapter does not delay or throttle failed authentications itself (unlike Dovecot's auth_failure_delay). Handle this in Postfix and at the firewall, which also covers other services and drops attackers before they reach the Userli API:
# master.cf: on the services with SASL enabled (submission, submissions)
submission inet n - n - - smtpd
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_auth_only=yes
-o smtpd_client_auth_rate_limit=10
-o smtpd_error_sleep_time=1s
-o smtpd_soft_error_limit=3
-o smtpd_hard_error_limit=5
smtpd_client_auth_rate_limit: maximum AUTH commands per client IP peranvil_rate_time_unit(default60s). Postfix default is0(unlimited).smtpd_error_sleep_time/smtpd_soft_error_limit: failed AUTHs count as errors; past the soft limit every response is delayed.smtpd_hard_error_limit: disconnect the client after this many errors.
Postfix logs every failed authentication with the client IP:
warning: unknown[192.0.2.1]: SASL LOGIN authentication failed: authentication failed
Ban offending clients based on this line, e.g. with the fail2ban postfix-sasl jail or the CrowdSec crowdsecurity/postfix collection (SASL failures feed the crowdsecurity/postfix-spam scenario). Monitor userli_postfix_adapter_sasl_auth_total{result="invalid_credentials"} to spot distributed attacks that stay below per-IP limits.
You can run the adapter using Docker.
A docker-compose.yml file is provided for convenience.
docker compose up -d
# Create the database and load fixtures
docker compose exec userli bin/console doctrine:schema:create
docker compose exec userli bin/console doctrine:fixtures:load --no-debug
docker compose exec postfix postmap -q "example.org" socketmap:inet:adapter:10001:domainThe socketmap names supported are:
alias- For virtual alias lookupsdomain- For virtual domain lookupsmailbox- For virtual mailbox lookupssenders- For sender login map lookups
You can test the socketmap adapter using postmap:
# Test alias lookup
postmap -q "test@example.com" socketmap:inet:localhost:10001:alias
# Test domain lookup
postmap -q "example.com" socketmap:inet:localhost:10001:domain
# Test mailbox lookup
postmap -q "user@example.com" socketmap:inet:localhost:10001:mailbox
# Test sender lookup
postmap -q "sender@example.com" socketmap:inet:localhost:10001:senders# Build and run with docker-compose
docker-compose up --build
# Or build manually
docker build -t userli-postfix-adapter .
docker run -e USERLI_TOKEN=your_token -e USERLI_BASE_URL=http://your-userli-instance -p 10001:10001 -p 10002:10002 userli-postfix-adapterThe adapter implements the Postfix socketmap protocol using netstrings for encoding. Each request and response is formatted as:
[length]:[data],
Where:
lengthis the decimal length of the data:is a delimiterdatais the actual request or response content,is the terminating comma
[length]:[mapname key],
Examples:
22:alias test@example.com,- Look up alias for test@example.com18:domain example.com,- Check if domain example.com exists23:mailbox user@example.com,- Check if mailbox user@example.com exists24:senders user@example.com,- Get senders for user@example.com
The adapter returns one of these response types:
OK [data]- Successful lookup with dataNOTFOUND- No data found for the keyTEMP [reason]- Temporary error (retry later)PERM [reason]- Permanent error (don't retry)
Examples:
19:OK dest@example.com,- Alias found, destination is dest@example.com4:OK 1,- Domain/mailbox exists8:NOTFOUND,- No data found20:TEMP Service error,- Temporary service error
The adapter exposes Prometheus metrics on /metrics (port 10002) and provides health check endpoints.
Socketmap Metrics:
userli_postfix_adapter_request_duration_seconds- Request duration histogramuserli_postfix_adapter_requests_total- Total request counteruserli_postfix_adapter_active_connections- Active connections gaugeuserli_postfix_adapter_connection_pool_usage- Connection pool usage (0-500)
HTTP Client Metrics:
userli_postfix_adapter_http_client_duration_seconds- Userli API request durationuserli_postfix_adapter_http_client_requests_total- Userli API request counter
Health:
userli_postfix_adapter_health_check_status- Userli API health (1=healthy, 0=unhealthy), checked in background every 15s, independent of/ready
Policy/Rate Limiting Metrics:
userli_postfix_adapter_policy_active_connections- Active policy connections gaugeuserli_postfix_adapter_policy_requests_total- Total policy request counteruserli_postfix_adapter_policy_request_duration_seconds- Policy request duration histogramuserli_postfix_adapter_quota_exceeded_total- Total messages rejected due to quotauserli_postfix_adapter_sending_denied_total- Total recipients rejected because the sender may not send to themuserli_postfix_adapter_quota_checks_total- Total quota checks performeduserli_postfix_adapter_ratelimit_backend_errors_total- Total Redis errors hit by the rate limiter, labelled byoperation
SASL Authentication Metrics:
userli_postfix_adapter_sasl_active_connections- Active SASL connections gaugeuserli_postfix_adapter_sasl_auth_total- Total SASL authentication attempts (by mechanism and result)userli_postfix_adapter_sasl_auth_duration_seconds- SASL authentication duration histogramuserli_postfix_adapter_sasl_connection_pool_full_total- SASL connection pool rejections
All metrics include relevant labels (handler, status, endpoint, etc.).
/health- Liveness probe (always returns 200 OK)/ready- Readiness probe (checks Userli API connectivity)
Example Kubernetes configuration:
livenessProbe:
httpGet:
path: /health
port: 10002
readinessProbe:
httpGet:
path: /ready
port: 10002