diff --git a/uffd/__init__.py b/uffd/__init__.py
index 521aba6287d26343744b0d4b0d8d7e97b2b3d470..54e0873b300d25a1986f4968004c4b6d09e2368d 100644
--- a/uffd/__init__.py
+++ b/uffd/__init__.py
@@ -39,10 +39,10 @@ def create_app(test_config=None):
 
 	db.init_app(app)
 	# pylint: disable=C0415
-	from uffd import user, selfservice, role, session, csrf, ldap
+	from uffd import user, selfservice, role, mail, session, csrf, ldap
 	# pylint: enable=C0415
 
-	for i in user.bp + selfservice.bp + role.bp + session.bp + csrf.bp + ldap.bp:
+	for i in user.bp + selfservice.bp + role.bp + mail.bp + session.bp + csrf.bp + ldap.bp:
 		app.register_blueprint(i)
 
 	@app.route("/")
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index d13385e43324a3260918b2ddb3ed8000dcaea7c3..1f93e78ea5c888fc1a8e97a7bc0477630185f9d4 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -1,5 +1,6 @@
 LDAP_BASE_USER="ou=users,dc=example,dc=com"
 LDAP_BASE_GROUPS="ou=groups,dc=example,dc=com"
+LDAP_BASE_MAIL="ou=postfix,dc=example,dc=com"
 LDAP_SERVICE_BIND_DN=""
 LDAP_SERVICE_BIND_PASSWORD=""
 LDAP_SERVICE_URL="ldapi:///"
diff --git a/uffd/mail/__init__.py b/uffd/mail/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..671578662f91c82cb987ffe679c1f102dc493d1f
--- /dev/null
+++ b/uffd/mail/__init__.py
@@ -0,0 +1,3 @@
+from .views import bp as bp_ui
+
+bp = [bp_ui]
diff --git a/uffd/mail/models.py b/uffd/mail/models.py
new file mode 100644
index 0000000000000000000000000000000000000000..1e404b1b79b505671cea8b8aaab5b1f252292db1
--- /dev/null
+++ b/uffd/mail/models.py
@@ -0,0 +1,73 @@
+import secrets
+
+from ldap3 import MODIFY_REPLACE, MODIFY_DELETE, MODIFY_ADD, HASHED_SALTED_SHA512
+from flask import current_app
+
+from uffd import ldap
+
+class Mail():
+	def __init__(self, uid=None, destinations=[], receivers=[], dn=None):
+		self.uid = uid
+		self.receivers = receivers
+		self.destinations = destinations
+		self.dn = dn
+
+	@classmethod
+	def from_ldap(cls, ldapobject):
+		return Mail(
+				uid=ldapobject['uid'].value,
+				receivers=ldap.get_ldap_array_attribute_safe(ldapobject, 'mailacceptinggeneralid'),
+				destinations=ldap.get_ldap_array_attribute_safe(ldapobject, 'maildrop'),
+				dn=ldapobject.entry_dn,
+			)
+
+	@classmethod
+	def from_ldap_dn(cls, dn):
+		conn = ldap.get_conn()
+		conn.search(dn, '(objectClass=postfixVirtual)')
+		if not len(conn.entries) == 1:
+			return None
+		return Mail.from_ldap(conn.entries[0])
+
+	def to_ldap(self, new=False):
+		conn = ldap.get_conn()
+		if new:
+			self.uid = ldap.get_next_uid()
+			attributes = {
+				'uidNumber': self.uid,
+				'gidNumber': current_app.config['LDAP_USER_GID'],
+				'homeDirectory': '/home/'+self.loginname,
+				'sn': ' ',
+				'userPassword': hashed(HASHED_SALTED_SHA512, secrets.token_hex(128)),
+				# same as for update
+				'givenName': self.displayname,
+				'displayName': self.displayname,
+				'cn': self.displayname,
+				'mail': self.mail,
+			}
+			dn = ldap.loginname_to_dn(self.loginname)
+			result = conn.add(dn, current_app.config['LDAP_USER_OBJECTCLASSES'], attributes)
+		else:
+			attributes = {
+				'givenName': [(MODIFY_REPLACE, [self.displayname])],
+				'displayName': [(MODIFY_REPLACE, [self.displayname])],
+				'cn': [(MODIFY_REPLACE, [self.displayname])],
+				'mail': [(MODIFY_REPLACE, [self.mail])],
+				}
+			if self.newpassword:
+				attributes['userPassword'] = [(MODIFY_REPLACE, [hashed(HASHED_SALTED_SHA512, self.newpassword)])]
+			dn = ldap.uid_to_dn(self.uid)
+			result = conn.modify(dn, attributes)
+		self.dn = dn
+
+		group_conn = ldap.get_conn()
+		for group in self.initial_groups_ldap:
+			if not group in self.groups_ldap:
+				group_conn.modify(group, {'uniqueMember': [(MODIFY_DELETE, [self.dn])]})
+		for group in self.groups_ldap:
+			if not group in self.initial_groups_ldap:
+				group_conn.modify(group, {'uniqueMember': [(MODIFY_ADD, [self.dn])]})
+		self.groups_changed = False
+
+		return result
+
diff --git a/uffd/mail/templates/mail_list.html b/uffd/mail/templates/mail_list.html
new file mode 100644
index 0000000000000000000000000000000000000000..32fa3b6448a407221ae4c9fa245a3ecf959e795c
--- /dev/null
+++ b/uffd/mail/templates/mail_list.html
@@ -0,0 +1,56 @@
+{% extends 'base.html' %}
+
+{% block body %}
+<div class="row">
+	<div class="col">
+		<table class="table table-striped table-sm">
+			<thead>
+				<tr>
+					<th scope="col">name</th>
+					<th scope="col">receiving address</th>
+					<th scope="col">destinations</th>
+					<th scope="col">
+						<p class="text-right">
+							<a type="button" class="btn btn-primary" href="{{ url_for("mail.show") }}">
+								<i class="fa fa-plus" aria-hidden="true"></i> New
+							</a>
+						</p>
+					</th>
+				</tr>
+			</thead>
+			<tbody>
+				{% for mail in mails|sort(attribute="uid") %}
+				<tr id="mail-{{ mail.uid }}">
+					<th scope="row">
+						<a href="{{ url_for("mail.show", uid=mail.uid) }}">
+						{{ mail.uid }}
+						</a>
+					</th>
+					<td>
+						<ul>
+							{% for i in mail.receivers %}
+							<li>{{ i }}</li>
+							{% endfor %}
+						</ul>
+					</td>
+					<td>
+						<ul>
+							{% for i in mail.destinations %}
+							<li>{{ i }}</li>
+							{% endfor %}
+						</ul>
+					</td>
+					<td>
+						<p class="text-right">
+							<a href="{{ url_for("mail.show", uid=mail.uid) }}" class="btn btn-primary">
+								<i class="fa fa-edit" aria-hidden="true"></i> Edit
+							</a>
+						</p>
+					</td>
+				</tr>
+				{% endfor %}
+			</tbody>
+		</table>
+	</div>
+</dev>
+{% endblock %}
diff --git a/uffd/mail/views.py b/uffd/mail/views.py
new file mode 100644
index 0000000000000000000000000000000000000000..460ca53bdd123f5fda0659e4a31f357e17aa035e
--- /dev/null
+++ b/uffd/mail/views.py
@@ -0,0 +1,123 @@
+from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
+
+from uffd.navbar import register_navbar
+from uffd.csrf import csrf_protect
+from uffd.ldap import get_conn, escape_filter_chars
+from uffd.session import login_required, is_valid_session, get_current_user
+
+from uffd.mail.models import Mail
+
+bp = Blueprint("mail", __name__, template_folder='templates', url_prefix='/mail/')
+@bp.before_request
+@login_required()
+def mail_acl(): #pylint: disable=inconsistent-return-statements
+	if not mail_acl_check():
+		flash('Access denied')
+		return redirect(url_for('index'))
+
+def mail_acl_check():
+	return is_valid_session() and get_current_user().is_in_group(current_app.config['ACL_ADMIN_GROUP'])
+
+@bp.route("/")
+@register_navbar('Mail', icon='envelope', blueprint=bp, visible=mail_acl_check)
+def index():
+	conn = get_conn()
+	conn.search(current_app.config["LDAP_BASE_MAIL"], '(objectclass=postfixVirtual)')
+	mails = []
+	for i in conn.entries:
+		mails.append(Mail.from_ldap(i))
+	print(mails)
+	return render_template('mail_list.html', mails=mails)
+
+@bp.route("/<uid>")
+@bp.route("/new")
+def show(uid=None):
+	return None
+	if not uid:
+		user = User()
+		ldif = '<none yet>'
+	else:
+		conn = get_conn()
+		conn.search(current_app.config["LDAP_BASE_USER"], '(&(objectclass=person)(uidNumber={}))'.format((escape_filter_chars(uid))))
+		assert len(conn.entries) == 1
+		user = User.from_ldap(conn.entries[0])
+		ldif = conn.entries[0].entry_to_ldif()
+	return render_template('user.html', user=user, user_ldif=ldif, roles=Role.query.all())
+
+@bp.route("/<uid>/update", methods=['POST'])
+@bp.route("/new", methods=['POST'])
+@csrf_protect(blueprint=bp)
+def update(uid=False):
+	return None
+	conn = get_conn()
+	is_newuser = bool(not uid)
+	if is_newuser:
+		user = User()
+		if not user.set_loginname(request.form['loginname']):
+			flash('Login name does not meet requirements')
+			return redirect(url_for('user.show'))
+	else:
+		conn.search(current_app.config["LDAP_BASE_USER"], '(&(objectclass=person)(uidNumber={}))'.format((escape_filter_chars(uid))))
+		assert len(conn.entries) == 1
+		user = User.from_ldap(conn.entries[0])
+	if not user.set_mail(request.form['mail']):
+		flash('Mail is invalide.')
+		return redirect(url_for('user.show', uid=uid))
+	new_displayname = request.form['displayname'] if request.form['displayname'] else request.form['loginname']
+	if not user.set_displayname(new_displayname):
+		flash('Display name does not meet requirements')
+		return redirect(url_for('user.show', uid=uid))
+	new_password = request.form.get('password')
+	if new_password and not is_newuser:
+		user.set_password(new_password)
+
+	session = db.session
+	roles = Role.query.all()
+	for role in roles:
+		role_member_dns = role.member_dns()
+		if request.values.get('role-{}'.format(role.id), False) or role.name in current_app.config["ROLES_BASEROLES"]:
+			if user.dn in role_member_dns:
+				continue
+			role.add_member(user)
+		elif user.dn in role_member_dns:
+			role.del_member(user)
+	usergroups = set()
+	for role in Role.get_for_user(user).all():
+		usergroups.update(role.group_dns())
+	user.replace_group_dns(usergroups)
+
+	if user.to_ldap(new=is_newuser):
+		if is_newuser:
+			send_passwordreset(user.loginname)
+			flash('User created. We sent the user a password reset link by mail')
+			session.commit()
+		else:
+			flash('User updated')
+			session.commit()
+	else:
+		flash('Error updating user: {}'.format(conn.result['message']))
+		session.rollback()
+	return redirect(url_for('user.show', uid=user.uid))
+
+@bp.route("/<uid>/del")
+@csrf_protect(blueprint=bp)
+def delete(uid):
+	return None
+	conn = get_conn()
+	conn.search(current_app.config["LDAP_BASE_USER"], '(&(objectclass=person)(uidNumber={}))'.format((escape_filter_chars(uid))))
+	assert len(conn.entries) == 1
+	user = User.from_ldap(conn.entries[0])
+
+	session = db.session
+	roles = Role.query.all()
+	for role in roles:
+		if user.dn in role.member_dns():
+			role.del_member(user)
+
+	if conn.delete(conn.entries[0].entry_dn):
+		flash('Deleted user')
+		session.commit()
+	else:
+		flash('Could not delete user: {}'.format(conn.result['message']))
+		session.rollback()
+	return redirect(url_for('user.index'))