diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 10cafd6a6f5b0fb9f21366b850bf944b74926929..5027411531fe4fdb3bf1a3f280e6ce1fb1e1a942 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -14,7 +14,7 @@ before_script:
 
 build:apt:
   script:
-  - ./debian/create_changelog.py uffd-socketmap > debian/changelog
+  - ./debian/create_changelog.py uffd-socketmapd > debian/changelog
   - dpkg-buildpackage -us -uc
   - mv ../*.deb ./
   - dpkg-deb -I *.deb
@@ -23,14 +23,29 @@ build:apt:
     paths:
     - ./*.deb
 
+linter:buster:
+  image: registry.git.cccv.de/uffd/docker-images/buster
+  stage: test
+  script:
+  - pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages!
+  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter 'uffd-socketmapd' > codeclimate.json
+  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter 'uffd-socketmapd' > pylint.html
+  - python3 -m pylint --rcfile .pylintrc --output-format=text 'uffd-socketmapd'
+  artifacts:
+    when: always
+    paths:
+    - pylint.html
+    reports:
+      codequality: codeclimate.json
+
 linter:bullseye:
   image: registry.git.cccv.de/uffd/docker-images/bullseye
   stage: test
   script:
   - pip3 install $PYLINT_PIN pylint-gitlab pylint-flask-sqlalchemy # this force-updates jinja2 and some other packages!
-  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter 'server.py' > codeclimate.json
-  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter 'server.py' > pylint.html
-  - python3 -m pylint --rcfile .pylintrc --output-format=text 'server.py'
+  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabCodeClimateReporter 'uffd-socketmapd' > codeclimate.json
+  - python3 -m pylint --exit-zero --rcfile .pylintrc --output-format=pylint_gitlab.GitlabPagesHtmlReporter 'uffd-socketmapd' > pylint.html
+  - python3 -m pylint --rcfile .pylintrc --output-format=text 'uffd-socketmapd'
   artifacts:
     when: always
     paths:
@@ -42,7 +57,7 @@ unittests:buster:
   image: registry.git.cccv.de/uffd/docker-images/buster
   stage: test
   script:
-  - python3-coverage run --include 'server.py' -m pytest --junitxml=report.xml || true
+  - python3-coverage run --include 'uffd-socketmapd' -m pytest --junitxml=report.xml || true
   #- python3-coverage report -m
   - python3-coverage html
   #- python3-coverage xml
@@ -61,7 +76,7 @@ unittests:bullseye:
   image: registry.git.cccv.de/uffd/docker-images/bullseye
   stage: test
   script:
-  - python3-coverage run --include 'server.py' -m pytest --junitxml=report.xml || true
+  - python3-coverage run --include 'uffd-socketmapd' -m pytest --junitxml=report.xml || true
   - python3-coverage report -m
   - python3-coverage html
   - python3-coverage xml
diff --git a/.pylintrc b/.pylintrc
index 8507faa3cbdd28f2ce2cd70cae06c6286d36abc0..1112222e5eca1847406325a2fb6588ad31c56766 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -63,7 +63,8 @@ confidence=
 disable=missing-module-docstring,
         missing-class-docstring,
         missing-function-docstring,
-        too-few-public-methods
+        too-few-public-methods,
+        invalid-name,
 
 # Enable the message, report, category or checker with the given id(s). You can
 # either give multiple identifier separated by comma (,) or put this option
diff --git a/LICENSE b/LICENSE
index 917d1d79215f2897eb10f47ac3cce1ac9d9db7a3..eb63dc281588cb4a92456cd2a713f1a81f8609a4 100644
--- a/LICENSE
+++ b/LICENSE
@@ -629,7 +629,7 @@ to attach them to the start of each source file to most effectively
 state the exclusion of warranty; and each file should have at least
 the "copyright" line and a pointer to where the full notice is found.
 
-    socketmap-proxy
+    uffd-socketmapd
     Copyright (C) 2021  Julian Rother
 
     This program is free software: you can redistribute it and/or modify
diff --git a/README.md b/README.md
index 670f8e3576983b81fa31eb59cfd69459dfe77c9a..40d478b76a5a2850ebce911255e30d365caceff2 100644
--- a/README.md
+++ b/README.md
@@ -3,10 +3,10 @@ Socketmap server to integrate uffd mail aliases with postfix
 
 [uffd](https://git.cccv.de/uffd/uffd) has features that rely on mail aliases.
 To make those mail aliases work, it provides an API to lookup alias addresses
-for a given address. uffd-socketmap uses this API to integrate alias lookup
+for a given address. uffd-socketmapd uses this API to integrate alias lookup
 with MTAs that support the socketmap protocol, like sendmail and postfix.
 
-uffd-socketmap can be run manually. For production deployments, use the
+uffd-socketmapd can be run manually. For production deployments, use the
 provided debian packages. Add our package mirror to `/etc/sources.list`:
 
 ```
@@ -14,16 +14,16 @@ deb https://packages.cccv.de/uffd bullseye main
 ```
 Then download [cccv-archive-key.gpg](cccv-archive-key.gpg) and add it to
 the trusted repository keys in `/etc/apt/trusted.gpg.d/`. Afterwards run
-`apt update && apt install uffd-socketmap` to install the package.
+`apt update && apt install uffd-socketmapd` to install the package.
 
-Set the API url and secret in `/etc/uffd-socketmap-postfix.conf`, enable
-and start `uffd-socketmap-postfix.socket`. Then configure Postfix, e.g.
+Set the API url and secret in `/etc/uffd-socketmapd-postfix.conf`, enable
+and start `uffd-socketmapd-postfix.socket`. Then configure Postfix, e.g.
 by adding the following lines to `/etc/postfix/main.cf`:
 
 ```
 # Note that postfix runs in a chroot (/var/spool/postfix) and paths are
 # relative to that!
-virtual_alias_maps = socketmap:unix:/uffd-socketmap.sock:virtual
+virtual_alias_maps = socketmap:unix:/uffd-socketmapd.sock:virtual
 # Defaults to $virtual_alias_maps, which does not work here, so unset it
 virtual_alias_domains =
 ```
diff --git a/debian/contrib/uffd-socketmap-postfix.conf b/debian/contrib/uffd-socketmapd-postfix.conf
similarity index 54%
rename from debian/contrib/uffd-socketmap-postfix.conf
rename to debian/contrib/uffd-socketmapd-postfix.conf
index c427afa4eac80512fc2c05202558e2249ce2e365..b0ce28bea1ff4ce5c7ae3471186ae99055245d47 100644
--- a/debian/contrib/uffd-socketmap-postfix.conf
+++ b/debian/contrib/uffd-socketmapd-postfix.conf
@@ -2,6 +2,6 @@
 #SERVER_API_URL="https://localhost"
 #SERVER_API_KEY="my_secret_api_token"
 
-# The socket path is hard-coded to "/var/spool/postfix/uffd-socketmap.sock"
-# ("/uffd-socketmap.sock" in the postfix sandbox). Use systemd overwrites
-# for uffd-socketmap-postfix.socket to change it.
+# The socket path is hard-coded to "/var/spool/postfix/uffd-socketmapd.sock"
+# ("/uffd-socketmapd.sock" in the postfix sandbox). Use systemd overwrites
+# for uffd-socketmapd-postfix.socket to change it.
diff --git a/debian/contrib/uffd-socketmap-postfix.service b/debian/contrib/uffd-socketmapd-postfix.service
similarity index 81%
rename from debian/contrib/uffd-socketmap-postfix.service
rename to debian/contrib/uffd-socketmapd-postfix.service
index 65a8275dce66124288b40fd1d9e6cfb702766640..fd4f9a2fa087b5a28be26156fcb50c45b7f41ad0 100644
--- a/debian/contrib/uffd-socketmap-postfix.service
+++ b/debian/contrib/uffd-socketmapd-postfix.service
@@ -2,16 +2,16 @@
 Description=Socketmap server to integrate uffd mail aliases with postfix
 After=network.target
 Before=postfix.service
-BindsTo=uffd-socketmap-postfix.socket
+BindsTo=uffd-socketmapd-postfix.socket
 
 [Service]
-ExecStart=/usr/bin/uffd-socketmap --socket-fd 3
+ExecStart=/usr/sbin/uffd-socketmapd --socket-fd 3
 
 Restart=always
 RestartSec=10
 StandardOutput=journal
 StandardError=journal
-SyslogIdentifier=uffd-socketmap-postfix
+SyslogIdentifier=uffd-socketmapd-postfix
 
 DynamicUser=true
 PrivateUsers=true
@@ -38,7 +38,7 @@ SystemCallArchitectures=native
 SystemCallFilter=@system-service
 MemoryDenyWriteExecute=true
 
-EnvironmentFile=/etc/uffd-socketmap-postfix.conf
+EnvironmentFile=/etc/uffd-socketmapd-postfix.conf
 
 [Install]
 WantedBy=default.target
diff --git a/debian/contrib/uffd-socketmap-postfix.socket b/debian/contrib/uffd-socketmapd-postfix.socket
similarity index 77%
rename from debian/contrib/uffd-socketmap-postfix.socket
rename to debian/contrib/uffd-socketmapd-postfix.socket
index 3c2e0a422b13f0f09ba6ae8837b80f66b5b4e742..4ea2b76a996b35ef3128aaa4b6fab4c5a8288e84 100644
--- a/debian/contrib/uffd-socketmap-postfix.socket
+++ b/debian/contrib/uffd-socketmapd-postfix.socket
@@ -2,7 +2,7 @@
 Description=Socketmap server to integrate uffd mail aliases with postfix
 
 [Socket]
-ListenStream=/var/spool/postfix/uffd-socketmap.sock
+ListenStream=/var/spool/postfix/uffd-socketmapd.sock
 SocketUser=postfix
 SocketGroup=postfix
 SocketMode=0640
diff --git a/debian/control b/debian/control
index 22732bed74b10029b629f12fa6f4471ad582ab3f..1597c337d196ab8b3212daee57c0189d5a3f9ae8 100644
--- a/debian/control
+++ b/debian/control
@@ -1,14 +1,14 @@
-Source: uffd-socketmap
+Source: uffd-socketmapd
 Section: python
 Priority: optional
 Maintainer: CCCV <it@cccv.de>
 Build-Depends:
  debhelper-compat (= 12),
 Standards-Version: 4.5.0
-Homepage: https://git.cccv.de/uffd/socketmap-proxy
-Vcs-Git: https://git.cccv.de/uffd/socketmap-proxy.git
+Homepage: https://git.cccv.de/uffd/uffd-socketmapd
+Vcs-Git: https://git.cccv.de/uffd/uffd-socketmapd.git
 
-Package: uffd-socketmap
+Package: uffd-socketmapd
 Architecture: all
 Depends:
  ${misc:Depends},
diff --git a/debian/install b/debian/install
index e886322c605606d1f23708ba9b7016abd6d60b8c..751244c2373d3fcc1d09260031fced2798fde1b7 100644
--- a/debian/install
+++ b/debian/install
@@ -1,4 +1,4 @@
-server.py /usr/lib/uffd-socketmap/
-debian/contrib/uffd-socketmap-postfix.service /usr/lib/systemd/system/
-debian/contrib/uffd-socketmap-postfix.socket /usr/lib/systemd/system/
-debian/contrib/uffd-socketmap-postfix.conf /etc/
+uffd-socketmapd /usr/sbin/
+debian/contrib/uffd-socketmapd-postfix.service /usr/lib/systemd/system/
+debian/contrib/uffd-socketmapd-postfix.socket /usr/lib/systemd/system/
+debian/contrib/uffd-socketmapd-postfix.conf /etc/
diff --git a/debian/links b/debian/links
deleted file mode 100644
index 5c199f3ebe5c63bffb09745ceb82bf912c220605..0000000000000000000000000000000000000000
--- a/debian/links
+++ /dev/null
@@ -1 +0,0 @@
-/usr/lib/uffd-socketmap/server.py /usr/bin/uffd-socketmap
diff --git a/debian/postinst b/debian/postinst
index bdba6dc00488e07d643734fae338985b9b62c6f1..3e727e452c8a1ba71222ed3775b4b5ed1d7efc7c 100755
--- a/debian/postinst
+++ b/debian/postinst
@@ -4,7 +4,7 @@ set -e
 
 case "$1" in
 	configure)
-		chmod 0640 /etc/uffd-socketmap-postfix.conf
+		chmod 0640 /etc/uffd-socketmapd-postfix.conf
 	;;
 
 	abort-upgrade|abort-remove|abort-deconfigure)
diff --git a/server.py b/server.py
deleted file mode 100755
index c13bf10cc587ddc7742fd9ef99cf046a7d933afc..0000000000000000000000000000000000000000
--- a/server.py
+++ /dev/null
@@ -1,257 +0,0 @@
-#!/usr/bin/python3
-import os
-import sys
-import logging
-import socket
-import socketserver
-
-import click
-import requests
-
-logger = logging.getLogger(__name__)
-
-class UffdAPI:
-	def __init__(self, baseurl, key):
-		self.baseurl = baseurl
-		self.key = key
-		self.session = requests.Session()
-		self.session.headers['Authorization'] = 'Bearer '+self.key
-
-	def get(self, endpoint, **kwargs):
-		resp = self.session.get(self.baseurl + endpoint, params=kwargs)
-		resp.raise_for_status()
-		return resp.json()
-
-	def get_aliases(self, mail_address):
-		'''Return list of alias addresses for a given mail address'''
-		results = self.get('/api/v1/getmails', receive_address=mail_address)
-		destinations = []
-		for result in results:
-			destinations += result['destination_addresses']
-		logger.debug('API getmails response for %s: %s', repr(mail_address), destinations)
-		return destinations
-
-# From https://cr.yp.to/proto/netstrings.txt
-#
-# Any string of 8-bit bytes may be encoded as [len]":"[string]",".
-# Here [string] is the string and [len] is a nonempty sequence of ASCII
-# digits giving the length of [string] in decimal. The ASCII digits are
-# <30> for 0, <31> for 1, and so on up through <39> for 9. Extra zeros
-# at the front of [len] are prohibited: [len] begins with <30> exactly
-# when [string] is empty.
-#
-# For example, the string "hello world!" is encoded as <31 32 3a 68
-# 65 6c 6c 6f 20 77 6f 72 6c 64 21 2c>, i.e., "12:hello world!,". The
-# empty string is encoded as "0:,".
-#
-# [len]":"[string]"," is called a netstring. [string] is called the
-# interpretation of the netstring.
-
-def encode_netstring(string):
-	'''Return netstring encoding of a Python str as bytes'''
-	string = string.encode('utf8')
-	return b'%d:%s,'%(len(string), string)
-
-def _parse_netstring_length(netstring):
-	if b':' not in netstring:
-		raise ValueError()
-	length, rest = netstring.split(b':', 1)
-	if not length.isdigit():
-		raise ValueError()
-	length = int(length)
-	return length, rest
-
-def is_netstring_complete(netstring):
-	'''Return whether the argument (bytes) is a complete netstring
-
-	Raises ValueError if the argument is invalid instead of only incomplete.'''
-	if b':' not in netstring:
-		return False
-	length, rest = _parse_netstring_length(netstring)
-	return len(rest) >= length + 1
-
-def decode_netstring(netstring):
-	'''Decode netstring at the beginning of argument
-
-	Returns (decoded_str, remaining_bytes). Raises ValueError if argument does
-	not start with a valid and complete netstring or the encoded string is not
-	valid UTF-8.'''
-	length, rest = _parse_netstring_length(netstring)
-	string, comma, rest = rest[:length], rest[length:length+1], rest[length+1:]
-	if len(string) != length or comma != b',':
-		raise ValueError()
-	# UnicodeDecodeError is a subclass of ValueError
-	return string.decode('utf8'), rest
-
-# From socketmap_table(5)
-#
-# PROTOCOL
-# Socketmaps use a simple protocol: the client sends one request, and the
-# server sends one reply. Each request and each reply are sent as one
-# netstring object.
-#
-# REQUEST FORMAT
-# The socketmap protocol supports only the lookup request. The request
-# has the following form:
-#
-# name <space> key
-#     Search the named socketmap for the specified key.
-#
-# [...]
-# REPLY FORMAT
-# The Postfix socketmap client requires that replies are not longer than
-# 100000 characters (not including the netstring encapsulation). Replies
-# must have the following form:
-#
-# OK <space> data
-#     The requested data was found.
-#
-# NOTFOUND <space>
-#     The requested data was not found.
-#
-# TEMP <space> reason
-#
-# TIMEOUT <space> reason
-#
-# PERM <space> reason
-#     The request failed. The reason, if non-empty, is descriptive
-#     text.
-
-class SocketmapsRequestHandler(socketserver.BaseRequestHandler):
-	api = None # Overwritten
-
-	def handle(self):
-		buf = b''
-		while not is_netstring_complete(buf):
-			chunk = self.request.recv(4096)
-			if not chunk:
-				return
-			buf += chunk
-		request, rest = decode_netstring(buf)
-		if rest:
-			return
-		try:
-			response = self.handle_request(request)
-		except: # pylint: disable=bare-except
-			logger.exception('Exception during request')
-			self.request.sendall(encode_netstring('TEMP Internal Error'))
-			return
-		loglevel = logging.INFO
-		if isinstance(response, tuple) and len(response) == 2:
-			response, loglevel = response
-		logger.log(loglevel, 'Request %s -> %s', repr(request), repr(response))
-		self.request.sendall(encode_netstring(response))
-
-	def handle_request(self, request):
-		if ' ' not in request:
-			return 'PERM Malformed request', logging.WARNING
-		name, key = request.split(' ', 1)
-		if name == 'virtual':
-			# Postfix option virtual_alias_domains defaults to virtual_alias_maps,
-			# so we might get some domain requests. Since Postfix does not retry
-			# failing virtual_alias_domains lookups, PERM is the best option here.
-			if '@' not in key:
-				return 'PERM Key is not a valid mail address. Maybe fix the virtual_alias_domains setting.', logging.WARNING
-			key = key.strip().lower()
-			# Emulate Postfixes virtual(8) behaviour when used with ldap_table(5).
-			local, domain = key.split('@', 1)
-			if '+' in local:
-				local, extention = local.split('+', 1)
-				lookup_order = [
-					(f'{local}+{extention}@{domain}', ''),
-					(f'{local}@{domain}', extention),
-					(f'{local}+{extention}', ''),
-					(f'{local}', extention),
-					(f'@{domain}', extention),
-				]
-			else:
-				lookup_order = [
-					(f'{local}@{domain}', ''),
-					(f'{local}', ''),
-					(f'@{domain}', ''),
-				]
-			results = []
-			for key, extention in lookup_order:
-				for address in self.api.get_aliases(key):
-					# All destinations must be full addresses, but we don't want to fail
-					# here, if any is malformed.
-					if '@' in address and extention:
-						local, domain = address.split('@', 1)
-						address = f'{local}+{extention}@{domain}'
-					results.append(address)
-				if results:
-					break
-			# Returning "OK " (without any data) is interperted as a protocol error
-			# and Postfix will retry the request.
-			if not results:
-				return 'NOTFOUND '
-			# There is no way to escape commas in the result items and it would not
-			# matter, since Postfix internally processes lookup results as a
-			# comma-separated list.
-			return 'OK ' + ','.join(results)
-		return 'PERM Unknown request name', logging.WARNING
-
-def make_requesthandler(api):
-	class RequestHandler(SocketmapsRequestHandler):
-		pass
-	RequestHandler.api = api
-	return RequestHandler
-
-class FilenoUnixStreamServer(socketserver.UnixStreamServer):
-	def __init__(self, fd, RequestHandlerClass, bind_and_activate=True):
-		self.server_fd = fd
-		super().__init__(None, RequestHandlerClass, bind_and_activate=bind_and_activate)
-
-	def server_bind(self):
-		self.socket.close() # UnixStreamServer.__init__ creates an unbound socket
-		self.socket = socket.fromfd(self.server_fd, socket.AF_UNIX, socket.SOCK_STREAM)
-		self.server_address = self.socket.getsockname()
-
-class ThreadingFilenoUnixStreamServer(socketserver.ThreadingMixIn, FilenoUnixStreamServer):
-	pass
-
-def cleanup_unix_socket(path):
-	if not os.path.exists(path):
-		return
-	conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
-	try:
-		conn.connect(path)
-	except ConnectionRefusedError:
-		os.remove(path)
-	conn.close()
-
-class StdoutFilter(logging.Filter):
-	def filter(self, record):
-		return record.levelno <= logging.INFO
-
-@click.command(help='Socketmap proxy for integrating Postfix and other compatible MTAs with uffd SSO. Supports virtual alias lookups (request name "virtual").')
-@click.option('--socket-path', type=click.Path(), help='Path for UNIX domain socket')
-@click.option('--socket-fd', type=int, help='Use fd number as server socket (alternative to --socket-path)')
-@click.option('--api-url', required=True, help='Uffd base URL without API prefix or trailing slash (e.g. https://example.com)')
-@click.option('--api-key', required=True, help='API secret, do not set this on the command-line, use environment variable SERVER_API_KEY instead')
-def main(socket_path, socket_fd, api_url, api_key):
-	if (socket_path is None and socket_fd is None) or \
-	   (socket_path is not None and socket_fd is not None):
-		raise click.ClickException('Either --socket-path or --socket-fd must be specified')
-
-	stdout_handler = logging.StreamHandler(sys.stdout)
-	stdout_handler.setLevel(logging.DEBUG)
-	stdout_handler.addFilter(StdoutFilter())
-	stderr_handler = logging.StreamHandler(sys.stderr)
-	stderr_handler.setLevel(logging.WARNING)
-	logger.setLevel(logging.INFO)
-	logger.addHandler(stdout_handler)
-	logger.addHandler(stderr_handler)
-
-	api = UffdAPI(api_url, api_key)
-	RequestHandler = make_requesthandler(api)
-	if socket_path is not None:
-		cleanup_unix_socket(socket_path)
-		socketserver.ThreadingUnixStreamServer(socket_path, RequestHandler).serve_forever()
-	else:
-		ThreadingFilenoUnixStreamServer(socket_fd, RequestHandler).serve_forever()
-
-if __name__ == '__main__':
-	# Pylint does not seem to understand the click's decorators
-	# pylint: disable=unexpected-keyword-arg,no-value-for-parameter
-	main(auto_envvar_prefix='SERVER')
diff --git a/server.py b/server.py
new file mode 120000
index 0000000000000000000000000000000000000000..287d5bfc622b3c43fdbb4ce5f83f10bf4f8670ea
--- /dev/null
+++ b/server.py
@@ -0,0 +1 @@
+uffd-socketmapd
\ No newline at end of file
diff --git a/uffd-socketmapd b/uffd-socketmapd
new file mode 100755
index 0000000000000000000000000000000000000000..c13bf10cc587ddc7742fd9ef99cf046a7d933afc
--- /dev/null
+++ b/uffd-socketmapd
@@ -0,0 +1,257 @@
+#!/usr/bin/python3
+import os
+import sys
+import logging
+import socket
+import socketserver
+
+import click
+import requests
+
+logger = logging.getLogger(__name__)
+
+class UffdAPI:
+	def __init__(self, baseurl, key):
+		self.baseurl = baseurl
+		self.key = key
+		self.session = requests.Session()
+		self.session.headers['Authorization'] = 'Bearer '+self.key
+
+	def get(self, endpoint, **kwargs):
+		resp = self.session.get(self.baseurl + endpoint, params=kwargs)
+		resp.raise_for_status()
+		return resp.json()
+
+	def get_aliases(self, mail_address):
+		'''Return list of alias addresses for a given mail address'''
+		results = self.get('/api/v1/getmails', receive_address=mail_address)
+		destinations = []
+		for result in results:
+			destinations += result['destination_addresses']
+		logger.debug('API getmails response for %s: %s', repr(mail_address), destinations)
+		return destinations
+
+# From https://cr.yp.to/proto/netstrings.txt
+#
+# Any string of 8-bit bytes may be encoded as [len]":"[string]",".
+# Here [string] is the string and [len] is a nonempty sequence of ASCII
+# digits giving the length of [string] in decimal. The ASCII digits are
+# <30> for 0, <31> for 1, and so on up through <39> for 9. Extra zeros
+# at the front of [len] are prohibited: [len] begins with <30> exactly
+# when [string] is empty.
+#
+# For example, the string "hello world!" is encoded as <31 32 3a 68
+# 65 6c 6c 6f 20 77 6f 72 6c 64 21 2c>, i.e., "12:hello world!,". The
+# empty string is encoded as "0:,".
+#
+# [len]":"[string]"," is called a netstring. [string] is called the
+# interpretation of the netstring.
+
+def encode_netstring(string):
+	'''Return netstring encoding of a Python str as bytes'''
+	string = string.encode('utf8')
+	return b'%d:%s,'%(len(string), string)
+
+def _parse_netstring_length(netstring):
+	if b':' not in netstring:
+		raise ValueError()
+	length, rest = netstring.split(b':', 1)
+	if not length.isdigit():
+		raise ValueError()
+	length = int(length)
+	return length, rest
+
+def is_netstring_complete(netstring):
+	'''Return whether the argument (bytes) is a complete netstring
+
+	Raises ValueError if the argument is invalid instead of only incomplete.'''
+	if b':' not in netstring:
+		return False
+	length, rest = _parse_netstring_length(netstring)
+	return len(rest) >= length + 1
+
+def decode_netstring(netstring):
+	'''Decode netstring at the beginning of argument
+
+	Returns (decoded_str, remaining_bytes). Raises ValueError if argument does
+	not start with a valid and complete netstring or the encoded string is not
+	valid UTF-8.'''
+	length, rest = _parse_netstring_length(netstring)
+	string, comma, rest = rest[:length], rest[length:length+1], rest[length+1:]
+	if len(string) != length or comma != b',':
+		raise ValueError()
+	# UnicodeDecodeError is a subclass of ValueError
+	return string.decode('utf8'), rest
+
+# From socketmap_table(5)
+#
+# PROTOCOL
+# Socketmaps use a simple protocol: the client sends one request, and the
+# server sends one reply. Each request and each reply are sent as one
+# netstring object.
+#
+# REQUEST FORMAT
+# The socketmap protocol supports only the lookup request. The request
+# has the following form:
+#
+# name <space> key
+#     Search the named socketmap for the specified key.
+#
+# [...]
+# REPLY FORMAT
+# The Postfix socketmap client requires that replies are not longer than
+# 100000 characters (not including the netstring encapsulation). Replies
+# must have the following form:
+#
+# OK <space> data
+#     The requested data was found.
+#
+# NOTFOUND <space>
+#     The requested data was not found.
+#
+# TEMP <space> reason
+#
+# TIMEOUT <space> reason
+#
+# PERM <space> reason
+#     The request failed. The reason, if non-empty, is descriptive
+#     text.
+
+class SocketmapsRequestHandler(socketserver.BaseRequestHandler):
+	api = None # Overwritten
+
+	def handle(self):
+		buf = b''
+		while not is_netstring_complete(buf):
+			chunk = self.request.recv(4096)
+			if not chunk:
+				return
+			buf += chunk
+		request, rest = decode_netstring(buf)
+		if rest:
+			return
+		try:
+			response = self.handle_request(request)
+		except: # pylint: disable=bare-except
+			logger.exception('Exception during request')
+			self.request.sendall(encode_netstring('TEMP Internal Error'))
+			return
+		loglevel = logging.INFO
+		if isinstance(response, tuple) and len(response) == 2:
+			response, loglevel = response
+		logger.log(loglevel, 'Request %s -> %s', repr(request), repr(response))
+		self.request.sendall(encode_netstring(response))
+
+	def handle_request(self, request):
+		if ' ' not in request:
+			return 'PERM Malformed request', logging.WARNING
+		name, key = request.split(' ', 1)
+		if name == 'virtual':
+			# Postfix option virtual_alias_domains defaults to virtual_alias_maps,
+			# so we might get some domain requests. Since Postfix does not retry
+			# failing virtual_alias_domains lookups, PERM is the best option here.
+			if '@' not in key:
+				return 'PERM Key is not a valid mail address. Maybe fix the virtual_alias_domains setting.', logging.WARNING
+			key = key.strip().lower()
+			# Emulate Postfixes virtual(8) behaviour when used with ldap_table(5).
+			local, domain = key.split('@', 1)
+			if '+' in local:
+				local, extention = local.split('+', 1)
+				lookup_order = [
+					(f'{local}+{extention}@{domain}', ''),
+					(f'{local}@{domain}', extention),
+					(f'{local}+{extention}', ''),
+					(f'{local}', extention),
+					(f'@{domain}', extention),
+				]
+			else:
+				lookup_order = [
+					(f'{local}@{domain}', ''),
+					(f'{local}', ''),
+					(f'@{domain}', ''),
+				]
+			results = []
+			for key, extention in lookup_order:
+				for address in self.api.get_aliases(key):
+					# All destinations must be full addresses, but we don't want to fail
+					# here, if any is malformed.
+					if '@' in address and extention:
+						local, domain = address.split('@', 1)
+						address = f'{local}+{extention}@{domain}'
+					results.append(address)
+				if results:
+					break
+			# Returning "OK " (without any data) is interperted as a protocol error
+			# and Postfix will retry the request.
+			if not results:
+				return 'NOTFOUND '
+			# There is no way to escape commas in the result items and it would not
+			# matter, since Postfix internally processes lookup results as a
+			# comma-separated list.
+			return 'OK ' + ','.join(results)
+		return 'PERM Unknown request name', logging.WARNING
+
+def make_requesthandler(api):
+	class RequestHandler(SocketmapsRequestHandler):
+		pass
+	RequestHandler.api = api
+	return RequestHandler
+
+class FilenoUnixStreamServer(socketserver.UnixStreamServer):
+	def __init__(self, fd, RequestHandlerClass, bind_and_activate=True):
+		self.server_fd = fd
+		super().__init__(None, RequestHandlerClass, bind_and_activate=bind_and_activate)
+
+	def server_bind(self):
+		self.socket.close() # UnixStreamServer.__init__ creates an unbound socket
+		self.socket = socket.fromfd(self.server_fd, socket.AF_UNIX, socket.SOCK_STREAM)
+		self.server_address = self.socket.getsockname()
+
+class ThreadingFilenoUnixStreamServer(socketserver.ThreadingMixIn, FilenoUnixStreamServer):
+	pass
+
+def cleanup_unix_socket(path):
+	if not os.path.exists(path):
+		return
+	conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+	try:
+		conn.connect(path)
+	except ConnectionRefusedError:
+		os.remove(path)
+	conn.close()
+
+class StdoutFilter(logging.Filter):
+	def filter(self, record):
+		return record.levelno <= logging.INFO
+
+@click.command(help='Socketmap proxy for integrating Postfix and other compatible MTAs with uffd SSO. Supports virtual alias lookups (request name "virtual").')
+@click.option('--socket-path', type=click.Path(), help='Path for UNIX domain socket')
+@click.option('--socket-fd', type=int, help='Use fd number as server socket (alternative to --socket-path)')
+@click.option('--api-url', required=True, help='Uffd base URL without API prefix or trailing slash (e.g. https://example.com)')
+@click.option('--api-key', required=True, help='API secret, do not set this on the command-line, use environment variable SERVER_API_KEY instead')
+def main(socket_path, socket_fd, api_url, api_key):
+	if (socket_path is None and socket_fd is None) or \
+	   (socket_path is not None and socket_fd is not None):
+		raise click.ClickException('Either --socket-path or --socket-fd must be specified')
+
+	stdout_handler = logging.StreamHandler(sys.stdout)
+	stdout_handler.setLevel(logging.DEBUG)
+	stdout_handler.addFilter(StdoutFilter())
+	stderr_handler = logging.StreamHandler(sys.stderr)
+	stderr_handler.setLevel(logging.WARNING)
+	logger.setLevel(logging.INFO)
+	logger.addHandler(stdout_handler)
+	logger.addHandler(stderr_handler)
+
+	api = UffdAPI(api_url, api_key)
+	RequestHandler = make_requesthandler(api)
+	if socket_path is not None:
+		cleanup_unix_socket(socket_path)
+		socketserver.ThreadingUnixStreamServer(socket_path, RequestHandler).serve_forever()
+	else:
+		ThreadingFilenoUnixStreamServer(socket_fd, RequestHandler).serve_forever()
+
+if __name__ == '__main__':
+	# Pylint does not seem to understand the click's decorators
+	# pylint: disable=unexpected-keyword-arg,no-value-for-parameter
+	main(auto_envvar_prefix='SERVER')