diff --git a/requirements.txt b/requirements.txt
index b636ab0156383ac8a5b81f7180d803d0e6b9849e..13331b700bbaed402feb962a0253b10ea531e11b 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
 --extra-index-url https://git.cccv.de/api/v4/projects/220/packages/pypi/simple
-ldapserver==0.0.1.dev3
+ldapserver==0.0.1.dev4
 
+requests==2.*
 CacheControl
diff --git a/server.py b/server.py
index 502d168260e8be9352f9953d8134a52a721ac017..06681dde85f3499df8bf97a2cbc1386d46ea3b28 100644
--- a/server.py
+++ b/server.py
@@ -5,13 +5,22 @@ import requests
 from cachecontrol import CacheControl
 from cachecontrol.heuristics import ExpiresAfter
 
-from ldapserver import SimpleLDAPRequestHandler
+import ldapserver
+from ldapserver import LDAPRequestHandler, rfc4518_stringprep
 from ldapserver.dn import DN
-from ldapserver.ldap import FilterEqual, FilterAnd
-from ldapserver.directory import BaseDirectory, SimpleFilterMixin, StaticDirectory, eval_ldap_filter
-from ldapserver.util import encode_attribute, CaseInsensitiveDict
 from ldapserver.exceptions import LDAPInvalidCredentials
-from ldapserver.schema import RFC2307BIS_SUBSCHEMA
+from ldapserver.schema import RFC2307BIS_SUBSCHEMA, RFC2798_SUBSCHEMA, WILDCARD_VALUE
+
+memberOf = ldapserver.schema.AttributeType(
+	'1.2.840.113556.1.2.102',
+	name='memberOf',
+	desc='Group that the entry belongs to',
+	equality=ldapserver.schema.rfc4517.matching_rules.distinguishedNameMatch,
+	syntax=ldapserver.schema.rfc4517.syntaxes.DN(),
+	usage=ldapserver.schema.AttributeTypeUsage.dSAOperation
+)
+
+CUSTOM_SUBSCHEMA = RFC2307BIS_SUBSCHEMA.extend(RFC2798_SUBSCHEMA, attribute_types=[memberOf])
 
 class UffdAPI:
 	def __init__(self, baseurl, key, cache_ttl=60):
@@ -30,211 +39,50 @@ class UffdAPI:
 		assert(resp.ok)
 		return resp.json()
 
-class UserDirectory(SimpleFilterMixin, BaseDirectory):
-	def __init__(self, api, dn_base):
-		self.api = api
-		self.rdn_attr = 'uid'
-		self.dn_base = DN('ou=users') + DN(dn_base)
-		self.group_dn_base = DN('ou=groups') + DN(dn_base)
-		self.structuralobjectclass = b'inetorgperson'
-		self.objectclasses = [b'top', b'inetorgperson', b'organizationalperson', b'person', b'posixaccount']
-		self.attributes = ['structuralobjectclass', 'objectclass', 'cn', 'displayname', 'givenname', 'homedirectory', 'mail', 'sn', 'uid', 'uidnumber', 'memberof']
-
-	def generate_result(self, user):
-		attributes = CaseInsensitiveDict(
-			structuralObjectClass=[self.structuralobjectclass],
-			objectClass=self.objectclasses,
-			cn=[encode_attribute(user['displayname'])],
-			displayname=[encode_attribute(user['displayname'])],
-			givenname=[encode_attribute(user['displayname'])],
-			homeDirectory=[encode_attribute('/home/'+user['loginname'])],
-			mail=[encode_attribute(user['email'])],
-			sn=[encode_attribute(' ')],
-			uid=[encode_attribute(user['loginname'])],
-			uidNumber=[encode_attribute(user['id'])],
-			memberOf=[encode_attribute(DN(cn=group) + self.group_dn_base) for group in user['groups']],
-		)
-		dn = str(DN(uid=user['loginname']) + self.dn_base)
-		return dn, attributes
-
-	def get_best_api_param(self, expr):
-		if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'uid':
-			return 'loginname', expr.value
-		if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'uidnumber':
-			return 'id', expr.value
-		if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'mail':
-			return 'email', expr.value
-		if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'memberof':
-			group_dn = DN.from_str(expr.value.decode())
-			if group_dn.is_direct_child_of(self.group_dn_base) and len(group_dn[0]) == 1 and group_dn[0][0].attribute == 'cn':
-				return 'group', group_dn[0][0].value
-		if isinstance(expr, FilterAnd):
-			params = dict([self.get_best_api_param(subexpr) for subexpr in expr.filters])
-			for key in ['loginname', 'id', 'email', 'group']:
-				if key in params:
-					return key, params[key]
-		return None, None
-
-	def search_fetch(self, expr):
-		if expr is False:
-			return
-		kwargs = {}
-		key, value = self.get_best_api_param(expr)
-		if key is not None:
-			kwargs[key] = value
-		for user in self.api.get('getusers', **kwargs):
-			dn, obj = self.generate_result(user)
-			if eval_ldap_filter(obj, expr):
-				yield dn, obj
-
-	def filter_equal(self, attribute, value):
-		if attribute == 'memberof':
-			value = str(DN.from_str(value.decode())).encode()
-		return super().filter_equal(attribute, value)
-
-	def filter_present(self, attribute):
-		if attribute not in self.attributes:
-			return False
-		return super().filter_present(attribute)
-
-class GroupDirectory(SimpleFilterMixin, BaseDirectory):
-	def __init__(self, api, dn_base):
-		self.api = api
-		self.rdn_attr = 'cn'
-		self.dn_base = DN('ou=groups') + DN(dn_base)
-		self.user_dn_base = DN('ou=users') + DN(dn_base)
-		self.structuralobjectclass = b'groupOfUniqueNames'
-		self.objectclasses = [b'top', b'groupOfUniqueNames', b'posixGroup']
-		self.attributes = ['structuralobjectclass', 'objectclass', 'cn', 'description', 'gidnumber', 'uniquemember']
-
-	def generate_result(self, group):
-		attributes = CaseInsensitiveDict(
-			structuralObjectClass=[self.structuralobjectclass],
-			objectClass=self.objectclasses,
-			cn=[encode_attribute(group['name'])],
-			description=[encode_attribute(' ')],
-			gidNumber=[encode_attribute(group['id'])],
-			uniqueMember=[encode_attribute(DN(uid=user) + self.user_dn_base) for user in group['members']],
-		)
-		dn = str(DN(cn=group['name']) + self.dn_base)
-		return dn, attributes
-
-	def get_best_api_param(self, expr):
-		if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'cn':
-			return 'name', expr.value
-		elif isinstance(expr, FilterEqual) and expr.attribute.lower() == 'gidnumber':
-			return 'id', expr.value
-		elif isinstance(expr, FilterEqual) and expr.attribute.lower() == 'uniquemember':
-			user_dn = DN.from_str(expr.value.decode())
-			if user_dn.is_direct_child_of(self.user_dn_base) and len(user_dn[0]) == 1 and user_dn[0][0].attribute == 'uid':
-				return 'member', user_dn[0][0].value
-		if isinstance(expr, FilterAnd):
-			params = dict([self.get_best_api_param(subexpr) for subexpr in expr.filters])
-			for key in ['name', 'id', 'member']:
-				if key in params:
-					return key, params[key]
-		return None, None
-
-	def search_fetch(self, expr):
-		if expr is False:
-			return
-		kwargs = {}
-		key, value = self.get_best_api_param(expr)
-		if key is not None:
-			kwargs[key] = value
-		for group in self.api.get('getgroups', **kwargs):
-			dn, obj = self.generate_result(group)
-			if eval_ldap_filter(obj, expr):
-				yield dn, obj
-
-	def filter_equal(self, attribute, value):
-		if attribute == 'uniquemember':
-			value = str(DN.from_str(value.decode())).encode()
-		return super().filter_equal(attribute, value)
-
-	def filter_present(self, attribute):
-		if attribute not in self.attributes:
-			return False
-		return super().filter_present(attribute)
-
-class MailDirectory(SimpleFilterMixin, BaseDirectory):
-	def __init__(self, api, dn_base):
-		self.api = api
-		self.rdn_attr = 'uid'
-		self.dn_base = DN('ou=postfix') + DN(dn_base)
-		self.structuralobjectclass = b'postfixVirtual'
-		self.objectclasses = [b'top', b'postfixVirtual']
-		self.attributes = ['structuralobjectclass', 'objectclass', 'uid', 'mailacceptinggeneralid', 'maildrop']
-
-	def generate_result(self, mail):
-		attributes = CaseInsensitiveDict(
-			structuralObjectClass=[self.structuralobjectclass],
-			objectClass=self.objectclasses,
-			uid=[encode_attribute(mail['name'])],
-			mailacceptinggeneralid=[encode_attribute(address) for address in mail['receive_addresses']],
-			maildrop=[encode_attribute(address) for address in mail['destination_addresses']],
-		)
-		dn = str(DN(uid=mail['name']) + self.dn_base)
-		return dn, attributes
-
-	def get_best_api_param(self, expr):
-		if isinstance(expr, FilterEqual) and expr.attribute.lower() == 'uid':
-			return 'name', expr.value
-		elif isinstance(expr, FilterEqual) and expr.attribute.lower() == 'mailacceptinggeneralid':
-			return 'receive_address', expr.value
-		elif isinstance(expr, FilterEqual) and expr.attribute.lower() == 'maildrop':
-			return 'destination_address', expr.value
-		if isinstance(expr, FilterAnd):
-			params = dict([self.get_best_api_param(subexpr) for subexpr in expr.filters])
-			for key in ['name', 'receive_address', 'destination_address']:
-				if key in params:
-					return key, params[key]
-		return None, None
-
-	def search_fetch(self, expr):
-		if expr is False:
-			return
-		kwargs = {}
-		key, value = self.get_best_api_param(expr)
-		if key is not None:
-			kwargs[key] = value
-		for mail in self.api.get('getmails', **kwargs):
-			dn, obj = self.generate_result(mail)
-			if eval_ldap_filter(obj, expr):
-				yield dn, obj
-
-	def filter_present(self, attribute):
-		if attribute not in self.attributes:
-			return False
-		return super().filter_present(attribute)
-
-class RequestHandler(SimpleLDAPRequestHandler):
-	subschema = RFC2307BIS_SUBSCHEMA
+def normalize_user_loginname(loginname):
+	# The equality matching rule for uid is caseIgnoreMatch. It prepares
+	# attribute and assertion value according to LDAP stringprep with
+	# case-folding.
+	#
+	# Uffd restricts loginnames to lower-case ASCII letters, digits,
+	# underscores and dashes. None of these characters are changed or
+	# rejetced by stringprep with case-folding. The effect stringprep has
+	# on loginnames is that it adds a leading and a final SPACE character.
+	#
+	# The assertion value (the argument to this function) could however contain
+	# characters that are mapped to SPACE or nothing for example. So we apply
+	# stringprep to the assertion value and then strip the added leading and
+	# final SPACE characters. Stringprep case-folds the input string to
+	# lower-case.
+	#
+	# The resulting string can be compared to an actual loginname with simple
+	# byte-for-byte or codepoint-for-codepoint comparison with or without
+	# case-folding. It matches if and only if the loginname matches the input
+	# value according to caseIgnoreMatch.
+	try:
+		return rfc4518_stringprep.prepare(loginname, rfc4518_stringprep.MatchingType.CASE_IGNORE_STRING).strip(' ')
+	except ValueError: # Input value contains prohibited characters
+		return None
+
+def normalize_group_name(name):
+	# Currently uffd has no restrictions for group names, but it is planned
+	# to add restrictions similar to loginname restrictions.
+	# See https://git.cccv.de/uffd/uffd/-/issues/127
+	return normalize_user_loginname(name)
+
+class RequestHandler(LDAPRequestHandler):
+	subschema = CUSTOM_SUBSCHEMA
 
 	# Overwritten before use
 	api = None
 	dn_base = None
-	static_directory = None
-	user_directory = None
-	group_directory = None
-	mail_directory = None
 	bind_dn = None
 	bind_password = None
 
 	def setup(self):
 		super().setup()
 
-	def handle(self):
-		print('CONNECT')
-		super().handle()
-		print('DISCONNECT')
-
-	def handle_message(self, shallowmsg):
-		print('MSG', shallowmsg.data)
-		return super().handle_message(shallowmsg)
-
 	def do_bind_simple_authenticated(self, dn, password):
-		print('BIND plain', dn)
 		dn = DN.from_str(dn)
 		if dn == self.bind_dn and password == self.bind_password:
 			return True
@@ -247,7 +95,6 @@ class RequestHandler(SimpleLDAPRequestHandler):
 	supports_sasl_plain = True
 
 	def do_bind_sasl_plain(self, identity, password, authzid=None):
-		print('BIND sasl', identity, authzid)
 		if authzid is not None and identity != authzid:
 			raise LDAPInvalidCredentials()
 		user = self.api.post('checkpassword', loginname=identity, password=password)
@@ -256,56 +103,114 @@ class RequestHandler(SimpleLDAPRequestHandler):
 		return user
 
 	def do_search(self, baseobj, scope, filter):
-		print('SEARCH %s "%s" %s'%(scope.name, baseobj, filter.get_filter_string()))
 		yield from super().do_search(baseobj, scope, filter)
 		if self.bind_object:
-			yield from self.static_directory.search(baseobj, scope, filter)
-			yield from self.user_directory.search(baseobj, scope, filter)
-			yield from self.group_directory.search(baseobj, scope, filter)
-			if self.mail_directory is not None:
-				yield from self.mail_directory.search(baseobj, scope, filter)
+			yield from self.do_search_static()
+			yield from self.do_search_users(baseobj, scope, filter)
+			yield from self.do_search_groups(baseobj, scope, filter)
+
+	def do_search_static(self):
+		base_attrs = {
+			'objectClass': ['top', 'dcObject', 'organization'],
+			'structuralObjectClass': ['organization'],
+		}
+		for rdnassertion in self.dn_base[0]:
+			base_attrs[rdnassertion.attribute] = [rdnassertion.value]
+		yield self.subschema.Object(self.dn_base, **base_attrs)
+		yield self.subschema.Object(DN('ou=users') + self.dn_base,
+			ou=['users'],
+			objectClass=['top', 'organizationalUnit'],
+			structuralObjectClass=['organizationalUnit'],
+		)
+		yield self.subschema.Object(DN('ou=groups') + self.dn_base,
+			ou=['groups'],
+			objectClass=['top', 'organizationalUnit'],
+			structuralObjectClass=['organizationalUnit'],
+		)
+		yield self.subschema.Object(DN('ou=system') + self.dn_base,
+			ou=['system'],
+			objectClass=['top', 'organizationalUnit'],
+			structuralObjectClass=['organizationalUnit'],
+		)
+		yield self.subschema.Object(DN('cn=service,ou=system') + self.dn_base,
+			cn=['service'],
+			objectClass=['top', 'organizationalRole', 'simpleSecurityObject'],
+			structuralObjectClass=['organizationalRole'],
+		)
+
+	def do_search_users(self, baseobj, scope, filter):
+		template = self.subschema.ObjectTemplate(DN(self.dn_base, ou='users'), 'uid',
+			structuralObjectClass=['inetorgperson'],
+			objectClass=['top', 'inetorgperson', 'organizationalperson', 'person', 'posixaccount'],
+			cn=[WILDCARD_VALUE],
+			displayname=[WILDCARD_VALUE],
+			givenname=[WILDCARD_VALUE],
+			homeDirectory=[WILDCARD_VALUE],
+			mail=[WILDCARD_VALUE],
+			sn=[' '],
+			uid=[WILDCARD_VALUE],
+			uidNumber=[WILDCARD_VALUE],
+			memberOf=[WILDCARD_VALUE],
+		)
+		if not template.match_search(baseobj, scope, filter):
+			return
+		constraints = template.extract_search_constraints(baseobj, scope, filter)
+		request_params = {}
+		if 'uid' in constraints:
+			request_params = {'loginname': normalize_user_loginname(constraints['uid'][0])}
+		elif 'uidnumber' in constraints:
+			request_params = {'id': constraints['uidnumber'][0]}
+		elif 'memberof' in constraints:
+			for value in constraints['memberof']:
+				if value.is_direct_child_of(DN(self.dn_base, ou='groups')) and value.object_attribute == 'cn':
+					request_params = {'group': normalize_group_name(value.object_value)}
+					break
+		for user in self.api.get('getusers', **request_params):
+			yield template.create_object(user['loginname'],
+				cn=[user['displayname']],
+				displayname=[user['displayname']],
+				givenname=[user['displayname']],
+				homeDirectory=['/home/'+user['loginname']],
+				mail=[user['email']],
+				uid=[user['loginname']],
+				uidNumber=[user['id']],
+				memberOf=[DN(DN(self.dn_base, ou='groups'), cn=group) for group in user['groups']],
+			)
+
+	def do_search_groups(self, baseobj, scope, filter):
+		template = self.subschema.ObjectTemplate(DN(self.dn_base, ou='groups'), 'cn',
+			structuralObjectClass=['groupOfUniqueNames'],
+			objectClass=['top', 'groupOfUniqueNames', 'posixGroup'],
+			cn=[WILDCARD_VALUE],
+			description=[' '],
+			gidNumber=[WILDCARD_VALUE],
+			uniqueMember=[WILDCARD_VALUE],
+		)
+		if not template.match_search(baseobj, scope, filter):
+			return
+		constraints = template.extract_search_constraints(baseobj, scope, filter)
+		request_params = {}
+		if 'cn' in constraints:
+			request_params = {'name': normalize_group_name(constraints['cn'][0])}
+		elif 'gidnumber' in constraints:
+			request_params = {'id': constraints['gidnumber'][0]}
+		elif 'uniquemember' in constraints:
+			for value in constraints['uniquemember']:
+				if value.is_direct_child_of(DN(self.dn_base, ou='users')) and value.object_attribute == 'uid':
+					request_params = {'member': normalize_user_loginname(value.object_value)}
+					break
+		for group in self.api.get('getgroups', **request_params):
+			yield template.create_object(group['name'],
+				cn=[group['name']],
+				gidNumber=[group['id']],
+				uniqueMember=[DN(DN(self.dn_base, ou='users'), uid=user) for user in group['members']],
+			)
 
 def main(config):
 	dn_base = DN.from_str(config['dn_base'])
 	api = UffdAPI(config['api_baseurl'], config['api_key'], config.get('cache_ttl', 60))
-	user_directory = UserDirectory(api, dn_base)
-	group_directory = GroupDirectory(api, dn_base)
-	mail_directory = MailDirectory(api, dn_base)
 
-	static_directory = StaticDirectory()
-	base_attrs = {
-		'objectClass': ['top', 'dcObject', 'organization'],
-		'structuralObjectClass': ['organization'],
-	}
-	for rdnassertion in dn_base[0]:
-		base_attrs[rdnassertion.attribute] = [rdnassertion.value]
-	static_directory.add(dn_base, base_attrs)
-	static_directory.add(DN('ou=users') + dn_base, {
-		'ou': ['users'],
-		'objectClass': ['top', 'organizationalUnit'],
-		'structuralObjectClass': ['organizationalUnit'],
-	})
-	static_directory.add(DN('ou=groups') + dn_base, {
-		'ou': ['groups'],
-		'objectClass': ['top', 'organizationalUnit'],
-		'structuralObjectClass': ['organizationalUnit'],
-	})
-	if config.get('enable_mail'):
-		static_directory.add(DN('ou=postfix') + dn_base, {
-			'ou': ['postfix'],
-			'objectClass': ['top', 'organizationalUnit'],
-			'structuralObjectClass': ['organizationalUnit'],
-		})
-	static_directory.add(DN('ou=system') + dn_base, {
-		'ou': ['system'],
-		'objectClass': ['top', 'organizationalUnit'],
-		'structuralObjectClass': ['organizationalUnit'],
-	})
-	static_directory.add(DN('cn=service,ou=system') + dn_base, {
-		'cn': ['service'],
-		'objectClass': ['top', 'organizationalRole', 'simpleSecurityObject'],
-		'structuralObjectClass': ['organizationalRole'],
-	})
+	subschema = RFC2307BIS_SUBSCHEMA
 
 	class CustomRequestHandler(RequestHandler):
 		pass
@@ -314,11 +219,6 @@ def main(config):
 	CustomRequestHandler.dn_base = dn_base
 	CustomRequestHandler.bind_dn = DN('cn=service,ou=system') + dn_base
 	CustomRequestHandler.bind_password = config['bind_password'].encode()
-	CustomRequestHandler.static_directory = static_directory
-	CustomRequestHandler.user_directory = user_directory
-	CustomRequestHandler.group_directory = group_directory
-	if config.get('enable_mail'):
-		CustomRequestHandler.mail_directory = mail_directory
 
 	if config['listen_addr'].startswith('unix:'):
 		socketserver.ThreadingUnixStreamServer(config['listen_addr'][5:], CustomRequestHandler).serve_forever()