From 4bc7ffd0ba088b3a2c24b84889fe425f8d2368bb Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Sun, 28 Aug 2022 17:23:19 +0200
Subject: [PATCH] Multiple e-mail addresses, seperate recovery address

---
 check_migrations.py                           |    5 +-
 tests/test_api.py                             |    5 +-
 tests/test_invite.py                          |    4 +-
 tests/test_oauth2.py                          |    2 +-
 tests/test_role.py                            |    8 +-
 tests/test_selfservice.py                     |  282 ++-
 tests/test_services.py                        |   28 +-
 tests/test_signup.py                          |    6 +-
 tests/test_user.py                            |  211 +-
 tests/utils.py                                |    4 +-
 uffd/commands/user.py                         |    5 +-
 .../9f824f61d8ac_use_utc_for_datetime.py      |    2 +-
 .../b273d7fdaa25_multiple_email_addresses.py  |  141 ++
 uffd/models/__init__.py                       |    9 +-
 uffd/models/selfservice.py                    |   16 -
 uffd/models/service.py                        |    8 +-
 uffd/models/signup.py                         |    6 +-
 uffd/models/user.py                           |  140 +-
 .../selfservice/mailverification.mail.txt     |    2 +-
 uffd/templates/selfservice/self.html          |   81 +-
 uffd/templates/user/show.html                 |   93 +-
 uffd/translations/de/LC_MESSAGES/messages.mo  |  Bin 36213 -> 38331 bytes
 uffd/translations/de/LC_MESSAGES/messages.po  | 1785 +++++++++--------
 uffd/views/api.py                             |    1 +
 uffd/views/selfservice.py                     |  118 +-
 uffd/views/user.py                            |   57 +-
 26 files changed, 1892 insertions(+), 1127 deletions(-)
 create mode 100644 uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py

diff --git a/check_migrations.py b/check_migrations.py
index 1a9773ea..eb8d8bc0 100755
--- a/check_migrations.py
+++ b/check_migrations.py
@@ -8,7 +8,7 @@ import flask_migrate
 
 from uffd import create_app, db
 from uffd.models import (
-	User, Group,
+	User, UserEmail, Group,
 	RecoveryCodeMethod, TOTPMethod, WebauthnMethod,
 	Role, RoleGroup,
 	Signup,
@@ -16,7 +16,7 @@ from uffd.models import (
 	DeviceLoginConfirmation,
 	Service,
 	OAuth2Client, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation,
-	PasswordToken, MailToken,
+	PasswordToken,
 )
 
 def run_test(dburi, revision):
@@ -73,7 +73,6 @@ def run_test(dburi, revision):
 		db.session.add(OAuth2Token(user=user, client=oauth2_client, token_type='Bearer', access_token='testcode', refresh_token='testcode', expires=datetime.datetime.now()))
 		db.session.add(OAuth2DeviceLoginInitiation(client=oauth2_client, confirmations=[DeviceLoginConfirmation(user=user)]))
 		db.session.add(PasswordToken(user=user))
-		db.session.add(MailToken(user=user, newmail='test@example.com'))
 		db.session.commit()
 		flask_migrate.downgrade(revision=revision)
 		flask_migrate.upgrade(revision='head')
diff --git a/tests/test_api.py b/tests/test_api.py
index 7734da84..9200fc89 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -266,10 +266,9 @@ class TestAPIRemailerResolve(UffdTestCase):
 	def test(self):
 		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
 		service = Service.query.filter_by(name='service2').one()
-		user = self.get_user()
-		r = self.client.get(path=url_for('api.resolve_remailer', orig_address=remailer.build_address(service.id, user.id)), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		r = self.client.get(path=url_for('api.resolve_remailer', orig_address=remailer.build_address(service.id, self.get_user().id)), headers=[basic_auth('test', 'test')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
-		self.assertEqual(r.json, {'address': user.mail})
+		self.assertEqual(r.json, {'address': self.get_user().primary_email.address})
 		r = self.client.get(path=url_for('api.resolve_remailer', orig_address='foo@bar'), headers=[basic_auth('test', 'test')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 		self.assertEqual(r.json, {'address': None})
diff --git a/tests/test_invite.py b/tests/test_invite.py
index 93dea192..d9bc44db 100644
--- a/tests/test_invite.py
+++ b/tests/test_invite.py
@@ -191,7 +191,7 @@ class TestInviteSignupModel(UffdTestCase):
 		self.assertTrue(invite.used)
 		self.assertEqual(user.loginname, 'newuser')
 		self.assertEqual(user.displayname, 'New User')
-		self.assertEqual(user.mail, 'test@example.com')
+		self.assertEqual(user.primary_email.address, 'test@example.com')
 		self.assertEqual(signup.user, user)
 		self.assertIn(base_role, user.roles_effective)
 		self.assertIn(role1, user.roles)
@@ -219,7 +219,7 @@ class TestInviteSignupModel(UffdTestCase):
 		self.assertTrue(invite.used)
 		self.assertEqual(user.loginname, 'newuser')
 		self.assertEqual(user.displayname, 'New User')
-		self.assertEqual(user.mail, 'test@example.com')
+		self.assertEqual(user.primary_email.address, 'test@example.com')
 		self.assertEqual(signup.user, user)
 		self.assertIn(base_role, user.roles_effective)
 		self.assertEqual(len(user.roles_effective), 1)
diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py
index 990e2c13..1dea4074 100644
--- a/tests/test_oauth2.py
+++ b/tests/test_oauth2.py
@@ -39,7 +39,7 @@ class TestViews(UffdTestCase):
 		self.assertEqual(r.json['id'], user.unix_uid)
 		self.assertEqual(r.json['name'], user.displayname)
 		self.assertEqual(r.json['nickname'], user.loginname)
-		self.assertEqual(r.json['email'], mail or user.mail)
+		self.assertEqual(r.json['email'], mail or user.primary_email.address)
 		self.assertTrue(r.json.get('groups'))
 
 	def test_authorization(self):
diff --git a/tests/test_role.py b/tests/test_role.py
index c02b862c..3283d273 100644
--- a/tests/test_role.py
+++ b/tests/test_role.py
@@ -32,7 +32,7 @@ class TestPrimitives(unittest.TestCase):
 
 class TestUserRoleAttributes(UffdTestCase):
 	def test_roles_effective(self):
-		db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
+		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
 		db.session.commit()
 		user = self.get_user()
 		service_user = User.query.filter_by(loginname='service').one_or_none()
@@ -82,7 +82,7 @@ class TestUserRoleAttributes(UffdTestCase):
 
 class TestRoleModel(UffdTestCase):
 	def test_members_effective(self):
-		db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
+		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
 		db.session.commit()
 		user1 = self.get_user()
 		user2 = self.get_admin()
@@ -229,7 +229,7 @@ class TestRoleViews(UffdTestCase):
 		# TODO: verify that group memberships are updated (currently not possible with ldap mock!)
 
 	def test_set_default(self):
-		db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
+		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
 		db.session.commit()
 		role = Role(name='test')
 		db.session.add(role)
@@ -261,7 +261,7 @@ class TestRoleViews(UffdTestCase):
 		admin_role = Role(name='admin', is_default=True)
 		db.session.add(admin_role)
 		admin_role.groups[self.get_admin_group()] = RoleGroup()
-		db.session.add(User(loginname='service', is_service_user=True, mail='service@example.com', displayname='Service'))
+		db.session.add(User(loginname='service', is_service_user=True, primary_email_address='service@example.com', displayname='Service'))
 		db.session.commit()
 		role = Role(name='test', is_default=True)
 		db.session.add(role)
diff --git a/tests/test_selfservice.py b/tests/test_selfservice.py
index 760b7a44..b31e579e 100644
--- a/tests/test_selfservice.py
+++ b/tests/test_selfservice.py
@@ -1,10 +1,11 @@
 import datetime
+import re
 import unittest
 
 from flask import url_for, request
 
 from uffd import create_app, db
-from uffd.models import MailToken, PasswordToken, User, Role, RoleGroup
+from uffd.models import PasswordToken, User, UserEmail, Role, RoleGroup
 
 from utils import dump, UffdTestCase
 
@@ -18,13 +19,13 @@ class TestSelfservice(UffdTestCase):
 		user = request.user
 		self.assertIn(user.displayname.encode(), r.data)
 		self.assertIn(user.loginname.encode(), r.data)
-		self.assertIn(user.mail.encode(), r.data)
+		self.assertIn(user.primary_email.address.encode(), r.data)
 
 	def test_update_displayname(self):
 		self.login_as('user')
 		user = request.user
 		r = self.client.post(path=url_for('selfservice.update_profile'),
-			data={'displayname': 'New Display Name', 'mail': user.mail},
+			data={'displayname': 'New Display Name'},
 			follow_redirects=True)
 		dump('update_displayname', r)
 		self.assertEqual(r.status_code, 200)
@@ -35,43 +36,209 @@ class TestSelfservice(UffdTestCase):
 		self.login_as('user')
 		user = request.user
 		r = self.client.post(path=url_for('selfservice.update_profile'),
-			data={'displayname': '', 'mail': user.mail},
+			data={'displayname': ''},
 			follow_redirects=True)
 		dump('update_displayname_invalid', r)
 		self.assertEqual(r.status_code, 200)
 		_user = request.user
 		self.assertNotEqual(_user.displayname, '')
 
-	def test_update_mail(self):
+	def test_add_email(self):
 		self.login_as('user')
-		user = request.user
-		r = self.client.post(path=url_for('selfservice.update_profile'),
-			data={'displayname': user.displayname, 'mail': 'newemail@example.com'},
+		r = self.client.post(path=url_for('selfservice.add_email'),
+			data={'address': 'new@example.com'},
 			follow_redirects=True)
-		dump('update_mail', r)
+		dump('selfservice_add_email', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertNotEqual(_user.mail, 'newemail@example.com')
-		token = MailToken.query.filter(MailToken.user == user).first()
-		self.assertEqual(token.newmail, 'newemail@example.com')
-		self.assertIn(token.token, str(self.app.last_mail.get_content()))
-		r = self.client.get(path=url_for('selfservice.token_mail', token_id=token.id, token=token.token), follow_redirects=True)
+		self.assertIn('new@example.com', self.app.last_mail['To'])
+		m = re.search(r'/email/([0-9]+)/verify/(.*)', str(self.app.last_mail.get_content()))
+		email_id, secret = m.groups()
+		email = UserEmail.query.get(email_id)
+		self.assertEqual(email.user, request.user)
+		self.assertEqual(email.address, 'new@example.com')
+		self.assertFalse(email.verified)
+		self.assertFalse(email.verification_expired)
+		self.assertTrue(email.verification_secret.verify(secret))
+
+	def test_add_email_duplicate(self):
+		self.login_as('user')
+		r = self.client.post(path=url_for('selfservice.add_email'),
+			data={'address': 'test@example.com'},
+			follow_redirects=True)
+		dump('selfservice_add_email_duplicate', r)
+		self.assertFalse(hasattr(self.app, 'last_mail'))
+		self.assertEqual(len(self.get_user().all_emails), 1)
+		self.assertEqual(UserEmail.query.filter_by(user=None).all(), [])
+
+	def test_verify_email(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_user(), address='new@example.com')
+		secret = email.start_verification()
+		db.session.add(email)
+		db.session.commit()
+		email_id = email.id
+		r = self.client.get(path=url_for('selfservice.verify_email', email_id=email_id, secret=secret), follow_redirects=True)
+		dump('selfservice_verify_email', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertEqual(_user.mail, 'newemail@example.com')
+		email = UserEmail.query.get(email_id)
+		self.assertTrue(email.verified)
+		self.assertEqual(self.get_user().primary_email.address, 'test@example.com')
 
-	def test_update_mail_sendfailure(self):
-		self.app.config['MAIL_SKIP_SEND'] = 'fail'
+	def test_verify_email_notfound(self):
 		self.login_as('user')
-		user = request.user
-		r = self.client.post(path=url_for('selfservice.update_profile'),
-			data={'displayname': user.displayname, 'mail': 'newemail@example.com'},
+		r = self.client.get(path=url_for('selfservice.verify_email', email_id=2342, secret='invalidsecret'), follow_redirects=True)
+		dump('selfservice_verify_email_notfound', r)
+
+	def test_verify_email_wrong_user(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_admin(), address='new@example.com')
+		secret = email.start_verification()
+		db.session.add(email)
+		db.session.commit()
+		r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret=secret), follow_redirects=True)
+		dump('selfservice_verify_email_wrong_user', r)
+		self.assertFalse(email.verified)
+
+	def test_verify_email_wrong_secret(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_user(), address='new@example.com')
+		secret = email.start_verification()
+		db.session.add(email)
+		db.session.commit()
+		r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret='invalidsecret'), follow_redirects=True)
+		dump('selfservice_verify_email_wrong_secret', r)
+		self.assertFalse(email.verified)
+
+	def test_verify_email_expired(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_user(), address='new@example.com')
+		secret = email.start_verification()
+		email.verification_expires = datetime.datetime.utcnow() - datetime.timedelta(days=1)
+		db.session.add(email)
+		db.session.commit()
+		r = self.client.get(path=url_for('selfservice.verify_email', email_id=email.id, secret='invalidsecret'), follow_redirects=True)
+		dump('selfservice_verify_email_expired', r)
+		self.assertFalse(email.verified)
+
+	def test_verify_email_legacy(self):
+		self.login_as('user')
+		email = UserEmail(
+			user=self.get_user(),
+			address='new@example.com',
+			verification_legacy_id=1337,
+			_verification_secret='{PLAIN}ZgvsUs2bZjr9Whpy1la7Q0PHbhjmpXtNdH1mCmDbQP7',
+			verification_expires=datetime.datetime.utcnow()+datetime.timedelta(days=1)
+		)
+		db.session.add(email)
+		db.session.commit()
+		email_id = email.id
+		r = self.client.get(path=f'/self/token/mail_verification/1337/ZgvsUs2bZjr9Whpy1la7Q0PHbhjmpXtNdH1mCmDbQP7', follow_redirects=True)
+		dump('selfservice_verify_email_legacy', r)
+		self.assertEqual(r.status_code, 200)
+		email = UserEmail.query.get(email_id)
+		self.assertTrue(email.verified)
+		self.assertEqual(self.get_user().primary_email, email)
+
+	def test_retry_email_verification(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_user(), address='new@example.com')
+		old_secret = email.start_verification()
+		db.session.add(email)
+		db.session.commit()
+		r = self.client.get(path=url_for('selfservice.retry_email_verification', email_id=email.id), follow_redirects=True)
+		dump('selfservice_retry_email_verification', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIn('new@example.com', self.app.last_mail['To'])
+		m = re.search(r'/email/([0-9]+)/verify/(.*)', str(self.app.last_mail.get_content()))
+		email_id, secret = m.groups()
+		email = UserEmail.query.get(email_id)
+		self.assertEqual(email.user, request.user)
+		self.assertEqual(email.address, 'new@example.com')
+		self.assertFalse(email.verified)
+		self.assertFalse(email.verification_expired)
+		self.assertTrue(email.verification_secret.verify(secret))
+		self.assertFalse(email.verification_secret.verify(old_secret))
+
+	def test_delete_email(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_user(), address='new@example.com', verified=True)
+		db.session.add(email)
+		self.get_user().recovery_email = email
+		db.session.commit()
+		r = self.client.post(path=url_for('selfservice.delete_email', email_id=email.id), follow_redirects=True)
+		dump('selfservice_delete_email', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIsNone(UserEmail.query.filter_by(address='new@example.com').first())
+		self.assertIsNone(self.get_user().recovery_email)
+		self.assertEqual(self.get_user().primary_email.address, 'test@example.com')
+
+	def test_delete_email_invalid(self):
+		self.login_as('user')
+		r = self.client.post(path=url_for('selfservice.delete_email', email_id=2324), follow_redirects=True)
+		self.assertEqual(r.status_code, 404)
+
+	def test_delete_email_primary(self):
+		self.login_as('user')
+		r = self.client.post(path=url_for('selfservice.delete_email', email_id=request.user.primary_email.id), follow_redirects=True)
+		dump('selfservice_delete_email_primary', r)
+		self.assertEqual(self.get_user().primary_email.address, 'test@example.com')
+
+	def test_update_email_preferences(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_user(), address='new@example.com', verified=True)
+		db.session.add(email)
+		db.session.commit()
+		email_id = email.id
+		old_email_id = self.get_user().primary_email.id
+		r = self.client.post(path=url_for('selfservice.update_email_preferences'),
+			data={'primary_email': str(email_id), 'recovery_email': 'primary'},
 			follow_redirects=True)
-		dump('update_mail_sendfailure', r)
+		dump('selfservice_update_email_preferences', r)
 		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertNotEqual(_user.mail, 'newemail@example.com')
-		# Maybe also check that there is no new token in the db
+		self.assertEqual(self.get_user().primary_email.id, email.id)
+		self.assertIsNone(self.get_user().recovery_email)
+		r = self.client.post(path=url_for('selfservice.update_email_preferences'),
+			data={'primary_email': str(old_email_id), 'recovery_email': str(email_id)},
+			follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(self.get_user().primary_email.id, old_email_id)
+		self.assertEqual(self.get_user().recovery_email.id, email_id)
+
+	def test_update_email_preferences_unverified(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_user(), address='new@example.com')
+		db.session.add(email)
+		db.session.commit()
+		email_id = email.id
+		old_email_id = self.get_user().primary_email.id
+		r = self.client.post(path=url_for('selfservice.update_email_preferences'),
+			data={'primary_email': str(email_id), 'recovery_email': 'primary'},
+			follow_redirects=True)
+		self.assertEqual(r.status_code, 400)
+		self.assertEqual(self.get_user().primary_email.address, 'test@example.com')
+		r = self.client.post(path=url_for('selfservice.update_email_preferences'),
+			data={'primary_email': str(old_email_id), 'recovery_email': str(email_id)},
+			follow_redirects=True)
+		self.assertEqual(r.status_code, 400)
+		self.assertIsNone(self.get_user().recovery_email)
+
+	def test_update_email_preferences_invalid(self):
+		self.login_as('user')
+		email = UserEmail(user=self.get_user(), address='new@example.com', verified=True)
+		db.session.add(email)
+		db.session.commit()
+		r = self.client.post(path=url_for('selfservice.update_email_preferences'),
+			data={'primary_email': str(email.id), 'recovery_email': '2342'},
+			follow_redirects=True)
+		self.assertEqual(r.status_code, 400)
+		r = self.client.post(path=url_for('selfservice.update_email_preferences'),
+			data={'primary_email': 'primary', 'recovery_email': 'primary'},
+			follow_redirects=True)
+		self.assertEqual(r.status_code, 400)
+		r = self.client.post(path=url_for('selfservice.update_email_preferences'),
+			data={'primary_email': '2342', 'recovery_email': 'primary'},
+			follow_redirects=True)
+		self.assertEqual(r.status_code, 400)
 
 	def test_change_password(self):
 		self.login_as('user')
@@ -141,70 +308,14 @@ class TestSelfservice(UffdTestCase):
 		self.assertEqual(len(_user.roles), 1)
 		self.assertEqual(list(_user.roles)[0].name, 'testrole2')
 
-	def test_token_mail_emptydb(self):
-		self.login_as('user')
-		user = request.user
-		r = self.client.get(path=url_for('selfservice.token_mail', token_id=1, token='A'*128), follow_redirects=True)
-		dump('token_mail_emptydb', r)
-		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertEqual(_user.mail, user.mail)
-
-	def test_token_mail_invalid(self):
-		self.login_as('user')
-		user = request.user
-		old_mail = user.mail
-		token = MailToken(user=user, newmail='newusermail@example.com')
-		db.session.add(token)
-		db.session.commit()
-		r = self.client.get(path=url_for('selfservice.token_mail', token_id=token.id, token='A'*128), follow_redirects=True)
-		dump('token_mail_invalid', r)
-		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertEqual(_user.mail, old_mail)
-
-	def test_token_mail_wrong_user(self):
-		self.login_as('user')
-		user = request.user
-		old_mail = user.mail
-		admin_user = self.get_admin()
-		old_admin_mail = admin_user.mail
-		db.session.add(MailToken(user=user, newmail='newusermail@example.com'))
-		admin_token = MailToken(user=admin_user, newmail='newadminmail@example.com')
-		db.session.add(admin_token)
-		db.session.commit()
-		r = self.client.get(path=url_for('selfservice.token_mail', token_id=admin_token.id, token=admin_token.token), follow_redirects=True)
-		dump('token_mail_wrong_user', r)
-		self.assertEqual(r.status_code, 403)
-		_user = self.get_user()
-		_admin_user = self.get_admin()
-		self.assertEqual(_user.mail, old_mail)
-		self.assertEqual(_admin_user.mail, old_admin_mail)
-
-	def test_token_mail_expired(self):
-		self.login_as('user')
-		user = request.user
-		old_mail = user.mail
-		token = MailToken(user=user, newmail='newusermail@example.com',
-			created=(datetime.datetime.utcnow() - datetime.timedelta(days=10)))
-		db.session.add(token)
-		db.session.commit()
-		r = self.client.get(path=url_for('selfservice.token_mail', token_id=token.id, token=token.token), follow_redirects=True)
-		dump('token_mail_expired', r)
-		self.assertEqual(r.status_code, 200)
-		_user = request.user
-		self.assertEqual(_user.mail, old_mail)
-		tokens = MailToken.query.filter(MailToken.user == _user).all()
-		self.assertEqual(len(tokens), 1)
-		self.assertTrue(tokens[0].expired)
-
 	def test_forgot_password(self):
 		user = self.get_user()
 		r = self.client.get(path=url_for('selfservice.forgot_password'))
 		dump('forgot_password', r)
 		self.assertEqual(r.status_code, 200)
+		user = self.get_user()
 		r = self.client.post(path=url_for('selfservice.forgot_password'),
-			data={'loginname': user.loginname, 'mail': user.mail}, follow_redirects=True)
+			data={'loginname': user.loginname, 'mail': user.primary_email.address}, follow_redirects=True)
 		dump('forgot_password_submit', r)
 		self.assertEqual(r.status_code, 200)
 		token = PasswordToken.query.filter(PasswordToken.user == user).first()
@@ -215,8 +326,9 @@ class TestSelfservice(UffdTestCase):
 		user = self.get_user()
 		r = self.client.get(path=url_for('selfservice.forgot_password'))
 		self.assertEqual(r.status_code, 200)
+		user = self.get_user()
 		r = self.client.post(path=url_for('selfservice.forgot_password'),
-			data={'loginname': 'not_a_user', 'mail': user.mail}, follow_redirects=True)
+			data={'loginname': 'not_a_user', 'mail': user.primary_email.address}, follow_redirects=True)
 		dump('forgot_password_submit_wrong_user', r)
 		self.assertEqual(r.status_code, 200)
 		self.assertFalse(hasattr(self.app, 'last_mail'))
diff --git a/tests/test_services.py b/tests/test_services.py
index 643b1b45..0c697023 100644
--- a/tests/test_services.py
+++ b/tests/test_services.py
@@ -19,14 +19,14 @@ class TestServiceUser(UffdTestCase):
 		service_count = Service.query.count()
 		user_count = User.query.count()
 		self.assertEqual(ServiceUser.query.count(), service_count * user_count)
-		db.session.add(User(loginname='newuser1', displayname='New User', mail='new1@example.com'))
+		db.session.add(User(loginname='newuser1', displayname='New User', primary_email_address='new1@example.com'))
 		db.session.commit()
 		self.assertEqual(ServiceUser.query.count(), service_count * (user_count + 1))
 		db.session.add(Service(name='service3'))
 		db.session.commit()
 		self.assertEqual(ServiceUser.query.count(), (service_count + 1) * (user_count + 1))
-		db.session.add(User(loginname='newuser2', displayname='New User', mail='new2@example.com'))
-		db.session.add(User(loginname='newuser3', displayname='New User', mail='new3@example.com'))
+		db.session.add(User(loginname='newuser2', displayname='New User', primary_email_address='new2@example.com'))
+		db.session.add(User(loginname='newuser3', displayname='New User', primary_email_address='new3@example.com'))
 		db.session.add(Service(name='service4'))
 		db.session.add(Service(name='service5'))
 		db.session.commit()
@@ -47,7 +47,7 @@ class TestServiceUser(UffdTestCase):
 		user = self.get_user()
 		service = Service.query.filter_by(name='service1').first()
 		service_user = ServiceUser.query.get((service.id, user.id))
-		self.assertEqual(service_user.real_email, user.mail)
+		self.assertEqual(service_user.real_email, user.primary_email.address)
 
 	def test_remailer_email(self):
 		user = self.get_user()
@@ -66,12 +66,12 @@ class TestServiceUser(UffdTestCase):
 		remailer_email = remailer.build_address(service.id, user.id)
 		# 1. remailer not setup
 		self.app.config['REMAILER_DOMAIN'] = ''
-		self.assertIsNone(ServiceUser.get_by_remailer_email(user.mail))
+		self.assertIsNone(ServiceUser.get_by_remailer_email(user.primary_email.address))
 		self.assertIsNone(ServiceUser.get_by_remailer_email(remailer_email))
 		self.assertIsNone(ServiceUser.get_by_remailer_email('invalid'))
 		# 2. remailer setup
 		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		self.assertIsNone(ServiceUser.get_by_remailer_email(user.mail))
+		self.assertIsNone(ServiceUser.get_by_remailer_email(user.primary_email.address))
 		self.assertEqual(ServiceUser.get_by_remailer_email(remailer_email), service_user)
 		self.assertIsNone(ServiceUser.get_by_remailer_email('invalid'))
 
@@ -83,17 +83,17 @@ class TestServiceUser(UffdTestCase):
 		remailer_email = remailer.build_address(service.id, user.id)
 		# 1. remailer not setup
 		self.app.config['REMAILER_DOMAIN'] = ''
-		self.assertEqual(service_user.email, user.mail)
+		self.assertEqual(service_user.email, user.primary_email.address)
 		# 2. remailer setup + service.use_remailer disabled
 		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		self.assertEqual(service_user.email, user.mail)
+		self.assertEqual(service_user.email, user.primary_email.address)
 		# 3. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS unset
 		service.use_remailer = True
 		db.session.commit()
 		self.assertEqual(service_user.email, remailer_email)
 		# 4. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS does not include user
 		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin']
-		self.assertEqual(service_user.email, user.mail)
+		self.assertEqual(service_user.email, user.primary_email.address)
 		# 5. remailer setup + service.use_remailer enabled + REMAILER_LIMIT_TO_USERS includes user
 		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser']
 		self.assertEqual(service_user.email, remailer_email)
@@ -103,7 +103,7 @@ class TestServiceUser(UffdTestCase):
 			return {(su.service_id, su.user_id) for su in ServiceUser.filter_query_by_email(ServiceUser.query, value)}
 
 		user1 = self.get_user()
-		user2 = User(loginname='user2', mail=user1.mail, displayname='User 2')
+		user2 = User(loginname='user2', primary_email_address=user1.primary_email.address, displayname='User 2')
 		db.session.add(user2)
 		db.session.commit()
 		service1 = Service.query.filter_by(name='service1').first() # use_remailer=False
@@ -116,7 +116,7 @@ class TestServiceUser(UffdTestCase):
 
 		# 1. remailer disabled
 		self.app.config['REMAILER_DOMAIN'] = ''
-		self.assertEqual(run_query(user1.mail), {
+		self.assertEqual(run_query(user1.primary_email.address), {
 			(service1.id, user1.id), (service1.id, user2.id),
 			(service2.id, user1.id), (service2.id, user2.id),
 		})
@@ -126,7 +126,7 @@ class TestServiceUser(UffdTestCase):
 
 		# 2. remailer enabled + REMAILER_LIMIT_TO_USERS unset
 		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		self.assertEqual(run_query(user1.mail), {
+		self.assertEqual(run_query(user1.primary_email.address), {
 			(service1.id, user1.id), (service1.id, user2.id),
 		})
 		self.assertEqual(run_query(remailer_email1_1), set())
@@ -138,7 +138,7 @@ class TestServiceUser(UffdTestCase):
 
 		# 3. remailer enabled + REMAILER_LIMIT_TO_USERS includes testuser
 		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser']
-		self.assertEqual(run_query(user1.mail), {
+		self.assertEqual(run_query(user1.primary_email.address), {
 			(service1.id, user1.id), (service1.id, user2.id),
 			(service2.id, user2.id),
 		})
@@ -153,7 +153,7 @@ class TestServiceUser(UffdTestCase):
 
 		# 4. remailer enabled + REMAILER_LIMIT_TO_USERS does not include user (should behave the same as 1.)
 		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin']
-		self.assertEqual(run_query(user1.mail), {
+		self.assertEqual(run_query(user1.primary_email.address), {
 			(service1.id, user1.id), (service1.id, user2.id),
 			(service2.id, user1.id), (service2.id, user2.id),
 		})
diff --git a/tests/test_signup.py b/tests/test_signup.py
index cfb599d5..9affe7f9 100644
--- a/tests/test_signup.py
+++ b/tests/test_signup.py
@@ -122,7 +122,7 @@ class TestSignupModel(UffdTestCase):
 		user = User.query.filter_by(loginname='newuser').one_or_none()
 		self.assertEqual(user.loginname, 'newuser')
 		self.assertEqual(user.displayname, 'New User')
-		self.assertEqual(user.mail, 'test@example.com')
+		self.assertEqual(user.primary_email.address, 'test@example.com')
 
 	def test_finish_completed(self):
 		signup = Signup(loginname='newuser', displayname='New User', mail='test@example.com', password='notsecret')
@@ -171,7 +171,7 @@ class TestSignupModel(UffdTestCase):
 		signup = Signup.query.get(signup1_id)
 		self.assert_finish_failure(signup, 'notsecret')
 		user = User.query.filter_by(loginname='newuser').one_or_none()
-		self.assertEqual(user.mail, 'test2@example.com')
+		self.assertEqual(user.primary_email.address, 'test2@example.com')
 
 class TestSignupViews(UffdTestCase):
 	def setUpApp(self):
@@ -333,7 +333,7 @@ class TestSignupViews(UffdTestCase):
 		self.assertTrue(signup.completed)
 		self.assertEqual(signup.user.loginname, 'newuser')
 		self.assertEqual(signup.user.displayname, 'New User')
-		self.assertEqual(signup.user.mail, 'test@example.com')
+		self.assertEqual(signup.user.primary_email.address, 'test@example.com')
 		self.assertIsNotNone(login_get_user('newuser', 'notsecret'))
 
 	def test_confirm_loggedin(self):
diff --git a/tests/test_user.py b/tests/test_user.py
index 190234d0..b023510c 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -6,7 +6,7 @@ import sqlalchemy
 
 from uffd import create_app, db
 from uffd.remailer import remailer
-from uffd.models import User, Group, Role, RoleGroup, Service
+from uffd.models import User, UserEmail, Group, Role, RoleGroup, Service
 
 from utils import dump, UffdTestCase
 
@@ -42,9 +42,9 @@ class TestUserModel(UffdTestCase):
 		self.app.config['USER_SERVICE_MAX_UID'] =19999
 		User.query.delete()
 		db.session.commit()
-		user0 = User(loginname='user0', displayname='user0', mail='user0@example.com')
-		user1 = User(loginname='user1', displayname='user1', mail='user1@example.com')
-		user2 = User(loginname='user2', displayname='user2', mail='user2@example.com')
+		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
+		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
+		user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com')
 		db.session.add_all([user0, user1, user2])
 		db.session.commit()
 		self.assertEqual(user0.unix_uid, 10000)
@@ -52,12 +52,12 @@ class TestUserModel(UffdTestCase):
 		self.assertEqual(user2.unix_uid, 10002)
 		db.session.delete(user1)
 		db.session.commit()
-		user3 = User(loginname='user3', displayname='user3', mail='user3@example.com')
+		user3 = User(loginname='user3', displayname='user3', primary_email_address='user3@example.com')
 		db.session.add(user3)
 		db.session.commit()
 		self.assertEqual(user3.unix_uid, 10003)
-		service0 = User(loginname='service0', displayname='service0', mail='service0@example.com', is_service_user=True)
-		service1 = User(loginname='service1', displayname='service1', mail='service1@example.com', is_service_user=True)
+		service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True)
+		service1 = User(loginname='service1', displayname='service1', primary_email_address='service1@example.com', is_service_user=True)
 		db.session.add_all([service0, service1])
 		db.session.commit()
 		self.assertEqual(service0.unix_uid, 19000)
@@ -70,9 +70,9 @@ class TestUserModel(UffdTestCase):
 		self.app.config['USER_SERVICE_MAX_UID'] = 19999
 		User.query.delete()
 		db.session.commit()
-		user0 = User(loginname='user0', displayname='user0', mail='user0@example.com')
-		service0 = User(loginname='service0', displayname='service0', mail='service0@example.com', is_service_user=True)
-		user1 = User(loginname='user1', displayname='user1', mail='user1@example.com')
+		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
+		service0 = User(loginname='service0', displayname='service0', primary_email_address='service0@example.com', is_service_user=True)
+		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
 		db.session.add_all([user0, service0, user1])
 		db.session.commit()
 		self.assertEqual(user0.unix_uid, 10000)
@@ -84,31 +84,74 @@ class TestUserModel(UffdTestCase):
 		self.app.config['USER_MAX_UID'] = 10001
 		User.query.delete()
 		db.session.commit()
-		user0 = User(loginname='user0', displayname='user0', mail='user0@example.com')
-		user1 = User(loginname='user1', displayname='user1', mail='user1@example.com')
+		user0 = User(loginname='user0', displayname='user0', primary_email_address='user0@example.com')
+		user1 = User(loginname='user1', displayname='user1', primary_email_address='user1@example.com')
 		db.session.add_all([user0, user1])
 		db.session.commit()
 		self.assertEqual(user0.unix_uid, 10000)
 		self.assertEqual(user1.unix_uid, 10001)
 		with self.assertRaises(sqlalchemy.exc.IntegrityError):
-			user2 = User(loginname='user2', displayname='user2', mail='user2@example.com')
+			user2 = User(loginname='user2', displayname='user2', primary_email_address='user2@example.com')
 			db.session.add(user2)
 			db.session.commit()
 
-	def test_set_mail(self):
+	def test_init_primary_email_address(self):
+		user = User(primary_email_address='foobar@example.com')
+		self.assertEqual(user.primary_email.address, 'foobar@example.com')
+		self.assertEqual(user.primary_email.verified, True)
+		self.assertEqual(user.primary_email.user, user)
+		user = User(primary_email_address='invalid')
+		self.assertEqual(user.primary_email.address, 'invalid')
+		self.assertEqual(user.primary_email.verified, True)
+		self.assertEqual(user.primary_email.user, user)
+
+	def test_set_primary_email_address(self):
 		user = User()
-		self.assertTrue(user.set_mail('foobar@example.com'))
-		self.assertEqual(user.mail, 'foobar@example.com')
-		self.assertFalse(user.set_mail(''))
-		self.assertEqual(user.mail, 'foobar@example.com')
-		self.assertFalse(user.set_mail('foobar'))
-		self.assertFalse(user.set_mail('@'))
+		self.assertFalse(user.set_primary_email_address('invalid'))
+		self.assertIsNone(user.primary_email)
+		self.assertEqual(len(user.all_emails), 0)
+		self.assertTrue(user.set_primary_email_address('foobar@example.com'))
+		self.assertEqual(user.primary_email.address, 'foobar@example.com')
+		self.assertEqual(len(user.all_emails), 1)
+		self.assertFalse(user.set_primary_email_address('invalid'))
+		self.assertEqual(user.primary_email.address, 'foobar@example.com')
+		self.assertEqual(len(user.all_emails), 1)
+		self.assertTrue(user.set_primary_email_address('other@example.com'))
+		self.assertEqual(user.primary_email.address, 'other@example.com')
+		self.assertEqual(len(user.all_emails), 2)
+		self.assertEqual({user.all_emails[0].address, user.all_emails[1].address}, {'foobar@example.com', 'other@example.com'})
+
+class TestUserEmailModel(UffdTestCase):
+	def test_set_address(self):
+		email = UserEmail()
+		self.assertFalse(email.set_address('invalid'))
+		self.assertIsNone(email.address)
+		self.assertFalse(email.set_address(''))
+		self.assertFalse(email.set_address('@'))
 		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		self.assertFalse(user.set_mail('foobar@remailer.example.com'))
-		self.assertFalse(user.set_mail('v1-1-testuser@remailer.example.com'))
-		self.assertFalse(user.set_mail('v1-1-testuser @ remailer.example.com'))
-		self.assertFalse(user.set_mail('v1-1-testuser@REMAILER.example.com'))
-		self.assertFalse(user.set_mail('v1-1-testuser@foobar@remailer.example.com'))
+		self.assertFalse(email.set_address('foobar@remailer.example.com'))
+		self.assertFalse(email.set_address('v1-1-testuser@remailer.example.com'))
+		self.assertFalse(email.set_address('v1-1-testuser @ remailer.example.com'))
+		self.assertFalse(email.set_address('v1-1-testuser@REMAILER.example.com'))
+		self.assertFalse(email.set_address('v1-1-testuser@foobar@remailer.example.com'))
+		self.assertTrue(email.set_address('foobar@example.com'))
+		self.assertEqual(email.address, 'foobar@example.com')
+
+	def test_verification(self):
+		email = UserEmail(address='foo@example.com')
+		self.assertFalse(email.finish_verification('test'))
+		secret = email.start_verification()
+		self.assertTrue(email.verification_secret)
+		self.assertTrue(email.verification_secret.verify(secret))
+		self.assertFalse(email.verification_expired)
+		self.assertFalse(email.finish_verification('test'))
+		orig_expires = email.verification_expires
+		email.verification_expires = datetime.datetime.utcnow() - datetime.timedelta(days=1)
+		self.assertFalse(email.finish_verification(secret))
+		email.verification_expires = orig_expires
+		self.assertTrue(email.finish_verification(secret))
+		self.assertFalse(email.verification_secret)
+		self.assertTrue(email.verification_expired)
 
 class TestUserViews(UffdTestCase):
 	def setUp(self):
@@ -133,7 +176,7 @@ class TestUserViews(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none())
 		r = self.client.post(path=url_for('user.update'),
-			data={'loginname': 'newuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'New User',
 			f'role-{role1_id}': '1', 'password': 'newpassword'}, follow_redirects=True)
 		dump('user_new_submit', r)
 		self.assertEqual(r.status_code, 200)
@@ -143,7 +186,7 @@ class TestUserViews(UffdTestCase):
 		self.assertFalse(user_.is_service_user)
 		self.assertEqual(user_.loginname, 'newuser')
 		self.assertEqual(user_.displayname, 'New User')
-		self.assertEqual(user_.mail, 'newuser@example.com')
+		self.assertEqual(user_.primary_email.address, 'newuser@example.com')
 		self.assertGreaterEqual(user_.unix_uid, self.app.config['USER_MIN_UID'])
 		self.assertLessEqual(user_.unix_uid, self.app.config['USER_MAX_UID'])
 		role1 = Role(name='role1')
@@ -163,7 +206,7 @@ class TestUserViews(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertIsNone(User.query.filter_by(loginname='newuser').one_or_none())
 		r = self.client.post(path=url_for('user.update'),
-			data={'loginname': 'newuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'New User',
 			f'role-{role1_id}': '1', 'password': 'newpassword', 'serviceaccount': '1'}, follow_redirects=True)
 		dump('user_new_submit', r)
 		self.assertEqual(r.status_code, 200)
@@ -173,7 +216,7 @@ class TestUserViews(UffdTestCase):
 		self.assertTrue(user.is_service_user)
 		self.assertEqual(user.loginname, 'newuser')
 		self.assertEqual(user.displayname, 'New User')
-		self.assertEqual(user.mail, 'newuser@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser@example.com')
 		self.assertTrue(user.unix_uid)
 		role1 = Role(name='role1')
 		self.assertEqual(roles, ['role1'])
@@ -181,7 +224,7 @@ class TestUserViews(UffdTestCase):
 
 	def test_new_invalid_loginname(self):
 		r = self.client.post(path=url_for('user.update'),
-			data={'loginname': '!newuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			data={'loginname': '!newuser', 'email': 'newuser@example.com', 'displayname': 'New User',
 			'password': 'newpassword'}, follow_redirects=True)
 		dump('user_new_invalid_loginname', r)
 		self.assertEqual(r.status_code, 200)
@@ -189,7 +232,7 @@ class TestUserViews(UffdTestCase):
 
 	def test_new_empty_loginname(self):
 		r = self.client.post(path=url_for('user.update'),
-			data={'loginname': '', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			data={'loginname': '', 'email': 'newuser@example.com', 'displayname': 'New User',
 			'password': 'newpassword'}, follow_redirects=True)
 		dump('user_new_empty_loginname', r)
 		self.assertEqual(r.status_code, 200)
@@ -197,7 +240,7 @@ class TestUserViews(UffdTestCase):
 
 	def test_new_empty_email(self):
 		r = self.client.post(path=url_for('user.update'),
-			data={'loginname': 'newuser', 'mail': '', 'displayname': 'New User',
+			data={'loginname': 'newuser', 'email': '', 'displayname': 'New User',
 			'password': 'newpassword'}, follow_redirects=True)
 		dump('user_new_empty_email', r)
 		self.assertEqual(r.status_code, 200)
@@ -205,7 +248,7 @@ class TestUserViews(UffdTestCase):
 
 	def test_new_invalid_display_name(self):
 		r = self.client.post(path=url_for('user.update'),
-			data={'loginname': 'newuser', 'mail': 'newuser@example.com', 'displayname': 'A'*200,
+			data={'loginname': 'newuser', 'email': 'newuser@example.com', 'displayname': 'A'*200,
 			'password': 'newpassword'}, follow_redirects=True)
 		dump('user_new_invalid_display_name', r)
 		self.assertEqual(r.status_code, 200)
@@ -213,6 +256,7 @@ class TestUserViews(UffdTestCase):
 
 	def test_update(self):
 		user_unupdated = self.get_user()
+		email_id = str(user_unupdated.primary_email.id)
 		db.session.add(Role(name='base', is_default=True))
 		role1 = Role(name='role1')
 		db.session.add(role1)
@@ -225,14 +269,16 @@ class TestUserViews(UffdTestCase):
 		dump('user_update', r)
 		self.assertEqual(r.status_code, 200)
 		r = self.client.post(path=url_for('user.update', id=user_unupdated.id),
-			data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
-			f'role-{role1_id}': '1', 'password': ''}, follow_redirects=True)
+			data={'loginname': 'testuser',
+			f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary',
+			'displayname': 'New User', f'role-{role1_id}': '1', 'password': ''},
+			follow_redirects=True)
 		dump('user_update_submit', r)
 		self.assertEqual(r.status_code, 200)
 		user_updated = self.get_user()
 		roles = sorted([r.name for r in user_updated.roles_effective])
 		self.assertEqual(user_updated.displayname, 'New User')
-		self.assertEqual(user_updated.mail, 'newuser@example.com')
+		self.assertEqual(user_updated.primary_email.address, 'test@example.com')
 		self.assertEqual(user_updated.unix_uid, user_unupdated.unix_uid)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
 		self.assertTrue(user_updated.password.verify('userpassword'))
@@ -240,16 +286,19 @@ class TestUserViews(UffdTestCase):
 
 	def test_update_password(self):
 		user_unupdated = self.get_user()
+		email_id = str(user_unupdated.primary_email.id)
 		r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 		r = self.client.post(path=url_for('user.update', id=user_unupdated.id),
-			data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			data={'loginname': 'testuser',
+			f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary',
+			'displayname': 'New User',
 			'password': 'newpassword'}, follow_redirects=True)
 		dump('user_update_password', r)
 		self.assertEqual(r.status_code, 200)
 		user_updated = self.get_user()
 		self.assertEqual(user_updated.displayname, 'New User')
-		self.assertEqual(user_updated.mail, 'newuser@example.com')
+		self.assertEqual(user_updated.primary_email.address, 'test@example.com')
 		self.assertEqual(user_updated.unix_uid, user_unupdated.unix_uid)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
 		self.assertTrue(user_updated.password.verify('newpassword'))
@@ -257,10 +306,13 @@ class TestUserViews(UffdTestCase):
 
 	def test_update_invalid_password(self):
 		user_unupdated = self.get_user()
+		email_id = str(user_unupdated.primary_email.id)
 		r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 		r = self.client.post(path=url_for('user.update', id=user_unupdated.id),
-			data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			data={'loginname': 'testuser',
+			f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary',
+			'displayname': 'New User',
 			'password': 'A'}, follow_redirects=True)
 		dump('user_update_invalid_password', r)
 		self.assertEqual(r.status_code, 200)
@@ -268,16 +320,19 @@ class TestUserViews(UffdTestCase):
 		self.assertFalse(user_updated.password.verify('A'))
 		self.assertTrue(user_updated.password.verify('userpassword'))
 		self.assertEqual(user_updated.displayname, user_unupdated.displayname)
-		self.assertEqual(user_updated.mail, user_unupdated.mail)
+		self.assertEqual(user_updated.primary_email.address, user_unupdated.primary_email.address)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
 
 	# Regression test for #100 (login not possible if password contains character disallowed by SASLprep)
 	def test_update_saslprep_invalid_password(self):
 		user_unupdated = self.get_user()
+		email_id = str(user_unupdated.primary_email.id)
 		r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 		r = self.client.post(path=url_for('user.update', id=user_unupdated.id),
-			data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'New User',
+			data={'loginname': 'testuser',
+			f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary',
+			'displayname': 'New User',
 			'password': 'newpassword\n'}, follow_redirects=True)
 		dump('user_update_invalid_password', r)
 		self.assertEqual(r.status_code, 200)
@@ -285,37 +340,55 @@ class TestUserViews(UffdTestCase):
 		self.assertFalse(user_updated.password.verify('newpassword\n'))
 		self.assertTrue(user_updated.password.verify('userpassword'))
 		self.assertEqual(user_updated.displayname, user_unupdated.displayname)
-		self.assertEqual(user_updated.mail, user_unupdated.mail)
+		self.assertEqual(user_updated.primary_email.address, user_unupdated.primary_email.address)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
 
-	def test_update_empty_email(self):
-		user_unupdated = self.get_user()
-		r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True)
-		self.assertEqual(r.status_code, 200)
-		r = self.client.post(path=url_for('user.update', id=user_unupdated.id),
-			data={'loginname': 'testuser', 'mail': '', 'displayname': 'New User',
-			'password': 'newpassword'}, follow_redirects=True)
-		dump('user_update_empty_mail', r)
+	def test_update_email(self):
+		user = self.get_user()
+		email = UserEmail(user=user, address='foo@example.com')
+		db.session.commit()
+		email1_id = user.primary_email.id
+		email2_id = email.id
+		r = self.client.post(path=url_for('user.update', id=user.id),
+			data={'loginname': 'testuser',
+			f'email-{email1_id}-present': '1',
+			f'email-{email2_id}-present': '1',
+			f'email-{email2_id}-verified': '1',
+			f'newemail-1-address': 'new1@example.com',
+			f'newemail-2-address': 'new2@example.com', f'newemail-2-verified': '1',
+			'primary_email': email2_id, 'recovery_email': email1_id,
+			'displayname': 'Test User', 'password': ''},
+			follow_redirects=True)
+		dump('user_update_email', r)
 		self.assertEqual(r.status_code, 200)
-		user_updated = self.get_user()
-		self.assertEqual(user_updated.displayname, user_unupdated.displayname)
-		self.assertEqual(user_updated.mail, user_unupdated.mail)
-		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
-		self.assertFalse(user_updated.password.verify('newpassword'))
-		self.assertTrue(user_updated.password.verify('userpassword'))
+		user = self.get_user()
+		self.assertEqual(user.primary_email.id, email2_id)
+		self.assertEqual(user.recovery_email.id, email1_id)
+		self.assertEqual(
+			{email.address: email.verified for email in user.all_emails},
+			{
+				'test@example.com': True,
+				'foo@example.com': True,
+				'new1@example.com': False,
+				'new2@example.com': True,
+			}
+		)
 
 	def test_update_invalid_display_name(self):
 		user_unupdated = self.get_user()
+		email_id = str(user_unupdated.primary_email.id)
 		r = self.client.get(path=url_for('user.show', id=user_unupdated.id), follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 		r = self.client.post(path=url_for('user.update', id=user_unupdated.id),
-			data={'loginname': 'testuser', 'mail': 'newuser@example.com', 'displayname': 'A'*200,
+			data={'loginname': 'testuser',
+			f'email-{email_id}-present': '1', 'primary_email': email_id, 'recovery_email': 'primary',
+			'displayname': 'A'*200,
 			'password': 'newpassword'}, follow_redirects=True)
 		dump('user_update_invalid_display_name', r)
 		self.assertEqual(r.status_code, 200)
 		user_updated = self.get_user()
 		self.assertEqual(user_updated.displayname, user_unupdated.displayname)
-		self.assertEqual(user_updated.mail, user_unupdated.mail)
+		self.assertEqual(user_updated.primary_email.address, user_unupdated.primary_email.address)
 		self.assertEqual(user_updated.loginname, user_unupdated.loginname)
 		self.assertFalse(user_updated.password.verify('newpassword'))
 		self.assertTrue(user_updated.password.verify('userpassword'))
@@ -361,42 +434,42 @@ newuser12,newuser12@example.com,{role1.id};{role1.id}
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser1')
 		self.assertEqual(user.displayname, 'newuser1')
-		self.assertEqual(user.mail, 'newuser1@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser1@example.com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, [])
 		user = User.query.filter_by(loginname='newuser2').one_or_none()
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser2')
 		self.assertEqual(user.displayname, 'newuser2')
-		self.assertEqual(user.mail, 'newuser2@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser2@example.com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, ['role1'])
 		user = User.query.filter_by(loginname='newuser3').one_or_none()
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser3')
 		self.assertEqual(user.displayname, 'newuser3')
-		self.assertEqual(user.mail, 'newuser3@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser3@example.com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, ['role1', 'role2'])
 		user = User.query.filter_by(loginname='newuser4').one_or_none()
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser4')
 		self.assertEqual(user.displayname, 'newuser4')
-		self.assertEqual(user.mail, 'newuser4@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser4@example.com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, [])
 		user = User.query.filter_by(loginname='newuser5').one_or_none()
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser5')
 		self.assertEqual(user.displayname, 'newuser5')
-		self.assertEqual(user.mail, 'newuser5@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser5@example.com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, [])
 		user = User.query.filter_by(loginname='newuser6').one_or_none()
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser6')
 		self.assertEqual(user.displayname, 'newuser6')
-		self.assertEqual(user.mail, 'newuser6@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser6@example.com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, ['role1', 'role2'])
 		self.assertIsNone(User.query.filter_by(loginname='newuser7').one_or_none())
@@ -406,14 +479,14 @@ newuser12,newuser12@example.com,{role1.id};{role1.id}
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser10')
 		self.assertEqual(user.displayname, 'newuser10')
-		self.assertEqual(user.mail, 'newuser10@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser10@example.com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, [])
 		user = User.query.filter_by(loginname='newuser11').one_or_none()
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser11')
 		self.assertEqual(user.displayname, 'newuser11')
-		self.assertEqual(user.mail, 'newuser11@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser11@example.com')
 		# Currently the csv import is not very robust, imho newuser11 should have role1 and role2!
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, ['role2'])
@@ -421,7 +494,7 @@ newuser12,newuser12@example.com,{role1.id};{role1.id}
 		self.assertIsNotNone(user)
 		self.assertEqual(user.loginname, 'newuser12')
 		self.assertEqual(user.displayname, 'newuser12')
-		self.assertEqual(user.mail, 'newuser12@example.com')
+		self.assertEqual(user.primary_email.address, 'newuser12@example.com')
 		roles = sorted([r.name for r in user.roles])
 		self.assertEqual(roles, ['role1'])
 
@@ -464,7 +537,7 @@ class TestUserCLI(UffdTestCase):
 		with self.app.test_request_context():
 			user = User.query.filter_by(loginname='newuser').first()
 			self.assertIsNotNone(user)
-			self.assertEqual(user.mail, 'newmail@example.com')
+			self.assertEqual(user.primary_email.address, 'newmail@example.com')
 			self.assertEqual(user.displayname, 'New Display Name')
 			self.assertTrue(user.password.verify('newpassword'))
 			self.assertEqual(user.roles, Role.query.filter_by(name='admin').all())
@@ -487,7 +560,7 @@ class TestUserCLI(UffdTestCase):
 		with self.app.test_request_context():
 			user = User.query.filter_by(loginname='testuser').first()
 			self.assertIsNotNone(user)
-			self.assertEqual(user.mail, 'newmail@example.com')
+			self.assertEqual(user.primary_email.address, 'newmail@example.com')
 			self.assertEqual(user.displayname, 'New Display Name')
 			self.assertTrue(user.password.verify('newpassword'))
 		result = self.app.test_cli_runner().invoke(args=['user', 'update', 'testuser', '--add-role', 'admin', '--add-role', 'test'])
diff --git a/tests/utils.py b/tests/utils.py
index c4017b22..3b98e64b 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -83,9 +83,9 @@ class UffdTestCase(unittest.TestCase):
 		db.session.add(access_group)
 		admin_group = Group(name='uffd_admin', unix_gid=20003, description='Admin access to uffd')
 		db.session.add(admin_group)
-		testuser = User(loginname='testuser', unix_uid=10000, password='userpassword', mail='test@example.com', displayname='Test User', groups=[users_group, access_group])
+		testuser = User(loginname='testuser', unix_uid=10000, password='userpassword', primary_email_address='test@example.com', displayname='Test User', groups=[users_group, access_group])
 		db.session.add(testuser)
-		testadmin = User(loginname='testadmin', unix_uid=10001, password='adminpassword', mail='admin@example.com', displayname='Test Admin', groups=[users_group, access_group, admin_group])
+		testadmin = User(loginname='testadmin', unix_uid=10001, password='adminpassword', primary_email_address='admin@example.com', displayname='Test Admin', groups=[users_group, access_group, admin_group])
 		db.session.add(testadmin)
 		testmail = Mail(uid='test', receivers=['test1@example.com', 'test2@example.com'], destinations=['testuser@mail.example.com'])
 		db.session.add(testmail)
diff --git a/uffd/commands/user.py b/uffd/commands/user.py
index 5b82eec6..cf3dd6e0 100644
--- a/uffd/commands/user.py
+++ b/uffd/commands/user.py
@@ -15,7 +15,7 @@ def update_attrs(user, mail=None, displayname=None, password=None,
                  add_role=tuple(), remove_role=tuple()):
 	if password is None and prompt_password:
 		password = click.prompt('Password', hide_input=True, confirmation_prompt='Confirm password')
-	if mail is not None and not user.set_mail(mail):
+	if mail is not None and not user.set_primary_email_address(mail):
 		raise click.ClickException('Invalid mail address')
 	if displayname is not None and not user.set_displayname(displayname):
 		raise click.ClickException('Invalid displayname')
@@ -50,7 +50,7 @@ def show(loginname):
 			raise click.ClickException(f'User {loginname} not found')
 		click.echo(f'Loginname: {user.loginname}')
 		click.echo(f'Displayname: {user.displayname}')
-		click.echo(f'Mail: {user.mail}')
+		click.echo(f'Mail: {user.primary_email.address}')
 		click.echo(f'Service User: {user.is_service_user}')
 		click.echo(f'Roles: {", ".join([role.name for role in user.roles])}')
 		click.echo(f'Groups: {", ".join([group.name for group in user.groups])}')
@@ -70,6 +70,7 @@ def create(loginname, mail, displayname, service, password, prompt_password, add
 		if displayname is None:
 			displayname = loginname
 		user = User(is_service_user=service)
+		user = User(is_service_user=service)
 		if not user.set_loginname(loginname, ignore_blocklist=True):
 			raise click.ClickException('Invalid loginname')
 		try:
diff --git a/uffd/migrations/versions/9f824f61d8ac_use_utc_for_datetime.py b/uffd/migrations/versions/9f824f61d8ac_use_utc_for_datetime.py
index f5290617..125458d8 100644
--- a/uffd/migrations/versions/9f824f61d8ac_use_utc_for_datetime.py
+++ b/uffd/migrations/versions/9f824f61d8ac_use_utc_for_datetime.py
@@ -31,7 +31,7 @@ def iter_rows_paged(table, pk='id', limit=1000):
 		if last_pk is not None:
 			expr = expr.where(pk_column > last_pk)
 		result = conn.execute(expr)
-		pk_index = result.keys().index(pk)
+		pk_index = list(result.keys()).index(pk)
 		rows = result.fetchall()
 		if not rows:
 			break
diff --git a/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py b/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py
new file mode 100644
index 00000000..ca83799f
--- /dev/null
+++ b/uffd/migrations/versions/b273d7fdaa25_multiple_email_addresses.py
@@ -0,0 +1,141 @@
+"""Multiple email addresses
+
+Revision ID: b273d7fdaa25
+Revises: 9f824f61d8ac
+Create Date: 2022-08-19 22:52:48.730877
+
+"""
+from alembic import op
+import sqlalchemy as sa
+import datetime
+
+# revision identifiers, used by Alembic.
+revision = 'b273d7fdaa25'
+down_revision = 'b8fbefca3675'
+branch_labels = None
+depends_on = None
+
+def iter_rows_paged(table, pk='id', limit=1000):
+	conn = op.get_bind()
+	pk_column = getattr(table.c, pk)
+	last_pk = None
+	while True:
+		expr = table.select().order_by(pk_column).limit(limit)
+		if last_pk is not None:
+			expr = expr.where(pk_column > last_pk)
+		result = conn.execute(expr)
+		pk_index = list(result.keys()).index(pk)
+		rows = result.fetchall()
+		if not rows:
+			break
+		yield from rows
+		last_pk = rows[-1][pk_index]
+
+def upgrade():
+	user_email_table = op.create_table('user_email',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('user_id', sa.Integer(), nullable=True),
+		sa.Column('address', sa.String(length=128), nullable=False),
+		sa.Column('verified', sa.Boolean(), nullable=False),
+		sa.Column('verification_legacy_id', sa.Integer(), nullable=True),
+		sa.Column('verification_secret', sa.Text(), nullable=True),
+		sa.Column('verification_expires', sa.DateTime(), nullable=True),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_user_email_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_user_email')),
+		sa.UniqueConstraint('user_id', 'address', name='uq_user_email_user_id_address')
+	)
+	user_table = sa.table('user',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('mail', sa.VARCHAR(length=128), nullable=False),
+	)
+	op.execute(user_email_table.insert().from_select(
+		['user_id', 'address', 'verified'],
+		sa.select([user_table.c.id, user_table.c.mail, sa.literal(True, sa.Boolean())])
+	))
+	with op.batch_alter_table('user', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('primary_email_id', sa.Integer(), nullable=True))
+		batch_op.add_column(sa.Column('recovery_email_id', sa.Integer(), nullable=True))
+		batch_op.create_foreign_key(batch_op.f('fk_user_primary_email_id_user_email'), 'user_email', ['primary_email_id'], ['id'], onupdate='CASCADE')
+		batch_op.create_foreign_key(batch_op.f('fk_user_recovery_email_id_user_email'), 'user_email', ['recovery_email_id'], ['id'], onupdate='CASCADE', ondelete='SET NULL')
+	meta = sa.MetaData(bind=op.get_bind())
+	user_table = sa.Table('user', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('unix_uid', sa.Integer(), nullable=False),
+		sa.Column('loginname', sa.String(length=32), nullable=False),
+		sa.Column('displayname', sa.String(length=128), nullable=False),
+		sa.Column('mail', sa.VARCHAR(length=128), nullable=False),
+		sa.Column('primary_email_id', sa.Integer(), nullable=True),
+		sa.Column('recovery_email_id', sa.Integer(), nullable=True),
+		sa.Column('pwhash', sa.Text(), nullable=True),
+		sa.Column('is_service_user', sa.Boolean(), nullable=False),
+		sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'),
+		sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_user')),
+		sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')),
+		sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid'))
+	)
+	op.execute(user_table.update().values(primary_email_id=sa.select([user_email_table.c.id]).where(user_email_table.c.user_id==user_table.c.id).limit(1).as_scalar()))
+	with op.batch_alter_table('user', copy_from=user_table) as batch_op:
+		batch_op.alter_column('primary_email_id', existing_type=sa.Integer(), nullable=False)
+		batch_op.drop_column('mail')
+	mailToken_table = sa.table('mailToken',
+		sa.column('id', sa.Integer()),
+		sa.column('token', sa.Text()),
+		sa.column('created', sa.DateTime()),
+		sa.column('newmail', sa.Text()),
+		sa.column('user_id', sa.Integer()),
+	)
+	for token_id, token, created, newmail, user_id in iter_rows_paged(mailToken_table):
+		op.execute(user_email_table.insert().insert().values(
+			user_id=user_id,
+			address=newmail,
+			verified=False,
+			verification_legacy_id=token_id,
+			verification_secret='{PLAIN}'+token,
+			# in-python because of this
+			verification_expires=(created + datetime.timedelta(days=2)),
+		))
+	op.drop_table('mailToken')
+
+def downgrade():
+	with op.batch_alter_table('user', schema=None) as batch_op:
+		batch_op.add_column(sa.Column('mail', sa.VARCHAR(length=128), nullable=True))
+	meta = sa.MetaData(bind=op.get_bind())
+	user_table = sa.Table('user', meta,
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('unix_uid', sa.Integer(), nullable=False),
+		sa.Column('loginname', sa.String(length=32), nullable=False),
+		sa.Column('displayname', sa.String(length=128), nullable=False),
+		sa.Column('mail', sa.VARCHAR(length=128), nullable=False),
+		sa.Column('primary_email_id', sa.Integer(), nullable=False),
+		sa.Column('recovery_email_id', sa.Integer(), nullable=True),
+		sa.Column('pwhash', sa.Text(), nullable=True),
+		sa.Column('is_service_user', sa.Boolean(), nullable=False),
+		sa.ForeignKeyConstraint(['primary_email_id'], ['user_email.id'], name=op.f('fk_user_primary_email_id_user_email'), onupdate='CASCADE'),
+		sa.ForeignKeyConstraint(['recovery_email_id'], ['user_email.id'], name=op.f('fk_user_recovery_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.PrimaryKeyConstraint('id', name=op.f('pk_user')),
+		sa.UniqueConstraint('loginname', name=op.f('uq_user_loginname')),
+		sa.UniqueConstraint('unix_uid', name=op.f('uq_user_unix_uid'))
+	)
+	user_email_table = sa.table('user_email',
+		sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+		sa.Column('address', sa.String(length=128), nullable=False),
+	)
+	op.execute(user_table.update().values(mail=sa.select([user_email_table.c.address]).where(user_email_table.c.id==user_table.c.primary_email_id).limit(1).as_scalar()))
+	with op.batch_alter_table('user', copy_from=user_table) as batch_op:
+		batch_op.alter_column('mail', existing_type=sa.VARCHAR(length=128), nullable=False)
+		batch_op.drop_constraint(batch_op.f('fk_user_recovery_email_id_user_email'), type_='foreignkey')
+		batch_op.drop_constraint(batch_op.f('fk_user_primary_email_id_user_email'), type_='foreignkey')
+		batch_op.drop_column('recovery_email_id')
+		batch_op.drop_column('primary_email_id')
+	op.create_table('mailToken',
+		sa.Column('id', sa.INTEGER(), nullable=False),
+		sa.Column('token', sa.VARCHAR(length=128), nullable=False),
+		sa.Column('created', sa.DATETIME(), nullable=True),
+		sa.Column('newmail', sa.VARCHAR(length=255), nullable=True),
+		sa.Column('user_id', sa.INTEGER(), nullable=False),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('id')
+	)
+	op.drop_table('user_email')
+
diff --git a/uffd/models/__init__.py b/uffd/models/__init__.py
index cedc85e6..52d9709b 100644
--- a/uffd/models/__init__.py
+++ b/uffd/models/__init__.py
@@ -4,11 +4,11 @@ from .mail import Mail, MailReceiveAddress, MailDestinationAddress
 from .mfa import MFAType, MFAMethod, RecoveryCodeMethod, TOTPMethod, WebauthnMethod
 from .oauth2 import OAuth2Client, OAuth2RedirectURI, OAuth2LogoutURI, OAuth2Grant, OAuth2Token, OAuth2DeviceLoginInitiation
 from .role import Role, RoleGroup, RoleGroupMap
-from .selfservice import PasswordToken, MailToken
+from .selfservice import PasswordToken
 from .service import Service, ServiceUser, get_services
 from .session import DeviceLoginType, DeviceLoginInitiation, DeviceLoginConfirmation
 from .signup import Signup
-from .user import User, Group
+from .user import User, UserEmail, Group
 from .ratelimit import RatelimitEvent, Ratelimit, HostRatelimit, host_ratelimit, format_delay
 
 __all__ = [
@@ -18,10 +18,11 @@ __all__ = [
 	'MFAType', 'MFAMethod', 'RecoveryCodeMethod', 'TOTPMethod', 'WebauthnMethod',
 	'OAuth2Client', 'OAuth2RedirectURI', 'OAuth2LogoutURI', 'OAuth2Grant', 'OAuth2Token', 'OAuth2DeviceLoginInitiation',
 	'Role', 'RoleGroup', 'RoleGroupMap',
-	'PasswordToken', 'MailToken',
+	'PasswordToken',
+	'Service', 'get_services',
 	'Service', 'ServiceUser', 'get_services',
 	'DeviceLoginType', 'DeviceLoginInitiation', 'DeviceLoginConfirmation',
 	'Signup',
-	'User', 'Group',
+	'User', 'UserEmail', 'Group',
 	'RatelimitEvent', 'Ratelimit', 'HostRatelimit', 'host_ratelimit', 'format_delay',
 ]
diff --git a/uffd/models/selfservice.py b/uffd/models/selfservice.py
index 5c4d5eb1..be3f36a7 100644
--- a/uffd/models/selfservice.py
+++ b/uffd/models/selfservice.py
@@ -22,19 +22,3 @@ class PasswordToken(db.Model):
 		if self.created is None:
 			return False
 		return self.created < datetime.datetime.utcnow() - datetime.timedelta(days=2)
-
-@cleanup_task.delete_by_attribute('expired')
-class MailToken(db.Model):
-	__tablename__ = 'mailToken'
-	id = Column(Integer(), primary_key=True, autoincrement=True)
-	token = Column(String(128), default=token_urlfriendly, nullable=False)
-	created = Column(DateTime, default=datetime.datetime.utcnow)
-	user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE'), nullable=False)
-	user = relationship('User')
-	newmail = Column(String(255))
-
-	@hybrid_property
-	def expired(self):
-		if self.created is None:
-			return False
-		return self.created < datetime.datetime.utcnow() - datetime.timedelta(days=2)
diff --git a/uffd/models/service.py b/uffd/models/service.py
index 6953ee71..131400f0 100644
--- a/uffd/models/service.py
+++ b/uffd/models/service.py
@@ -6,7 +6,7 @@ from sqlalchemy.orm import relationship
 from uffd.database import db
 from uffd.remailer import remailer
 from uffd.tasks import cleanup_task
-from .user import User
+from .user import User, UserEmail
 
 class Service(db.Model):
 	__tablename__ = 'service'
@@ -53,7 +53,7 @@ class ServiceUser(db.Model):
 	# Actual e-mail address that mails from the service are sent to
 	@property
 	def real_email(self):
-		return self.user.mail
+		return self.user.primary_email.address
 
 	@property
 	def remailer_email(self):
@@ -91,9 +91,11 @@ class ServiceUser(db.Model):
 			return query.filter(cls.user_id == service_user.user_id, cls.service_id == service_user.service_id)
 
 		AliasedUser = db.aliased(User)
+		AliasedPrimaryEmail = db.aliased(UserEmail)
 		AliasedService = db.aliased(Service)
 
 		query = query.join(cls.user.of_type(AliasedUser))
+		query = query.join(AliasedUser.primary_email.of_type(AliasedPrimaryEmail))
 		query = query.join(cls.service.of_type(AliasedService))
 
 		remailer_enabled_expr = AliasedService.use_remailer if remailer.configured else False
@@ -102,7 +104,7 @@ class ServiceUser(db.Model):
 				remailer_enabled_expr,
 				AliasedUser.loginname.in_(current_app.config['REMAILER_LIMIT_TO_USERS']),
 			)
-		return query.filter(db.and_(db.not_(remailer_enabled_expr), AliasedUser.mail == email))
+		return query.filter(db.and_(db.not_(remailer_enabled_expr), AliasedPrimaryEmail.address == email))
 
 @db.event.listens_for(db.Session, 'after_flush') # pylint: disable=no-member
 def create_service_users(session, flush_context): # pylint: disable=unused-argument
diff --git a/uffd/models/signup.py b/uffd/models/signup.py
index c04dbb41..e56f565e 100644
--- a/uffd/models/signup.py
+++ b/uffd/models/signup.py
@@ -79,8 +79,8 @@ class Signup(db.Model):
 			return False, _('Login name is invalid')
 		if not User().set_displayname(self.displayname):
 			return False, _('Display name is invalid')
-		if not User().set_mail(self.mail):
-			return False, _('Mail address is invalid')
+		if not User().set_primary_email_address(self.mail):
+			return False, _('E-Mail address is invalid')
 		if not self.password:
 			return False, _('Invalid password')
 		if User.query.filter_by(loginname=self.loginname).all():
@@ -104,7 +104,7 @@ class Signup(db.Model):
 			return None, _('Wrong password')
 		if User.query.filter_by(loginname=self.loginname).all():
 			return None, _('A user with this login name already exists')
-		user = User(loginname=self.loginname, displayname=self.displayname, mail=self.mail, password=self.password)
+		user = User(loginname=self.loginname, displayname=self.displayname, primary_email_address=self.mail, password=self.password)
 		db.session.add(user)
 		user.update_groups() # pylint: disable=no-member
 		self.user = user
diff --git a/uffd/models/user.py b/uffd/models/user.py
index f822e232..374afef6 100644
--- a/uffd/models/user.py
+++ b/uffd/models/user.py
@@ -1,14 +1,17 @@
 import string
 import re
+import datetime
 
 from flask import current_app, escape
 from flask_babel import lazy_gettext
-from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text
-from sqlalchemy.orm import relationship
+from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text, DateTime
+from sqlalchemy.orm import relationship, validates
+from sqlalchemy.ext.hybrid import hybrid_property
 
 from uffd.database import db
-from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash
 from uffd.remailer import remailer
+from uffd.utils import token_urlfriendly
+from uffd.password_hash import PasswordHashAttribute, LowEntropyPasswordHash, HighEntropyPasswordHash
 
 # pylint: disable=E1101
 user_groups = db.Table('user_groups',
@@ -37,7 +40,39 @@ class User(db.Model):
 	unix_uid = Column(Integer(), unique=True, nullable=False)
 	loginname = Column(String(32), unique=True, nullable=False)
 	displayname = Column(String(128), nullable=False)
-	mail = Column(String(128), nullable=False)
+
+	all_emails = relationship(
+		'UserEmail',
+		foreign_keys='UserEmail.user_id',
+		cascade='all, delete-orphan',
+		back_populates='user',
+		post_update=True,
+	)
+	verified_emails = relationship(
+		'UserEmail',
+		foreign_keys='UserEmail.user_id',
+		viewonly=True,
+		primaryjoin='and_(User.id == UserEmail.user_id, UserEmail.verified)',
+	)
+
+	primary_email_id = Column(Integer(), ForeignKey('user_email.id', onupdate='CASCADE'), nullable=False)
+	primary_email = relationship('UserEmail', foreign_keys='User.primary_email_id')
+
+	# recovery_email_id == NULL -> use primary email
+	recovery_email_id = Column(Integer(), ForeignKey('user_email.id', onupdate='CASCADE', ondelete='SET NULL'))
+	recovery_email = relationship('UserEmail', foreign_keys='User.recovery_email_id')
+
+	@validates('primary_email', 'recovery_email')
+	def validate_email(self, key, value):
+		if value is not None:
+			if not value.user:
+				value.user = self
+			if value.user != self:
+				raise ValueError(f'UserEmail assigned to User.{key} is not associated with user')
+			if not value.verified:
+				raise ValueError(f'UserEmail assigned to User.{key} is not verified')
+		return  value
+
 	_password = Column('pwhash', Text(), nullable=True)
 	password = PasswordHashAttribute('_password', LowEntropyPasswordHash)
 	is_service_user = Column(Boolean(), default=False, nullable=False)
@@ -46,6 +81,11 @@ class User(db.Model):
 
 	service_users = relationship('ServiceUser', viewonly=True)
 
+	def __init__(self, primary_email_address=None, **kwargs):
+		super().__init__(**kwargs)
+		if primary_email_address is not None:
+			self.primary_email = UserEmail(address=primary_email_address, verified=True)
+
 	@property
 	def unix_gid(self):
 		return current_app.config['USER_GID']
@@ -97,18 +137,100 @@ class User(db.Model):
 		self.password = value
 		return True
 
-	def set_mail(self, value):
+	def set_primary_email_address(self, address):
+		# UserEmail.query.filter_by(user=self, address=address).first() would cause
+		# a flush, so we do this in python. A flush would cause an IntegrityError if
+		# this method is used a new User object, since primary_email_id is not
+		# nullable.
+		email = ([item for item in self.all_emails if item.address == address] or [None])[0]
+		if not email:
+			email = UserEmail()
+			if not email.set_address(address):
+				return False
+		email.verified = True
+		self.primary_email = email
+		return True
+
+	# Somehow pylint non-deterministically fails to detect that .update_groups is set in role.models
+	def update_groups(self):
+		pass
+
+class UserEmail(db.Model):
+	__tablename__ = 'user_email'
+	id = Column(Integer(), primary_key=True, autoincrement=True)
+
+	# We have a cyclic dependency between User.primary_email and UserEmail.user.
+	# To solve this, we make UserEmail.user nullable, add validators, and set
+	# post_update=True here and for the backref.
+	user_id = Column(Integer(), ForeignKey('user.id', onupdate='CASCADE', ondelete='CASCADE', use_alter=True))
+	user = relationship('User', foreign_keys='UserEmail.user_id', back_populates='all_emails', post_update=True)
+
+	@validates('user')
+	def validate_user(self, key, value): # pylint: disable=unused-argument
+		if self.user is not None and self.user != value:
+			raise ValueError('UserEmail.user cannot be changed once set')
+		return value
+
+	address = Column(String(128), nullable=False)
+
+	@validates('address')
+	def validate_address(self, key, value): # pylint: disable=unused-argument
+		if self.address is not None and self.address != value:
+			raise ValueError('UserEmail.address cannot be changed once set')
+		return value
+
+	verified = Column(Boolean(), default=False, nullable=False)
+
+	@validates('verified')
+	def validate_verified(self, key, value): # pylint: disable=unused-argument
+		if self.verified and not value:
+			raise ValueError('UserEmail cannot be unverified once verified')
+		return value
+
+	verification_legacy_id = Column(Integer()) # id of old MailToken
+	_verification_secret = Column('verification_secret', Text())
+	verification_secret = PasswordHashAttribute('_verification_secret', HighEntropyPasswordHash)
+	verification_expires = Column(DateTime)
+
+	__table_args__ = (
+		db.UniqueConstraint('user_id', 'address', name='uq_user_email_user_id_address'),
+	)
+
+	def set_address(self, value):
 		if len(value) < 3 or '@' not in value:
 			return False
 		domain = value.rsplit('@', 1)[-1]
 		if remailer.is_remailer_domain(domain):
 			return False
-		self.mail = value
+		self.address = value
 		return True
 
-	# Somehow pylint non-deterministically fails to detect that .update_groups is set in invite.modes
-	def update_groups(self):
-		pass
+	def start_verification(self):
+		if self.verified:
+			raise Exception('UserEmail.start_verification must not be called if address is already verified')
+		self.verification_legacy_id = None
+		secret = token_urlfriendly()
+		self.verification_secret = secret
+		self.verification_expires = datetime.datetime.utcnow() + datetime.timedelta(days=2)
+		return secret
+
+	@hybrid_property
+	def verification_expired(self):
+		if self.verification_expires is None:
+			return True
+		return self.verification_expires < datetime.datetime.utcnow()
+
+	def finish_verification(self, secret):
+		# pylint: disable=using-constant-test
+		if self.verification_expired:
+			return False
+		if not self.verification_secret.verify(secret):
+			return False
+		self.verification_legacy_id = None
+		self.verification_secret = None
+		self.verification_expires = None
+		self.verified = True
+		return True
 
 def next_id_expr(column, min_value, max_value):
 	# db.func.max(column) + 1: highest used value in range + 1, NULL if no values in range
diff --git a/uffd/templates/selfservice/mailverification.mail.txt b/uffd/templates/selfservice/mailverification.mail.txt
index f508be90..4b705f28 100644
--- a/uffd/templates/selfservice/mailverification.mail.txt
+++ b/uffd/templates/selfservice/mailverification.mail.txt
@@ -1,6 +1,6 @@
 Hi {{ user.displayname }},
 
 you have requested to change your mail address. To confirm the change, please visit the following url:
-{{ url_for('selfservice.token_mail', token_id=token.id, token=token.token, _external=True) }}
+{{ url_for('selfservice.verify_email', email_id=email.id, secret=secret, _external=True) }}
 
 **The link is valid for 48h**
diff --git a/uffd/templates/selfservice/self.html b/uffd/templates/selfservice/self.html
index f32b2e19..39a36499 100644
--- a/uffd/templates/selfservice/self.html
+++ b/uffd/templates/selfservice/self.html
@@ -25,14 +25,81 @@
 				<label>{{_("Display Name")}}</label>
 				<input type="text" class="form-control" id="user-displayname" name="displayname" value="{{ user.displayname }}">
 			</div>
+			<button type="submit" class="btn btn-primary btn-block">{{_("Update Profile")}}</button>
+		</form>
+	</div>
+</div>
+
+<hr>
+
+<div class="row">
+	<div class="col-12 col-md-5">
+		<h5>{{_("E-Mail Addresses")}}</h5>
+		<p>{{_("Add and delete addresses associated with your account. You will need to verify new addresses by opening a link set to them.")}}</p>
+	</div>
+	<div class="col-12 col-md-7">
+    <form method="POST" action="{{ url_for('selfservice.add_email') }}" class="form mb-2">
+      <div class="row m-0">
+        <label class="sr-only" for="new-email-address">{{_("Email")}}</label>
+        <input type="text" class="form-control mb-2 col-12 col-lg-auto mr-2" style="width: 20em;" id="new-email-address" name="address" placeholder="{{_("New E-Mail Address")}}" required>
+        <button type="submit" class="btn btn-primary mb-2 col">{{_("Add address")}}</button>
+      </div>
+    </form>
+		<table class="table mb-0">
+			<tbody>
+				{% for email in user.all_emails|sort(attribute='id') %}
+				<tr>
+					<td class="pl-0">
+						{{ email.address }}
+						{% if email == user.primary_email %}
+						<span class="badge badge-primary">{{ _('primary') }}</span>
+						{% elif not email.verified  %}
+						<span class="badge badge-danger">{{ _('unverified') }}</span>
+						{% endif %}
+					</td>
+					<td class="pt-2 pb-1 pr-0">
+						<form method="POST" action="{{ url_for('selfservice.delete_email', email_id=email.id) }}" onsubmit='return confirm({{_("Are you sure?")|tojson|e}});'>
+							<button type="submit" class="btn btn-sm btn-danger float-right ml-1 mb-1"{% if email == user.primary_email %} disabled title="{{ _('Cannot delete primary e-mail address') }}"{% endif %}>{{_("Delete")}}</button>
+						</form>
+						{% if not email.verified  %}
+						<a href="{{ url_for('selfservice.retry_email_verification', email_id=email.id) }}" class="btn btn-sm btn-primary float-right mb-1">{{_("Retry verification")}}</a>
+						{% endif %}
+					</td>
+				</tr>
+				{% endfor %}
+			</tbody>
+		</table>
+	</div>
+</div>
+
+<hr>
+
+<div class="row">
+	<div class="col-12 col-md-5">
+		<h5>{{_("E-Mail Preferences")}}</h5>
+		<p>{{_("Choose which of your verified address to use as your primary or recovery address.")}}</p>
+	</div>
+	<div class="col-12 col-md-7">
+		<form class="form" action="{{ url_for("selfservice.update_email_preferences") }}" method="POST">
 			<div class="form-group">
-				<label>{{_("E-Mail Address")}}</label>
-				<input type="email" class="form-control" id="user-mail" name="mail" value="{{ user.mail }}" required>
-				<small class="form-text text-muted">
-					{{_("We will send you a confirmation mail to this address if you change it")}}
-				</small>
+				<label>{{_("Primary Address")}}</label>
+				<select name="primary_email" class="form-control">
+					{% for email in user.all_emails if email.verified %}
+					<option value="{{ email.id }}" {{ 'selected' if email == user.primary_email }}>{{ email.address }}</option>
+					{% endfor %}
+				</select>
 			</div>
-			<button type="submit" class="btn btn-primary btn-block">{{_("Update Profile")}}</button>
+			<div class="form-group">
+				<label>{{_("Recovery Address")}}</label>
+				<select name="recovery_email" class="form-control">
+					<option value="primary" {{ 'selected' if not user.recovery_email }}>{{ _('Use primary address') }}</option>
+					{% for email in user.all_emails if email.verified %}
+					<option value="{{ email.id }}" {{ 'selected' if email == user.recovery_email }}>{{ email.address }}</option>
+					{% endfor %}
+				</select>
+				<small class="text-muted">{{ _('Password reset e-mails will be sent to this address') }}</small>
+			</div>
+			<button type="submit" class="btn btn-primary btn-block">{{_("Update E-Mail Preferences")}}</button>
 		</form>
 	</div>
 </div>
@@ -91,6 +158,7 @@
 	</div>
 	<div class="col-12 col-md-7">
 		<p>{{_("Administrators and role moderators can invite you to new roles.")}}</p>
+
 		<table class="table">
 			<thead>
 				<tr>
@@ -122,6 +190,7 @@
 				{% endif %}
 			</tbody>
 		</table>
+
 	</div>
 </div>
 
diff --git a/uffd/templates/user/show.html b/uffd/templates/user/show.html
index 20f4f7a0..8b814f5d 100644
--- a/uffd/templates/user/show.html
+++ b/uffd/templates/user/show.html
@@ -1,5 +1,20 @@
 {% extends 'base.html' %}
 
+{% macro new_email_row(tmp_id) %}
+<tr>
+	<td>
+		<input class="form-control form-control-sm" type="email" name="newemail-{{ tmp_id }}-address" placeholder="{{ _('New address') }}">
+	</td>
+	<td class="text-center">
+		<input type="checkbox" value="1" name="newemail-{{ tmp_id }}-verified">
+	</td>
+	<td class="text-center">
+		<button type="button" class="btn btn-sm delete-new-email-row d-none"><i class="fas fa-times"></i></button>
+	</td>
+</tr>
+
+{% endmacro %}
+
 {% block body %}
 <form action="{{ url_for("user.update", id=user.id) }}" method="POST">
 <div class="align-self-center">
@@ -65,19 +80,76 @@
 					{{_("If you leave this empty it will be set to the login name. At most 128, at least 2 characters. No character restrictions.")}}
 				</small>
 			</div>
+
+			{% if not user.id %}
 			<div class="form-group col">
-				<label for="user-mail">{{_("Mail")}}</label>
-				<input type="email" class="form-control" id="user-mail" name="mail" value="{{ user.mail or '' }}">
+				<label for="user-email">{{_("E-Mail Address")}}</label>
+				<input type="email" class="form-control" id="user-email" name="email" value="">
 				<small class="form-text text-muted">
-					{{_("Check that the address is unique. A user can take over another account if both have the same mail address set.")}}
+					{{_("Make sure the address is correct! Services might use e-mail addresses as account identifiers and rely on them being unique and verified.")}}
 				</small>
 			</div>
+			{% else %}
+			<div class="form-group col">
+				<span>{{_("E-Mail Addresses")}}</span>
+				<table class="table table-sm mt-2">
+					<thead>
+						<tr>
+							<th scope="col">{{_("Address")}}</th>
+							<th scope="col" class="text-center">{{_("Verified")}}</th>
+							<th scope="col" class="text-center">{{_("Delete")}}</th>
+						</tr>
+					</thead>
+					<tbody id="email-rows">
+						{% for email in user.all_emails %}
+						<tr>
+							<td>
+								<input type="hidden" name="email-{{ email.id }}-present" value="1">
+								{{ email.address }}
+								{% if email == user.primary_email %}
+								<span class="badge badge-primary">{{ _('primary') }}</span>
+								{% endif %}
+							</td>
+							<td class="text-center">
+								<input type="checkbox" value="1" name="email-{{ email.id }}-verified"{{ ' checked disabled' if email.verified }}>
+							</td>
+							<td class="text-center">
+								<input type="checkbox" value="1" name="email-{{ email.id }}-delete"{{ ' disabled' if email == user.primary_email }}>
+							</td>
+						</tr>
+						{% endfor %}
+						{{ new_email_row(0) }}
+					</tbody>
+				</table>
+				<small class="form-text text-muted">
+					{{_("Make sure that addresses you add are correct! Services might use e-mail addresses as account identifiers and rely on them being unique and verified.")}}
+				</small>
+			</div>
+			<div class="form-group col">
+				<label>{{_("Primary E-Mail Address")}}</label>
+				<select name="primary_email" class="form-control">
+					{% for email in user.all_emails if email.verified %}
+					<option value="{{ email.id }}"{{ ' selected' if email == user.primary_email }}>{{ email.address }}</option>
+					{% endfor %}
+				</select>
+			</div>
+			<div class="form-group col">
+				<label>{{_("Recovery E-Mail Address")}}</label>
+				<select name="recovery_email" class="form-control">
+					<option value="primary"{{ ' selected' if not user.recovery_email }}>{{ _('Use primary address') }}</option>
+					{% for email in user.all_emails if email.verified %}
+					<option value="{{ email.id }}" {{ 'selected' if email == user.recovery_email }}>{{ email.address }}</option>
+					{% endfor %}
+				</select>
+			</div>
+			{% endif %}
+
 			<div class="form-group col">
 				<label for="user-loginname">{{_("Password")}}</label>
 				{% if user.id %}
 				<input type="password" class="form-control" id="user-password" name="password" placeholder="●●●●●●●●" minlength={{ User.PASSWORD_MINLEN }} maxlength={{ User.PASSWORD_MAXLEN }} pattern="{{ User.PASSWORD_REGEX }}">
 				{% else %}
-				<input type="password" class="form-control" id="user-password" name="password" placeholder="{{_("mail to set it will be sent")}}" readonly>
+				<input type="password" class="form-control" id="user-password" name="password" placeholder="{{_("E-Mail to set it will be sent")}}" readonly>
 				{% endif %}
 				<small class="form-text text-muted">
 					{{ User.PASSWORD_DESCRIPTION|safe }}
@@ -159,4 +231,17 @@
 	</div>
 </div>
 </form>
+
+<script>
+$('#email-rows').on('click', '.delete-new-email-row', function () {
+	$(this).closest('tr').remove()
+});
+let new_email_id = 1;
+$('#email-rows').on('input', 'tr:last input', function () {
+	$('#email-rows tr:last button.delete-new-email-row').removeClass('d-none');
+	$('#email-rows').append({{ new_email_row('TMPID')|tojson }}.replace('TMPID', new_email_id));
+	new_email_id ++;
+});
+</script>
+
 {% endblock %}
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index 2675ce774b0fd969fc54ba5df443f0e0a845295a..d40e990ed5133f9eed8b87e4832fabb4c5e23389 100644
GIT binary patch
delta 8789
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

delta 6766
zcmdnJn(6B-ruutAEK?a67#Q*x85m?37#MoEK|BQhBgVkM&%nU&SB!x{n1O+TSDb-?
zje&tdQJjInhk=1XL!5zuhk=2iRGfi<i-Ccm3CiykXJF7`U|^U96+bG@z`)DEz;HpF
zfkB9Yf#IGw*t~j%kKzmrQVa|XtP%_i><kPH3K9$qA|Qh$7#I{87#M6N7#Qps7#QLu
z7#Io}7#QYDFfhn5Ffa&8LJTyJWMGJ9U|_J5WMEhhvQUzN!JUDD!AXjNp_YMxp-YN^
zL5YEZfkT>sL7ahsK^satOEWNtG1N0KL`yR;m@qIf6iGuIvRs;h!Ht1|;gB=~gCqk3
z1E&l~Ap?W53?wA1WEdDM7#JA5WFS6nmVtywAC#U7RX0Zl;_x*x3=FIc3=D5%7#Q*x
z7#KduFfed3Ffb&_GBEJgGcYh@%R&@X$}%vpFfcIG%Q7$+GB7Z-$U-dKCd<Ge%fP^J
zOqPK`l!1Zai7W$yGy?;}FIff#K?Vi}F*yjWA;-YL!N9;^B?ob^vm8WSlpHvy8Or1s
z82CUTBFDf0ishMd3=H+4I9m$UxIqr$lbv#qpgkZ5vGAN6B#xgzHGY(XMA2WUxR5+V
zUS1v&Vmk5+44MoK3~ureiwoo#7%V{^l82bT532sCJVQM=jxWhWT>b?r@mC&VF_!`Z
zgE}ZpC_v)QLje*+2?`MTY6XaoCPL{23JeSi3=9nG6&M(-L5WQP5(PYpkhJBa$iR@v
zz`zg!<?mChhgf_}5#qA*iVO?_3=9m{6d^(XToDpf-xV1cm>C!tn3W(w%B}=Sgvv?~
zi=31oAy%gZF|P;8p9-ZHD?vhJqY}iP+e#3JKdM)Pr1FnS3=CHp7#M_=85k-U7#LnE
zGcZhKU|>j6fmrxO1)@<vm4QK-fq_9>72-f^Rfs{}P=1suB(-O$GB9jrU|{HliaV-7
zLLg8L5=D_}kPxjeR)fS*w;Ci4=c_?NV2v8Y#e3Bt<-&8QIu><E;*wB@i0i9E9Ok1A
z38`Rph<Wkq5Qi13Lp<1|&cL9-z`(FXoq<7xfq~&H#GHDDPwJ33;?#h+&{G3qV4wyA
zgEj*LLxct-ceH3gaz~d2B>VJhK;n9)1_OgC0|Ub@4Tz5(Xh5RohXw<K3n-*CA!#T^
z6B2SQnh^6=Xo9kDJp;o!sDhoE5Q83SLJ}9d76XGk0|SGs79_}QwIGQvTnpmBA}xrI
zE43g#Yt@1{Xq^_sq35+AA$mm%;;@HWkSP191u>sfn}I<Wl>g<mA^F=^8)87VHYDhJ
zv>_of8>Endfnl*W$i)l{7quY<-GTDoXhR(GT^kZI%sLDV`k<m(2jUQ09R`L_P>HAm
z(LX~6l9(6kFfi1E%I@Vl5SMPzfyDhm9Y`5{Ne2>SPjw&$bLv7uL|7LRhsL@PbrHG{
zec8GU42}#83^lq842=v74EuB;<%EMC1H%*s28K{Q28N9c3=FUIAh~3YK0`gY#9FEk
zF<_lOB=PLmha|qs`jEu-7s?kgfcVtT0ODXT14!BlH(+2$XJBBM4we6J00}v6Lx_38
zhL8|dH)LSQXJBA3Gh|?BV_;xdW>^o&x4K3U+S&->GEXCj#X(T<Oe2Uv<wgt)whRmm
z-9`)yMhpxLCyf{wBp4VN{un{hgs3qjYGjNdA*N#tvDnNQk|<q`A!#VZ7*add*Be6;
z&nja`P+o>=d}a)B&<800pD`rWa+pAhQa%%iK^`U${mCYfxb885IAo#;149G@1H%jx
zNQiwjfjIQ92_)piOd)i=vMD5v3``+DcQu7nCVr+6mnT3K=9@Aw%mBqLRGp|9#DQ{V
zkf_r!gP3D$25~@;8N@@8W{^aeV+IM)ax+M~q0bCl(A6{CHiNh{#GHX)5(5LnT62g2
z4i=C&bcfRZ77z=<Eg-dHjs*ilD+2?=JPQVf2@DJjDwdFJy44aARp%`k7#1@yFg&(o
zU<hDfU}(2uV6b6eV7Ot$z+k|@z#wW3N@Mj53~tttAT6+lG#pl0Lk#+44atr?Hjq@U
zYs0|cz`($eZv)8{8*Cs!e#Hh7H8*V_`TD*M1H)Se28ORT3=HNV2iQWgx3nE3+gaK{
zLd@2Vfq@T{|NZPBK9046G!Rnk7#KVl7#J?wF)(;BFfhp2Lqa0m9^!+1dq~`^v4>c^
z6H4#5hs5<!dq|Oc(H`Q^&-ReSC++}=S~CZTeI5=B4E3NEj=uxM<(W{4dIw0{wK+ht
z*GvZnhA0LGhMNwMrk05#gr49CiLy723=FBDhKmyegBb$@!&4^)h8U2;of#Nh85kHU
zogqc}erE=TV+;%oubm+t-Q>bh4{mBHx-u}7FfcG=xH2#pGcYjRb%i88K{rUyE4V>w
zwInwN1~mo-hE_L7B3k1H$%a4OAQtJkLyGVscLoMKQ0jMw)SlPf85l|#7#KdggW{Zl
zA=3j=Bv1CJhh(4C9t;e+3=9lcJQx@v85kItJQ)}g85kI1p!7aZh`|b83=Bs=EgLTe
z22it^+Z&P#7J5U{%zAG~6rA*iSbWtRl14syLvq(QZ%A(V@6Eu##K6G7Rqq3dTV5zF
z>H~>eSszG2qUFQD(96icVC4geyA!^U)P2hrV&FSpNKk+Gg(RvFKZpZT{U8o2^kZN!
z1x1Y?#Nw@fkf=T22MK}t%YKkl{@M=`v^@ThL}ly`NfVa-kdn&Z9}+TE{tOIpppf#1
zgotqfBwI!XFfb&8`Vj#P3~Lw|7%l}cfTOlPkb$8H)OZbqR7#dXkb)~S2wWc2GqeOj
zDwWwmkfzk`AV|aFdk_OdFaraFNH8QQGlC%wY6*t;U{)~1XKRBYK6@MtDRSQhLqhaN
zFeF46LLd$i41t7fL<j@JOa=yq)DV#RdIpC7A&{Wr422jZ9tw#wrBF!fR}Y2McIlyz
zMA!qR*M~xUaw!xNHE%;9QS(0(lD&k(Am&PiLG){cLDc((K|(Ae44VIIK@3pWD-7f!
z28P*TkhonM2JykVFi42(4}(~ACk*0~cTj_VL-_*X5OLXXh(%iAkdn?d91?<M;gAAu
zQaB`pZiO>2)Pp(@kDv;_ghLXKY6OHfi~z?GgKY!@gC3~a905t?%@L3iZEgf4>UKv!
ze11Cul5O8dFfiyaFfja!fMj3oNJv!XMnclo!AMBg?06(YJ-E~88U=BAKorDfu~85M
zQ==epT^R+*4LwniB6Lm^B(W}tf@Is{Q4FAHX80Nf2_dg&NL0o|L-eIbL(D6WhIHM!
zqapSjiLQqP@tJ4_a7W{AGy}sAP-irTfuR!QgBS*e)eH;_6|s;?M<ostGGTF$k}fF@
z;=q}45R2EuL85S993)qrh+|+l1ZvL5K^(Fs9wNUd9uk7b>f<4)`bj(_&VI#193+|m
zF<31D5|ow+5DjSw5Qnxv=`{(EIKGkq33}E<2rZHbF;6)W;&Z!1NJ$x#2+7W^iI7BD
zzabHlxUMEbs>x4@5SKb9L3|pV1ZnZaCqZ03HwltEmM1|H>6#=+uGpFcao7zgA5<8C
z8llR`kn$opnSsHNfq`LDG9+XeQ^5M`8I)2W7HOwI^0RpgB(deDKoZ-W6i5hcOM!&I
z;}l40W=sVez#y9nNjqw(5Pim}kb)*D6%rDYp!C92h`tR_`b;Xw<Mj*-FH#v8tUz&|
z2B|h3(jbW{DGd^*6Vf1wX;B(voM0`K{|GAoHVu*s{-;5FCZ7%oa))$CffkYu39$+3
zkfM5OIwWd8r86)vgX(|Q42VlPGazj=i42GW!wg6n?UDhhq+&84K5fl_#NDI}h(otS
z^*x2^`<KDM5Dpq4$z))tV_;y&%!Fj)7nuwUD;XFV{%0~U9AIEzSeXUw|7T@Gg0eIl
zVsTwIM8m{vNRY0`hGe(v*^m~{zifzu?Q<Y$#V-faV2R6tRO8EYAc<He7g7@H=0f-;
zxeyPz<}xt!FfcI0<}%bXtOIojav??K%sfbtFVBPcbY~tU6(7rkSokmx6lV+!AMzL&
zoIpLCd`Nx{$cLnr&U{EBU7QaoV%O$F%sZ41@z9lgNJHdxJ|xP-3+f?FD)RzJTdkx3
zBC)0b66a?MAc^xjl>fK@;-hy3khuL?0CA9EAtY`16+#@)45fPu85nXw9g{+cIjlvH
zT2rtH;z9HJB1jOr6hY!XqzF<n<rYDrq7+JZK-JAIVqjRsz`(G&2ohpp#SovS6hrh^
z7DGZ}QZb}lSyK$L@K7-%BwiFl($249NMfv)FJWMq1!~uqK+0tCQb;?0YAM90dSws?
znU+C(U|R;U(6bDZE#t}{)oFJbq%Ejg4k-_IltZHIb~&U3d{qwVdabR1M4fCUxO-mD
zkWdL}lP#@eU~pz&U^rjNzz_r)38{kQ_p~aAMJ-hf4BiY33>&K$7(77(l2r`gE|_~Y
zq)w=+hGet4YKYI9s~H$X7#SF5Rzuo?>uMmm=xq%|y;v>C{CWlksal8zi&{vW_|-Bn
zWHT@@MAt$z9<PO1$W{kQ69IJ$41pl|I!H*Yt%KyALv@glI$Z||smpZ`kKM0><RZ>`
zNC9L~4=Eo)>KPb#K>0tT9#U|W)<aU~w0ej|hoF_y*?LI5eXkzUlu~PejAG4f0C%An
zm>U@wHh`kA5mMCZHbGiEJxvS@RiJ)E6U4!O&5*KxK{ErxCk6(F)6EPF^`K_)z7|OP
z-lUa*p@V^ep}&;@JR&O521zu&ZIG5wd>bUS&u@eHY)=~lgD(RE!?QL>ZD`UCacFis
zq+r_I4$=3p9Te0I42m6)v|`@@skCl(Fw}!bGPyb-4F$DMh`|}15T8!!gjB<<T@ZeK
z7sLUdx)>NfGcYjlcSA<Gn0p`&-QEK!FHZJAqVh=(r04Uo2a+}fdLdCG3Z)f$p@T}g
zy^uIG>V?FibuXl(a_)s>r|90zSrWpGlP5}=O!k#h*jy@Q$};)9TwHx>a<)QAMq-IV
zNk*zdVoFL;YH_hbX0bwPUS?rws-A+QLTPbokwS7}o<d1tcB(>tS!$6&VqShp28d2h
z&M(a?QOHbFNXjqCP{>FuO9dNLoS2)ckeis912?ibwM37>IVUqUuSCJq1;z^A+%Ip-
zJo$jK;$&qN!OcIEKXRHf_=5B~LYxBOAh|0uuPiYqGX)}^S*!r%PyVPYIk{9>XS14q
z9HVGJVsUYKeo+d8UurqT8O5n3n^zmSGEY`9`8(OeRBv*-DgWkmrYo2>XIkWOZT{#a
z$~ald#ddO}%hb)^UF@0p6v{JmaukYF^HLNl^GheMbCa0-+nrY+GY!O5NX|&iOHWnE
zEZJ=AA<iKU_GwAJLUC$|LS_j>Yf>smcgbYW09pRx)a3lUlwyYB)S|M?<kZO>0gW7n
zCJKheR)(gV1p|wjCeI5tpS(RpXY=cjT9(OkB6Hmj&q+!xDkwU<CoNS+AwNkWB{NmQ
zRTmUAx{lyTN>#|qPtH&%%}YwnD=n!iQOGPVQB-iv%u{fL#!7Z#UfyK+Mscgddy-O%
z@=`N$Q}c8b%2V_56p~UiQ&PcZ7N_PUVVGQ&T2!8zmy(*dIU|aZW%7nH-Oa1w%XlVd
z#_>(gh~w8#$jeO5C{X~%L@EQivk-EV<#OaF%fwr6K9_rzQL$J78ZRa2TGCSwFUd<u
zoh(=^w)s~6Wai0ti`6#YDVArPtWs({*&&f_a($`LWQQ_=&4){UnKx@x=J0IZ+$zDi
z`Dq&q<L0mJf0%iS6v|VJQd092QZkDs*L6#1RF&o`1gDl%frL_v6!KC_Q}fV65EQ+e
kUw4OcP7aTkpX@MINiaAyyA<ScSX41gcFYu=Ja4Kj0J}>A2><{9

diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index 87a74d14..293df9db 100644
--- a/uffd/translations/de/LC_MESSAGES/messages.po
+++ b/uffd/translations/de/LC_MESSAGES/messages.po
@@ -7,7 +7,7 @@ msgid ""
 msgstr ""
 "Project-Id-Version: PROJECT VERSION\n"
 "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
-"POT-Creation-Date: 2022-08-14 13:15+0200\n"
+"POT-Creation-Date: 2022-08-28 17:21+0200\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -18,333 +18,386 @@ msgstr ""
 "Content-Transfer-Encoding: 8bit\n"
 "Generated-By: Babel 2.8.0\n"
 
-#: uffd/ratelimit.py:76
+#: uffd/models/invite.py:82 uffd/models/invite.py:105 uffd/models/invite.py:110
+msgid "Invite link is invalid"
+msgstr "Einladungslink ist ungültig"
+
+#: uffd/models/invite.py:84
+msgid "Invite link does not grant any roles"
+msgstr "Einladungslink weist keine Rollen zu"
+
+#: uffd/models/invite.py:86
+msgid "Invite link does not grant any new roles"
+msgstr "Einladungslink weist keine neuen Rollen zu"
+
+#: uffd/models/invite.py:91 uffd/models/signup.py:115
+#: uffd/templates/mfa/setup.html:225
+msgid "Success"
+msgstr "Erfolgreich"
+
+#: uffd/models/ratelimit.py:76
 msgid "a few seconds"
 msgstr "ein paar Sekunden"
 
-#: uffd/ratelimit.py:78
+#: uffd/models/ratelimit.py:78
 msgid "30 seconds"
 msgstr "30 Sekunden"
 
-#: uffd/ratelimit.py:80
+#: uffd/models/ratelimit.py:80
 msgid "one minute"
 msgstr "eine Minute"
 
-#: uffd/ratelimit.py:82
+#: uffd/models/ratelimit.py:82
 #, python-format
 msgid "%(minutes)d minutes"
 msgstr "%(minutes)d Minuten"
 
-#: uffd/ratelimit.py:84
+#: uffd/models/ratelimit.py:84
 msgid "one hour"
 msgstr "eine Stunde"
 
-#: uffd/ratelimit.py:85
+#: uffd/models/ratelimit.py:85
 #, python-format
 msgid "%(hours)d hours"
 msgstr "%(hours)d Stunden"
 
-#: uffd/invite/models.py:81 uffd/invite/models.py:104 uffd/invite/models.py:109
-msgid "Invite link is invalid"
-msgstr "Einladungslink ist ungültig"
+#: uffd/models/signup.py:77 uffd/models/signup.py:102
+msgid "Invalid signup request"
+msgstr "Ungültiger Account-Registrierungs-Link"
 
-#: uffd/invite/models.py:83
-msgid "Invite link does not grant any roles"
-msgstr "Einladungslink weist keine Rollen zu"
+#: uffd/models/signup.py:79
+msgid "Login name is invalid"
+msgstr "Anmeldename ist ungültig"
 
-#: uffd/invite/models.py:85
-msgid "Invite link does not grant any new roles"
-msgstr "Einladungslink weist keine neuen Rollen zu"
+#: uffd/models/signup.py:81
+msgid "Display name is invalid"
+msgstr "Anzeigename ist ungültig"
 
-#: uffd/invite/models.py:90 uffd/mfa/templates/mfa/setup.html:225
-#: uffd/signup/models.py:115
-msgid "Success"
-msgstr "Erfolgreich"
+#: uffd/models/signup.py:83 uffd/views/selfservice.py:112 uffd/views/user.py:54
+#: uffd/views/user.py:73
+msgid "E-Mail address is invalid"
+msgstr "Ungültige E-Mail-Adresse"
 
-#: uffd/invite/views.py:46
-msgid "Invites"
-msgstr "Einladungslinks"
+#: uffd/models/signup.py:85 uffd/views/selfservice.py:49
+msgid "Invalid password"
+msgstr "Passwort ungültig"
 
-#: uffd/invite/views.py:75
-msgid "The \"Expires After\" date is too far in the future"
-msgstr "Das Ablaufdatum liegt zu weit in der Zukunft"
+#: uffd/models/signup.py:87 uffd/models/signup.py:106
+msgid "A user with this login name already exists"
+msgstr "Ein Account mit diesem Anmeldenamen existiert bereits"
 
-#: uffd/invite/views.py:78
-msgid "You are not allowed to create invite links with these permissions"
-msgstr "Dir fehlen Berechtigungen um diesen Einladungslink zu erstellen"
+#: uffd/models/signup.py:88
+msgid "Valid"
+msgstr "Gültig"
 
-#: uffd/invite/views.py:81
-msgid "Invite link must either allow signup or grant at least one role"
+#: uffd/models/signup.py:104 uffd/views/signup.py:104
+msgid "Wrong password"
+msgstr "Falsches Passwort"
+
+#: uffd/models/user.py:35
+#, python-format
+msgid ""
+"At least %(minlen)d and at most %(maxlen)d characters. Only letters, "
+"digits, spaces and some symbols (<code>%(symbols)s</code>) allowed. "
+"Please use a password manager."
 msgstr ""
-"Einladungslink muss entweder Account-Registrierung erlauben oder Rollen "
-"vergeben"
+"%(minlen)d bis %(maxlen)d Zeichen. Nur Buchstaben, Ziffern, Leerzeichen "
+"und manche Symbole (<code>%(symbols)s</code>), keine Umlaute. Bitte "
+"verwende einen Passwort-Manager."
 
-#: uffd/invite/views.py:111 uffd/invite/views.py:146
-msgid "Invalid invite link"
-msgstr "Ungültiger Einladungslink"
+#: uffd/templates/403.html:10
+msgid "Access Denied"
+msgstr "Zugriff verweigert"
 
-#: uffd/invite/views.py:129
-msgid "Roles successfully updated"
-msgstr "Rollen erfolgreich geändert"
+#: uffd/templates/403.html:17
+msgid "You don't have the permission to access this page."
+msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen."
 
-#: uffd/invite/views.py:149
-msgid "Invite link does not allow signup"
-msgstr "Einladungslink erlaubt keine Account-Registrierung"
+#: uffd/templates/base.html:84
+msgid "Change"
+msgstr "Ändern"
 
-#: uffd/invite/views.py:175 uffd/selfservice/views.py:49
-#: uffd/signup/views.py:49
-msgid "Passwords do not match"
-msgstr "Die Passwörter stimmen nicht überein"
+#: uffd/templates/base.html:92 uffd/templates/session/deviceauth.html:12
+msgid "Authorize Device Login"
+msgstr "Gerätelogin erlauben"
 
-#: uffd/invite/views.py:181 uffd/signup/views.py:55
-#, python-format
-msgid "Too many signup requests with this mail address! Please wait %(delay)s."
-msgstr ""
-"Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte "
-"%(delay)s."
+#: uffd/templates/base.html:93 uffd/templates/session/devicelogin.html:6
+msgid "Device Login"
+msgstr "Gerätelogin"
 
-#: uffd/invite/views.py:184 uffd/signup/views.py:58
-#, python-format
-msgid "Too many requests! Please wait %(delay)s."
-msgstr "Zu viele Anfragen! Bitte warte %(delay)s."
+#: uffd/templates/base.html:99 uffd/templates/oauth2/logout.html:5
+msgid "Logout"
+msgstr "Abmelden"
 
-#: uffd/invite/views.py:199 uffd/signup/views.py:76
-msgid "Could not send mail"
-msgstr "Mailversand fehlgeschlagen"
+#: uffd/templates/base.html:106 uffd/templates/service/overview.html:15
+#: uffd/templates/session/login.html:6 uffd/templates/session/login.html:20
+msgid "Login"
+msgstr "Anmelden"
+
+#: uffd/templates/base.html:142
+msgid "About uffd"
+msgstr "Über uffd"
 
-#: uffd/invite/templates/invite/list.html:6
-#: uffd/mail/templates/mail/list.html:8 uffd/role/templates/role/list.html:8
-#: uffd/service/templates/service/index.html:8
-#: uffd/service/templates/service/show.html:50
-#: uffd/service/templates/service/show.html:78
-#: uffd/user/templates/group/list.html:8 uffd/user/templates/user/list.html:8
+#: uffd/templates/group/list.html:8 uffd/templates/invite/list.html:6
+#: uffd/templates/mail/list.html:8 uffd/templates/role/list.html:8
+#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:50
+#: uffd/templates/service/show.html:78 uffd/templates/user/list.html:8
 msgid "New"
 msgstr "Neu"
 
-#: uffd/invite/templates/invite/list.html:12
+#: uffd/templates/group/list.html:14
+msgid "GID"
+msgstr "GID"
+
+#: uffd/templates/group/list.html:15 uffd/templates/group/show.html:26
+#: uffd/templates/invite/new.html:35 uffd/templates/mail/list.html:14
+#: uffd/templates/mail/show.html:7 uffd/templates/mfa/setup.html:98
+#: uffd/templates/mfa/setup.html:99 uffd/templates/mfa/setup.html:107
+#: uffd/templates/mfa/setup.html:157 uffd/templates/mfa/setup.html:158
+#: uffd/templates/mfa/setup.html:169 uffd/templates/role/list.html:14
+#: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44
+#: uffd/templates/selfservice/self.html:165
+#: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20
+#: uffd/templates/service/show.html:84 uffd/templates/user/show.html:178
+#: uffd/templates/user/show.html:210
+msgid "Name"
+msgstr "Name"
+
+#: uffd/templates/group/list.html:16 uffd/templates/group/show.html:33
+#: uffd/templates/invite/new.html:36 uffd/templates/role/list.html:15
+#: uffd/templates/role/show.html:48 uffd/templates/rolemod/list.html:10
+#: uffd/templates/rolemod/show.html:26 uffd/templates/selfservice/self.html:166
+#: uffd/templates/user/show.html:179 uffd/templates/user/show.html:211
+msgid "Description"
+msgstr "Beschreibung"
+
+#: uffd/templates/group/show.html:8 uffd/templates/mail/show.html:27
+#: uffd/templates/role/show.html:13 uffd/templates/rolemod/show.html:8
+#: uffd/templates/service/api.html:15 uffd/templates/service/oauth2.html:15
+#: uffd/templates/service/show.html:16 uffd/templates/user/show.html:22
+msgid "Save"
+msgstr "Speichern"
+
+#: uffd/templates/group/show.html:9 uffd/templates/invite/new.html:56
+#: uffd/templates/mail/show.html:28 uffd/templates/mfa/auth.html:33
+#: uffd/templates/role/show.html:14 uffd/templates/rolemod/show.html:9
+#: uffd/templates/service/api.html:9 uffd/templates/service/oauth2.html:9
+#: uffd/templates/service/show.html:10
+#: uffd/templates/session/deviceauth.html:39
+#: uffd/templates/session/deviceauth.html:49
+#: uffd/templates/session/devicelogin.html:29 uffd/templates/user/show.html:23
+msgid "Cancel"
+msgstr "Abbrechen"
+
+#: uffd/templates/group/show.html:11 uffd/templates/role/show.html:19
+#: uffd/templates/role/show.html:21 uffd/templates/selfservice/self.html:61
+#: uffd/templates/selfservice/self.html:180 uffd/templates/service/api.html:11
+#: uffd/templates/service/oauth2.html:11 uffd/templates/service/show.html:12
+#: uffd/templates/user/show.html:25 uffd/templates/user/show.html:166
+msgid "Are you sure?"
+msgstr "Wirklich fortfahren?"
+
+#: uffd/templates/group/show.html:11 uffd/templates/group/show.html:13
+#: uffd/templates/mail/show.html:30 uffd/templates/mail/show.html:32
+#: uffd/templates/mfa/setup.html:117 uffd/templates/mfa/setup.html:179
+#: uffd/templates/role/show.html:21 uffd/templates/role/show.html:24
+#: uffd/templates/selfservice/self.html:62 uffd/templates/service/api.html:12
+#: uffd/templates/service/oauth2.html:12 uffd/templates/service/show.html:13
+#: uffd/templates/user/show.html:25 uffd/templates/user/show.html:27
+#: uffd/templates/user/show.html:100
+msgid "Delete"
+msgstr "Löschen"
+
+#: uffd/templates/group/show.html:18
+msgid "Group ID"
+msgstr "Gruppen ID"
+
+#: uffd/templates/group/show.html:29 uffd/templates/signup/start.html:18
+msgid ""
+"At least one and at most 32 lower-case characters, digits, dashes (\"-\")"
+" or underscores (\"_\"). <b>Cannot be changed later!</b>"
+msgstr ""
+"1 bis 32 Kleinbuchstaben, Zahlen, Binde- (\"-\") und Unterstriche "
+"(\"_\"). <b>Kann später nicht geändert werden!</b>"
+
+#: uffd/templates/group/show.html:40 uffd/templates/role/show.html:71
+#: uffd/templates/rolemod/show.html:16
+msgid "Members"
+msgstr "Mitglieder"
+
+#: uffd/templates/invite/list.html:12
 msgid "Link"
 msgstr "Link"
 
-#: uffd/invite/templates/invite/list.html:13
+#: uffd/templates/invite/list.html:13
 msgid "Created by"
 msgstr "Erstellt durch"
 
-#: uffd/invite/templates/invite/list.html:14
-#: uffd/service/templates/service/api.html:34
-#: uffd/service/templates/service/show.html:85
+#: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34
+#: uffd/templates/service/show.html:85
 msgid "Permissions"
 msgstr "Berechtigungen"
 
-#: uffd/invite/templates/invite/list.html:15
+#: uffd/templates/invite/list.html:15
 msgid "Usages"
 msgstr "Verwendungen"
 
-#: uffd/invite/templates/invite/list.html:16
+#: uffd/templates/invite/list.html:16
 msgid "Status"
 msgstr "Status"
 
-#: uffd/invite/templates/invite/list.html:26
+#: uffd/templates/invite/list.html:26
 msgid "Copy link to clipboard"
 msgstr "Link kopieren"
 
-#: uffd/invite/templates/invite/list.html:27
+#: uffd/templates/invite/list.html:27
 msgid "Show link as QR code"
 msgstr "Link als QR-Code anzeigen"
 
-#: uffd/invite/templates/invite/list.html:40
+#: uffd/templates/invite/list.html:40
 msgid "Signup"
 msgstr "Account-Registrierung"
 
-#: uffd/invite/templates/invite/list.html:44
+#: uffd/templates/invite/list.html:44
 msgid "user signups"
 msgstr "Account-Registrierungen"
 
-#: uffd/invite/templates/invite/list.html:49
-#: uffd/user/templates/user/show.html:91
+#: uffd/templates/invite/list.html:49 uffd/templates/user/show.html:163
 msgid "Disabled"
 msgstr "Deaktiviert"
 
-#: uffd/invite/templates/invite/list.html:51
+#: uffd/templates/invite/list.html:51
 msgid "Voided"
 msgstr "Verbraucht"
 
-#: uffd/invite/templates/invite/list.html:53
+#: uffd/templates/invite/list.html:53
 msgid "Expired"
 msgstr "Abgelaufen"
 
-#: uffd/invite/templates/invite/list.html:55
+#: uffd/templates/invite/list.html:55
 msgid "Invalid, unpermitted creator"
 msgstr "Ungültig, erstellt durch unberechtigten Account"
 
-#: uffd/invite/templates/invite/list.html:57
+#: uffd/templates/invite/list.html:57
 msgid "Invalid"
 msgstr "Ungültig"
 
-#: uffd/invite/templates/invite/list.html:59
+#: uffd/templates/invite/list.html:59
 #, python-format
 msgid "Valid once, expires %(expiry_date)s"
 msgstr "Einmal verwendbar, gültig bis %(expiry_date)s"
 
-#: uffd/invite/templates/invite/list.html:61
+#: uffd/templates/invite/list.html:61
 #, python-format
 msgid "Valid, expires %(expiry_date)s"
 msgstr "Gültig bis %(expiry_date)s"
 
-#: uffd/invite/templates/invite/list.html:78
+#: uffd/templates/invite/list.html:78
 msgid "Invite Link Details"
 msgstr "Details zum Einladungslink"
 
-#: uffd/invite/templates/invite/list.html:85
+#: uffd/templates/invite/list.html:85
 msgid "Type:"
 msgstr "Typ:"
 
-#: uffd/invite/templates/invite/list.html:85
+#: uffd/templates/invite/list.html:85
 msgid "Single-use"
 msgstr "Einmal verwendbar"
 
-#: uffd/invite/templates/invite/list.html:85
-#: uffd/invite/templates/invite/new.html:9
+#: uffd/templates/invite/list.html:85 uffd/templates/invite/new.html:9
 msgid "Multi-use"
 msgstr "Mehrfach verwendbar"
 
-#: uffd/invite/templates/invite/list.html:86
+#: uffd/templates/invite/list.html:86
 msgid "Created:"
 msgstr "Erstellt:"
 
-#: uffd/invite/templates/invite/list.html:87
+#: uffd/templates/invite/list.html:87
 msgid "Expires:"
 msgstr "Ablaufdatum:"
 
-#: uffd/invite/templates/invite/list.html:88
+#: uffd/templates/invite/list.html:88
 msgid "Permissions:"
 msgstr "Berechtigungen:"
 
-#: uffd/invite/templates/invite/list.html:91
-#: uffd/invite/templates/invite/new.html:21
+#: uffd/templates/invite/list.html:91 uffd/templates/invite/new.html:21
 msgid "Link allows account registration"
 msgstr "Link erlaubt Account-Registrierung"
 
-#: uffd/invite/templates/invite/list.html:93
-#: uffd/invite/templates/invite/new.html:22
+#: uffd/templates/invite/list.html:93 uffd/templates/invite/new.html:22
 msgid "No account registration allowed"
 msgstr "Keine Account-Registrierung möglich"
 
-#: uffd/invite/templates/invite/list.html:96
+#: uffd/templates/invite/list.html:96
 #, python-format
 msgid "Link grants users the role \"%(name)s\""
 msgstr "Link gibt Accounts die Rolle \"%(name)s\""
 
-#: uffd/invite/templates/invite/list.html:102
+#: uffd/templates/invite/list.html:102
 msgid "Never used"
 msgstr "Keine Verwendungen"
 
-#: uffd/invite/templates/invite/list.html:106
+#: uffd/templates/invite/list.html:106
 #, python-format
 msgid "Registration of user <a href=\"%(user_url)s\">%(user_name)s</a>"
 msgstr "Account-Registrierung von <a href=\"%(user_url)s\">%(user_name)s</a>"
 
-#: uffd/invite/templates/invite/list.html:109
+#: uffd/templates/invite/list.html:109
 #, python-format
 msgid "Roles granted to <a href=\"%(user_url)s\">%(user_name)s</a>"
 msgstr "Rollen an <a href=\"%(user_url)s\">%(user_name)s</a> vergeben"
 
-#: uffd/invite/templates/invite/list.html:120
+#: uffd/templates/invite/list.html:120
 msgid "Disable Link"
 msgstr "Link deaktivieren"
 
-#: uffd/invite/templates/invite/list.html:124
+#: uffd/templates/invite/list.html:124
 msgid "Reenable Link"
 msgstr "Link reaktivieren"
 
-#: uffd/invite/templates/invite/list.html:138
+#: uffd/templates/invite/list.html:138
 msgid "Invite"
 msgstr "Einladungslink"
 
-#: uffd/invite/templates/invite/new.html:6
+#: uffd/templates/invite/new.html:6
 msgid "Link Type"
 msgstr "Link Typ"
 
-#: uffd/invite/templates/invite/new.html:8
+#: uffd/templates/invite/new.html:8
 msgid "Valid for a single successful use"
 msgstr "Für eine erfolgreiche Verwendung gültig"
 
-#: uffd/invite/templates/invite/new.html:13
+#: uffd/templates/invite/new.html:13
 msgid "Valid Until"
 msgstr "Ablaufdatum"
 
-#: uffd/invite/templates/invite/new.html:15
+#: uffd/templates/invite/new.html:15
 #, python-format
 msgid "Must be within the next %(max_valid_days)d days"
 msgstr "Muss innerhalb der nächsten %(max_valid_days)d Tage liegen"
 
-#: uffd/invite/templates/invite/new.html:19
-#: uffd/signup/templates/signup/start.html:6
+#: uffd/templates/invite/new.html:19 uffd/templates/signup/start.html:6
 msgid "Account Registration"
 msgstr "Account-Registrierung"
 
-#: uffd/invite/templates/invite/new.html:30
+#: uffd/templates/invite/new.html:30
 msgid "Granted Roles"
 msgstr "Enthaltene Rollen"
 
-#: uffd/invite/templates/invite/new.html:35
-#: uffd/mail/templates/mail/list.html:14 uffd/mail/templates/mail/show.html:7
-#: uffd/mfa/templates/mfa/setup.html:98 uffd/mfa/templates/mfa/setup.html:99
-#: uffd/mfa/templates/mfa/setup.html:107 uffd/mfa/templates/mfa/setup.html:157
-#: uffd/mfa/templates/mfa/setup.html:158 uffd/mfa/templates/mfa/setup.html:169
-#: uffd/role/templates/role/list.html:14
-#: uffd/rolemod/templates/rolemod/list.html:9
-#: uffd/rolemod/templates/rolemod/show.html:44
-#: uffd/selfservice/templates/selfservice/self.html:97
-#: uffd/service/templates/service/index.html:14
-#: uffd/service/templates/service/show.html:20
-#: uffd/service/templates/service/show.html:84
-#: uffd/user/templates/group/list.html:15
-#: uffd/user/templates/group/show.html:26
-#: uffd/user/templates/user/show.html:106
-#: uffd/user/templates/user/show.html:138
-msgid "Name"
-msgstr "Name"
-
-#: uffd/invite/templates/invite/new.html:36
-#: uffd/role/templates/role/list.html:15 uffd/role/templates/role/show.html:48
-#: uffd/rolemod/templates/rolemod/list.html:10
-#: uffd/rolemod/templates/rolemod/show.html:26
-#: uffd/selfservice/templates/selfservice/self.html:98
-#: uffd/user/templates/group/list.html:16
-#: uffd/user/templates/group/show.html:33
-#: uffd/user/templates/user/show.html:107
-#: uffd/user/templates/user/show.html:139
-msgid "Description"
-msgstr "Beschreibung"
-
-#: uffd/invite/templates/invite/new.html:55
+#: uffd/templates/invite/new.html:55
 msgid "Create Link"
 msgstr "Link erstellen"
 
-#: uffd/invite/templates/invite/new.html:56
-#: uffd/mail/templates/mail/show.html:28 uffd/mfa/templates/mfa/auth.html:33
-#: uffd/role/templates/role/show.html:14
-#: uffd/rolemod/templates/rolemod/show.html:9
-#: uffd/service/templates/service/api.html:9
-#: uffd/service/templates/service/oauth2.html:9
-#: uffd/service/templates/service/show.html:10
-#: uffd/session/templates/session/deviceauth.html:39
-#: uffd/session/templates/session/deviceauth.html:49
-#: uffd/session/templates/session/devicelogin.html:29
-#: uffd/user/templates/group/show.html:9 uffd/user/templates/user/show.html:8
-msgid "Cancel"
-msgstr "Abbrechen"
-
-#: uffd/invite/templates/invite/use.html:5
+#: uffd/templates/invite/use.html:5
 msgid "Invite Link"
 msgstr "Einladungslink"
 
-#: uffd/invite/templates/invite/use.html:8
+#: uffd/templates/invite/use.html:8
 #, python-format
 msgid "Welcome to the %(org_name)s Single-Sign-On!"
 msgstr "Willkommen im %(org_name)s Single-Sign-On!"
 
-#: uffd/invite/templates/invite/use.html:12
+#: uffd/templates/invite/use.html:12
 msgid ""
 "With this link you can register a new user account with the following "
 "roles or add the roles to an existing account:"
@@ -352,62 +405,45 @@ msgstr ""
 "Mit diesem Link kannst du einen Account mit den folgenden Rollen "
 "erstellen oder diese Rollen zu einem existierenden Account hinzufügen:"
 
-#: uffd/invite/templates/invite/use.html:14
+#: uffd/templates/invite/use.html:14
 msgid "With this link you can add the following roles to an existing account:"
 msgstr ""
 "Mit diesem Link kannst du die folgenden Rollen zu einem existierenden "
 "Account hinzufügen:"
 
-#: uffd/invite/templates/invite/use.html:16
+#: uffd/templates/invite/use.html:16
 msgid "With this link you can register a new user account."
 msgstr "Mit diesem Link kannst du einen Account registieren."
 
-#: uffd/invite/templates/invite/use.html:28
+#: uffd/templates/invite/use.html:28
 msgid "Add the roles to your account now"
 msgstr "Rollen jetzt zu deinem Account hinzufügen"
 
-#: uffd/invite/templates/invite/use.html:30
+#: uffd/templates/invite/use.html:30
 msgid "Logout and switch to a different account"
 msgstr "Abmelden und zu einem anderen Account wechseln"
 
-#: uffd/invite/templates/invite/use.html:33
+#: uffd/templates/invite/use.html:33
 msgid "Logout to register a new account"
 msgstr "Abmelden um einen neuen Account zu registrieren"
 
-#: uffd/invite/templates/invite/use.html:37
+#: uffd/templates/invite/use.html:37
 msgid "Register a new account"
 msgstr "Neuen Account registrieren"
 
-#: uffd/invite/templates/invite/use.html:40
+#: uffd/templates/invite/use.html:40
 msgid "Login and add the roles to your account"
 msgstr "Anmelden und die Rollen zu deinem Account hinzufügen"
 
-#: uffd/mail/views.py:22
-msgid "Forwardings"
-msgstr "Weiterleitungen"
-
-#: uffd/mail/views.py:47
-#, python-format
-msgid "Invalid receive address: %(mail_address)s"
-msgstr "Ungültige Empfangsadresse: %(mail_address)s"
-
-#: uffd/mail/views.py:51
-msgid "Mail mapping updated."
-msgstr "Mailweiterleitung geändert."
-
-#: uffd/mail/views.py:60
-msgid "Deleted mail mapping."
-msgstr "Mailweiterleitung gelöscht."
-
-#: uffd/mail/templates/mail/list.html:15 uffd/mail/templates/mail/show.html:13
+#: uffd/templates/mail/list.html:15 uffd/templates/mail/show.html:13
 msgid "Receiving addresses"
 msgstr "Empfangsadressen"
 
-#: uffd/mail/templates/mail/list.html:16 uffd/mail/templates/mail/show.html:20
+#: uffd/templates/mail/list.html:16 uffd/templates/mail/show.html:20
 msgid "Destinations"
 msgstr "Zieladressen"
 
-#: uffd/mail/templates/mail/show.html:16
+#: uffd/templates/mail/show.html:16
 msgid ""
 "One address pattern (local+ext@domain, local@domain, local, @domain) per "
 "line. Only lower-case ASCII letters, digits and symbols."
@@ -415,167 +451,103 @@ msgstr ""
 "Ein Adressmuster (local+ext@domain, local@domain, local, @domain) pro "
 "Zeile. Nur ASCII-Kleinbuchstaben, -Ziffern und -Symbole."
 
-#: uffd/mail/templates/mail/show.html:23
+#: uffd/templates/mail/show.html:23
 msgid "One address per line"
 msgstr "Eine Adresse pro Zeile"
 
-#: uffd/mail/templates/mail/show.html:27 uffd/role/templates/role/show.html:13
-#: uffd/rolemod/templates/rolemod/show.html:8
-#: uffd/service/templates/service/api.html:15
-#: uffd/service/templates/service/oauth2.html:15
-#: uffd/service/templates/service/show.html:16
-#: uffd/user/templates/group/show.html:8 uffd/user/templates/user/show.html:7
-msgid "Save"
-msgstr "Speichern"
-
-#: uffd/mail/templates/mail/show.html:30 uffd/mail/templates/mail/show.html:32
-#: uffd/mfa/templates/mfa/setup.html:117 uffd/mfa/templates/mfa/setup.html:179
-#: uffd/role/templates/role/show.html:21 uffd/role/templates/role/show.html:24
-#: uffd/service/templates/service/api.html:12
-#: uffd/service/templates/service/oauth2.html:12
-#: uffd/service/templates/service/show.html:13
-#: uffd/user/templates/group/show.html:11
-#: uffd/user/templates/group/show.html:13 uffd/user/templates/user/show.html:10
-#: uffd/user/templates/user/show.html:12
-msgid "Delete"
-msgstr "Löschen"
-
-#: uffd/mfa/views.py:51
-msgid "Two-factor authentication was reset"
-msgstr "Zwei-Faktor-Authentifizierung wurde zurückgesetzt"
-
-#: uffd/mfa/views.py:80
-msgid "Generate recovery codes first!"
-msgstr "Generiere zuerst die Wiederherstellungscodes!"
-
-#: uffd/mfa/views.py:88
-msgid "Code is invalid"
-msgstr "Wiederherstellungscode ist ungültig"
-
-#: uffd/mfa/views.py:107
-#, python-format
-msgid ""
-"2FA WebAuthn support disabled because import of the fido2 module failed "
-"(%s)"
-msgstr ""
-"2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen "
-"werden konnte (%s)"
-
-#: uffd/mfa/views.py:216
-#, python-format
-msgid "We received too many invalid attempts! Please wait at least %s."
-msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s."
-
-#: uffd/mfa/views.py:230
-msgid "You have exhausted your recovery codes. Please generate new ones now!"
-msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!"
-
-#: uffd/mfa/views.py:233
-msgid ""
-"You only have a few recovery codes remaining. Make sure to generate new "
-"ones before they run out."
-msgstr ""
-"Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere "
-"diese erneut bevor keine mehr übrig sind."
-
-#: uffd/mfa/views.py:237
-msgid "Two-factor authentication failed"
-msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen"
-
-#: uffd/mfa/templates/mfa/auth.html:6
-#: uffd/selfservice/templates/selfservice/self.html:67
-#: uffd/user/templates/user/show.html:89
+#: uffd/templates/mfa/auth.html:6 uffd/templates/selfservice/self.html:134
+#: uffd/templates/user/show.html:161
 msgid "Two-Factor Authentication"
 msgstr "Zwei-Faktor-Authentifizierung"
 
-#: uffd/mfa/templates/mfa/auth.html:11
+#: uffd/templates/mfa/auth.html:11
 msgid "Enable javascript for authentication with U2F/FIDO2 devices"
 msgstr "Aktiviere Javascript zur Authentifizierung mit U2F/FIDO2 Geräten"
 
-#: uffd/mfa/templates/mfa/auth.html:15
+#: uffd/templates/mfa/auth.html:15
 msgid "Authentication with U2F/FIDO2 devices is not supported by your browser"
 msgstr ""
 "Authentifizierung mit U2F/FIDO2 Geräten wird von deinem Browser nicht "
 "unterstützt"
 
-#: uffd/mfa/templates/mfa/auth.html:21
+#: uffd/templates/mfa/auth.html:21
 msgid "Authenticate with U2F/FIDO2 device"
 msgstr "Authentifiziere dich mit einem U2F/FIDO2-Gerät"
 
-#: uffd/mfa/templates/mfa/auth.html:24
+#: uffd/templates/mfa/auth.html:24
 msgid "or"
 msgstr "oder"
 
-#: uffd/mfa/templates/mfa/auth.html:27
+#: uffd/templates/mfa/auth.html:27
 msgid "Code from your authenticator app or recovery code"
 msgstr "Code aus deiner Authentifikator-App oder Wiederherstellungscode"
 
-#: uffd/mfa/templates/mfa/auth.html:30
+#: uffd/templates/mfa/auth.html:30
 msgid "Verify"
 msgstr "Verifizieren"
 
-#: uffd/mfa/templates/mfa/auth.html:43 uffd/mfa/templates/mfa/setup.html:199
+#: uffd/templates/mfa/auth.html:43 uffd/templates/mfa/setup.html:199
 msgid "Contacting server"
 msgstr "Verbinde mit Server"
 
-#: uffd/mfa/templates/mfa/auth.html:52 uffd/mfa/templates/mfa/auth.html:80
+#: uffd/templates/mfa/auth.html:52 uffd/templates/mfa/auth.html:80
 msgid "Session timed out"
 msgstr "Sitzung abgelaufen"
 
-#: uffd/mfa/templates/mfa/auth.html:54
+#: uffd/templates/mfa/auth.html:54
 msgid "You have not registered any U2F/FIDO2 devices for your account"
 msgstr "Es sind keine U2F/FIDO2-Geräte für deinen Account registriert"
 
-#: uffd/mfa/templates/mfa/auth.html:56 uffd/mfa/templates/mfa/setup.html:208
+#: uffd/templates/mfa/auth.html:56 uffd/templates/mfa/setup.html:208
 msgid "Server error"
 msgstr "Serverfehler"
 
-#: uffd/mfa/templates/mfa/auth.html:59 uffd/mfa/templates/mfa/setup.html:210
+#: uffd/templates/mfa/auth.html:59 uffd/templates/mfa/setup.html:210
 msgid "Waiting for device"
 msgstr "Warte auf Gerät"
 
-#: uffd/mfa/templates/mfa/auth.html:62
+#: uffd/templates/mfa/auth.html:62
 msgid "Verifing response"
 msgstr "Überprüfe Antwort"
 
-#: uffd/mfa/templates/mfa/auth.html:76
+#: uffd/templates/mfa/auth.html:76
 msgid "Success, redirecting"
 msgstr "Erfolg, leite weiter"
 
-#: uffd/mfa/templates/mfa/auth.html:82 uffd/mfa/templates/mfa/setup.html:228
+#: uffd/templates/mfa/auth.html:82 uffd/templates/mfa/setup.html:228
 msgid "Invalid response from device"
 msgstr "Ungültige Antwort von Gerät"
 
-#: uffd/mfa/templates/mfa/auth.html:88
+#: uffd/templates/mfa/auth.html:88
 msgid "Authentication timed out, was aborted or not allowed"
 msgstr "Authentifikation abgelaufen, abgebrochen oder nicht erlaubt"
 
-#: uffd/mfa/templates/mfa/auth.html:90
+#: uffd/templates/mfa/auth.html:90
 msgid "Device is not registered for your account"
 msgstr "Gerät ist nicht für deinen Account registriert"
 
-#: uffd/mfa/templates/mfa/auth.html:92
+#: uffd/templates/mfa/auth.html:92
 msgid "Authentication was aborted"
 msgstr "Authentifikation abgebrochen"
 
-#: uffd/mfa/templates/mfa/auth.html:94 uffd/mfa/templates/mfa/setup.html:240
-#: uffd/mfa/templates/mfa/setup.html:264
+#: uffd/templates/mfa/auth.html:94 uffd/templates/mfa/setup.html:240
+#: uffd/templates/mfa/setup.html:264
 msgid "U2F and FIDO2 devices are not supported by your browser"
 msgstr "U2F- und FIDO2-Geräte werden vom Webbrowser nicht unterstüzt"
 
-#: uffd/mfa/templates/mfa/auth.html:97 uffd/mfa/templates/mfa/setup.html:243
+#: uffd/templates/mfa/auth.html:97 uffd/templates/mfa/setup.html:243
 msgid "Could not connect to server"
 msgstr "Verbindung zum Server fehlgeschlagen"
 
-#: uffd/mfa/templates/mfa/auth.html:103
+#: uffd/templates/mfa/auth.html:103
 msgid "Authentication failed "
 msgstr "Authentifikation fehlgeschlagen"
 
-#: uffd/mfa/templates/mfa/auth.html:106
+#: uffd/templates/mfa/auth.html:106
 msgid "Retry authenticate with U2F/FIDO2 device"
 msgstr "Authentifikation mit U2F/FIDO2-Gerät nochmal versuchen"
 
-#: uffd/mfa/templates/mfa/disable.html:6
+#: uffd/templates/mfa/disable.html:6
 msgid ""
 "When you proceed, all recovery codes, registered authenticator "
 "applications and devices will be invalidated.\n"
@@ -587,23 +559,21 @@ msgstr ""
 "Wiederherstellungscodes generieren und das Setup der Anwendungen und "
 "Geräte erneut durchführen."
 
-#: uffd/mfa/templates/mfa/disable.html:11 uffd/mfa/templates/mfa/setup.html:32
+#: uffd/templates/mfa/disable.html:11 uffd/templates/mfa/setup.html:32
 msgid "Disable two-factor authentication"
 msgstr "Zwei-Faktor-Authentifizierung (2FA) deaktivieren"
 
-#: uffd/mfa/templates/mfa/setup.html:18
-#: uffd/selfservice/templates/selfservice/self.html:73
+#: uffd/templates/mfa/setup.html:18 uffd/templates/selfservice/self.html:140
 msgid "Two-factor authentication is currently <strong>enabled</strong>."
 msgstr "Die Zwei-Faktor-Authentifizierung ist derzeit <strong>aktiviert</strong>."
 
-#: uffd/mfa/templates/mfa/setup.html:20
-#: uffd/selfservice/templates/selfservice/self.html:75
+#: uffd/templates/mfa/setup.html:20 uffd/templates/selfservice/self.html:142
 msgid "Two-factor authentication is currently <strong>disabled</strong>."
 msgstr ""
 "Die Zwei-Faktor-Authentifizierung ist derzeit "
 "<strong>deaktiviert</strong>."
 
-#: uffd/mfa/templates/mfa/setup.html:23
+#: uffd/templates/mfa/setup.html:23
 msgid ""
 "You need to generate recovery codes and setup at least one authentication"
 " method to enable two-factor authentication."
@@ -612,7 +582,7 @@ msgstr ""
 "Authentifizierungsmethode hinzufügen um Zwei-Faktor-Authentifizierung "
 "nutzen zu können."
 
-#: uffd/mfa/templates/mfa/setup.html:25
+#: uffd/templates/mfa/setup.html:25
 msgid ""
 "You need to setup at least one authentication method to enable two-factor"
 " authentication."
@@ -620,17 +590,16 @@ msgstr ""
 "Du musst mindestens eine Authentifizierungsmethode hinzufügen um Zwei-"
 "Faktor-Authentifizierung nutzen zu können."
 
-#: uffd/mfa/templates/mfa/setup.html:36
+#: uffd/templates/mfa/setup.html:36
 msgid "Reset two-factor configuration"
 msgstr "Zwei-Faktor-Authentifizierung zurücksetzen"
 
-#: uffd/mfa/templates/mfa/setup.html:46
-#: uffd/mfa/templates/mfa/setup_recovery.html:5
-#: uffd/user/templates/user/show.html:92
+#: uffd/templates/mfa/setup.html:46 uffd/templates/mfa/setup_recovery.html:5
+#: uffd/templates/user/show.html:164
 msgid "Recovery Codes"
 msgstr "Wiederherstellungscodes"
 
-#: uffd/mfa/templates/mfa/setup.html:48
+#: uffd/templates/mfa/setup.html:48
 msgid ""
 "Recovery codes allow you to login and setup new two-factor methods when "
 "you lost your registered second factor."
@@ -638,7 +607,7 @@ msgstr ""
 "Wiederherstellungscodes erlauben die Anmeldung und das erneute Hinzufügen"
 " einer Zwei-Faktor-Methode, falls der Zweite Faktor verloren geht."
 
-#: uffd/mfa/templates/mfa/setup.html:52
+#: uffd/templates/mfa/setup.html:52
 msgid ""
 "You need to setup recovery codes before you can setup up authenticator "
 "apps or U2F/FIDO2 devices."
@@ -646,33 +615,33 @@ msgstr ""
 "Du musst Wiederherstellungscodes generieren bevor du einen "
 "Authentifikator-App oder ein U2F/FIDO2 Gerät hinzufen kannst."
 
-#: uffd/mfa/templates/mfa/setup.html:54
+#: uffd/templates/mfa/setup.html:54
 msgid "Each code can only be used once."
 msgstr "Jeder Code kann nur einmal verwendet werden."
 
-#: uffd/mfa/templates/mfa/setup.html:62
+#: uffd/templates/mfa/setup.html:62
 msgid "Generate recovery codes to enable two-factor authentication"
 msgstr ""
 "Generiere Wiederherstellungscodes um die Zwei-Faktor-Authentifizierung zu"
 " aktivieren"
 
-#: uffd/mfa/templates/mfa/setup.html:66
+#: uffd/templates/mfa/setup.html:66
 msgid "Generate new recovery codes"
 msgstr "Generiere neue Wiederherstellungscodes"
 
-#: uffd/mfa/templates/mfa/setup.html:75
+#: uffd/templates/mfa/setup.html:75
 msgid "You have no remaining recovery codes."
 msgstr "Du hast keine Wiederherstellungscodes übrig."
 
-#: uffd/mfa/templates/mfa/setup.html:85 uffd/user/templates/user/show.html:92
+#: uffd/templates/mfa/setup.html:85 uffd/templates/user/show.html:164
 msgid "Authenticator Apps (TOTP)"
 msgstr "Authentifikator-Apps (TOTP)"
 
-#: uffd/mfa/templates/mfa/setup.html:87
+#: uffd/templates/mfa/setup.html:87
 msgid "Use an authenticator application on your mobile device as a second factor."
 msgstr "Nutze eine Authentifikator-App auf deinem Mobilgerät als zweiten Faktor."
 
-#: uffd/mfa/templates/mfa/setup.html:90
+#: uffd/templates/mfa/setup.html:90
 msgid ""
 "The authenticator app generates a 6-digit one-time code each time you "
 "login.\n"
@@ -682,27 +651,27 @@ msgstr ""
 "jeden Login. Passende Apps sind kostenlos verfügbar für die meisten "
 "Mobilgeräte."
 
-#: uffd/mfa/templates/mfa/setup.html:100
+#: uffd/templates/mfa/setup.html:100
 msgid "Setup new app"
 msgstr "Neue App hinzufügen"
 
-#: uffd/mfa/templates/mfa/setup.html:108 uffd/mfa/templates/mfa/setup.html:170
+#: uffd/templates/mfa/setup.html:108 uffd/templates/mfa/setup.html:170
 msgid "Registered On"
 msgstr "Registriert am"
 
-#: uffd/mfa/templates/mfa/setup.html:122
+#: uffd/templates/mfa/setup.html:122
 msgid "No authenticator apps registered yet"
 msgstr "Bisher keine Authentifikator-Apps registriert"
 
-#: uffd/mfa/templates/mfa/setup.html:134 uffd/user/templates/user/show.html:92
+#: uffd/templates/mfa/setup.html:134 uffd/templates/user/show.html:164
 msgid "U2F and FIDO2 Devices"
 msgstr "U2F und FIDO2 Geräte"
 
-#: uffd/mfa/templates/mfa/setup.html:136
+#: uffd/templates/mfa/setup.html:136
 msgid "Use an U2F or FIDO2 compatible hardware security key as a second factor."
 msgstr "Nutze einen U2F oder FIDO2 kompatiblen Key als zweiten Faktor."
 
-#: uffd/mfa/templates/mfa/setup.html:139
+#: uffd/templates/mfa/setup.html:139
 msgid ""
 "U2F and FIDO2 devices are not supported by all browsers and can be "
 "particularly difficult to use on mobile\n"
@@ -715,49 +684,49 @@ msgstr ""
 "wird dringend empfohlen ebenfalls eine Authentifikator-App "
 "hinzuzufügen</strong> um einen Login mit allen Browsern zu ermöglichen."
 
-#: uffd/mfa/templates/mfa/setup.html:147
+#: uffd/templates/mfa/setup.html:147
 msgid "U2F/FIDO2 support not enabled"
 msgstr "U2F/FIDO2 Unterstützung nicht aktiviert"
 
-#: uffd/mfa/templates/mfa/setup.html:151
+#: uffd/templates/mfa/setup.html:151
 msgid "Enable javascript in your browser to use U2F and FIDO2 devices!"
 msgstr ""
 "Aktiviere Javascript in deinem Browser, um U2F und FIDO2 Geräte nutzen zu"
 " können!"
 
-#: uffd/mfa/templates/mfa/setup.html:161
+#: uffd/templates/mfa/setup.html:161
 msgid "Setup new device"
 msgstr "Neues Gerät hinzufügen"
 
-#: uffd/mfa/templates/mfa/setup.html:184
+#: uffd/templates/mfa/setup.html:184
 msgid "No U2F/FIDO2 devices registered yet"
 msgstr "Bisher kein U2F/FIDO2 Gerät registriert"
 
-#: uffd/mfa/templates/mfa/setup.html:207
+#: uffd/templates/mfa/setup.html:207
 msgid "You need to generate recovery codes first"
 msgstr "Du musst erst Wiederherstellungscodes generieren"
 
-#: uffd/mfa/templates/mfa/setup.html:234
+#: uffd/templates/mfa/setup.html:234
 msgid "Registration timed out, was aborted or not allowed"
 msgstr "Registrierung abgelaufen, abgebrochen oder nicht erlaubt"
 
-#: uffd/mfa/templates/mfa/setup.html:236
+#: uffd/templates/mfa/setup.html:236
 msgid "Device already registered"
 msgstr "Gerät bereits registriert"
 
-#: uffd/mfa/templates/mfa/setup.html:238
+#: uffd/templates/mfa/setup.html:238
 msgid "Registration was aborted"
 msgstr "Registrierung abgebrochen"
 
-#: uffd/mfa/templates/mfa/setup.html:249
+#: uffd/templates/mfa/setup.html:249
 msgid "Registration failed"
 msgstr "Registrierung fehlgeschlagen"
 
-#: uffd/mfa/templates/mfa/setup.html:252
+#: uffd/templates/mfa/setup.html:252
 msgid "Retry registration"
 msgstr "Registrierung nochmal versuchen"
 
-#: uffd/mfa/templates/mfa/setup_recovery.html:8
+#: uffd/templates/mfa/setup_recovery.html:8
 msgid ""
 "Recovery codes allow you to login when you lose access to your "
 "authenticator app or U2F/FIDO device. Each code can\n"
@@ -767,7 +736,7 @@ msgstr ""
 "Authentifikator-App oder das U2F/FIDO2 Gerät verloren geht. Jeder Code "
 "kann nur einmal verwendet werden."
 
-#: uffd/mfa/templates/mfa/setup_recovery.html:21
+#: uffd/templates/mfa/setup_recovery.html:21
 msgid ""
 "These are your new recovery codes. Make sure to store them in a safe "
 "place or you risk losing access to your\n"
@@ -777,21 +746,21 @@ msgstr ""
 "Ort, sonst könntest du den Zugriff auf dein Konto verlieren. Alle "
 "vorherigen Wiederherstellungscodes sind nun ungültig."
 
-#: uffd/mfa/templates/mfa/setup_recovery.html:26
-#: uffd/session/templates/session/deviceauth.html:36
-#: uffd/session/templates/session/devicelogin.html:25
+#: uffd/templates/mfa/setup_recovery.html:26
+#: uffd/templates/session/deviceauth.html:36
+#: uffd/templates/session/devicelogin.html:25
 msgid "Continue"
 msgstr "Weiter"
 
-#: uffd/mfa/templates/mfa/setup_recovery.html:28
+#: uffd/templates/mfa/setup_recovery.html:28
 msgid "Download codes"
 msgstr "Codes herunterladen"
 
-#: uffd/mfa/templates/mfa/setup_recovery.html:30
+#: uffd/templates/mfa/setup_recovery.html:30
 msgid "Print codes"
 msgstr "Codes ausdrucken"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:6
+#: uffd/templates/mfa/setup_totp.html:6
 msgid ""
 "Install an authenticator application on your mobile device like FreeOTP "
 "or Google Authenticator and scan this QR\n"
@@ -801,7 +770,7 @@ msgstr ""
 "oder Google Authenticator and scanne diesen QR Code. Auf Geräten von "
 "Apple kann die App \"Authenticator\" verwendet werden."
 
-#: uffd/mfa/templates/mfa/setup_totp.html:18
+#: uffd/templates/mfa/setup_totp.html:18
 msgid ""
 "If you are on your mobile device and cannot scan the code, you can click "
 "on it to open it with your\n"
@@ -814,86 +783,51 @@ msgstr ""
 " öffnen. Wenn das nicht funktioniert, gib die folgenden Angaben manuell "
 "in die Authentifikator-App ein:"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:23
+#: uffd/templates/mfa/setup_totp.html:23
 msgid "Issuer"
 msgstr "Herausgeber"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:24
+#: uffd/templates/mfa/setup_totp.html:24
 msgid "Account"
 msgstr "Konto"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:25
+#: uffd/templates/mfa/setup_totp.html:25
 msgid "Secret"
 msgstr "Geheimnis"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:26
+#: uffd/templates/mfa/setup_totp.html:26
 msgid "Type"
 msgstr "Typ"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:27
+#: uffd/templates/mfa/setup_totp.html:27
 msgid "Digits"
 msgstr "Zeichen"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:28
+#: uffd/templates/mfa/setup_totp.html:28
 msgid "Hash algorithm"
 msgstr "Hash-Algorithmus"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:29
+#: uffd/templates/mfa/setup_totp.html:29
 msgid "Interval/period"
 msgstr "Intervall/Dauer"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:29
+#: uffd/templates/mfa/setup_totp.html:29
 msgid "seconds"
 msgstr "Sekunden"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:37
+#: uffd/templates/mfa/setup_totp.html:37
 msgid "Code"
 msgstr "Code"
 
-#: uffd/mfa/templates/mfa/setup_totp.html:38
+#: uffd/templates/mfa/setup_totp.html:38
 msgid "Verify and complete setup"
 msgstr "Verifiziere und beende das Setup"
 
-#: uffd/oauth2/views.py:169 uffd/selfservice/views.py:71
-#: uffd/session/views.py:74
-#, python-format
-msgid ""
-"We received too many requests from your ip address/network! Please wait "
-"at least %(delay)s."
-msgstr ""
-"Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
-"Netzwerk empfangen! Bitte warte mindestens %(delay)s."
-
-#: uffd/oauth2/views.py:177
-msgid "Device login is currently not available. Try again later!"
-msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
-
-#: uffd/oauth2/views.py:190
-msgid "Device login failed"
-msgstr "Gerätelogin fehlgeschlagen"
-
-#: uffd/oauth2/views.py:196
-msgid "You need to login to access this service"
-msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können"
-
-#: uffd/oauth2/views.py:203
-#, python-format
-msgid ""
-"You don't have the permission to access the service "
-"<b>%(service_name)s</b>."
-msgstr ""
-"Du bist nicht berechtigt, auf den Dienst <b>%(service_name)s</b> "
-"zuzugreifen."
-
-#: uffd/oauth2/templates/oauth2/logout.html:5 uffd/templates/base.html:99
-msgid "Logout"
-msgstr "Abmelden"
-
-#: uffd/oauth2/templates/oauth2/logout.html:10
+#: uffd/templates/oauth2/logout.html:10
 msgid "Javascript is required for automatic logout"
 msgstr "Für das automatische Abmelden muss Javascript aktiviert sein"
 
-#: uffd/oauth2/templates/oauth2/logout.html:12
+#: uffd/templates/oauth2/logout.html:12
 msgid ""
 "While you successfully logged out of the Single-Sign-On service, you may "
 "still be logged in on these services:"
@@ -901,7 +835,7 @@ msgstr ""
 "Während du nun aus dem Single-Sign-On abgemeldet bist, bist du eventuell "
 "weiterhin in folgenden Diensten angemeldet:"
 
-#: uffd/oauth2/templates/oauth2/logout.html:25
+#: uffd/templates/oauth2/logout.html:25
 msgid ""
 "Please wait until you have been automatically logged out of all services "
 "or make sure of this yourself."
@@ -909,39 +843,29 @@ msgstr ""
 "Bitte warte, bis das automatische Abmelden bei allen Diensten "
 "abgeschlossen ist oder melde dich überall manuell ab."
 
-#: uffd/oauth2/templates/oauth2/logout.html:29
+#: uffd/templates/oauth2/logout.html:29
 msgid "Logging you out on all services ..."
 msgstr "Abmeldung bei allen Diensten ..."
 
-#: uffd/oauth2/templates/oauth2/logout.html:33
+#: uffd/templates/oauth2/logout.html:33
 msgid "Skip this and continue"
 msgstr "Automatisches Abmelden überspringen"
 
-#: uffd/oauth2/templates/oauth2/logout.html:72
+#: uffd/templates/oauth2/logout.html:72
 msgid "Done, redirecting ..."
 msgstr "Abgeschlossen, leite weiter ..."
 
-#: uffd/oauth2/templates/oauth2/logout.html:76
+#: uffd/templates/oauth2/logout.html:76
 msgid "Log out failed on some services. Retry?"
 msgstr ""
 "Automatisches Abmelden bei einigen Diensten fehlgeschlagen. Nochmal "
 "versuchen?"
 
-#: uffd/role/views.py:48 uffd/selfservice/templates/selfservice/self.html:86
-#: uffd/user/templates/user/list.html:20 uffd/user/templates/user/show.html:20
-#: uffd/user/templates/user/show.html:101
-msgid "Roles"
-msgstr "Rollen"
-
-#: uffd/role/views.py:95
-msgid "Locked roles cannot be deleted"
-msgstr "Gesperrte Rollen können nicht gelöscht werden"
-
-#: uffd/role/templates/role/list.html:23
+#: uffd/templates/role/list.html:23
 msgid "<empty name>"
 msgstr "<leerer Name>"
 
-#: uffd/role/templates/role/show.html:6
+#: uffd/templates/role/show.html:6
 msgid ""
 "Name, moderator group, included roles and groups of this role are managed"
 " externally."
@@ -949,7 +873,7 @@ msgstr ""
 "Name, Moderationsgruppe, enthaltene Rollen und Gruppen dieser Rolle "
 "werden extern verwaltet."
 
-#: uffd/role/templates/role/show.html:17
+#: uffd/templates/role/show.html:17
 msgid ""
 "All non-service users will be removed as members from this role and get "
 "its permissions implicitly. Are you sure?"
@@ -957,218 +881,106 @@ msgstr ""
 "Alle Nicht-Service-Accounts verlieren diese Rolle und erhalten dessen "
 "Berechtigungen implizit."
 
-#: uffd/role/templates/role/show.html:17 uffd/role/templates/role/show.html:23
+#: uffd/templates/role/show.html:17 uffd/templates/role/show.html:23
 msgid "Set as default"
 msgstr "Als Default setzen"
 
-#: uffd/role/templates/role/show.html:19 uffd/role/templates/role/show.html:21
-#: uffd/selfservice/templates/selfservice/self.html:112
-#: uffd/service/templates/service/api.html:11
-#: uffd/service/templates/service/oauth2.html:11
-#: uffd/service/templates/service/show.html:12
-#: uffd/user/templates/group/show.html:11 uffd/user/templates/user/show.html:10
-#: uffd/user/templates/user/show.html:94
-msgid "Are you sure?"
-msgstr "Wirklich fortfahren?"
-
-#: uffd/role/templates/role/show.html:19
+#: uffd/templates/role/show.html:19
 msgid "Unset as default"
 msgstr "Nicht mehr als Default setzen"
 
-#: uffd/role/templates/role/show.html:29
+#: uffd/templates/role/show.html:29
 msgid "Settings"
 msgstr "Einstellungen"
 
-#: uffd/role/templates/role/show.html:32
+#: uffd/templates/role/show.html:32
 msgid "Included roles"
 msgstr "Enthaltene Rollen"
 
-#: uffd/role/templates/role/show.html:35 uffd/role/templates/role/show.html:122
+#: uffd/templates/role/show.html:35 uffd/templates/role/show.html:122
 msgid "Included groups"
 msgstr "Enthaltene Gruppen"
 
-#: uffd/role/templates/role/show.html:42
+#: uffd/templates/role/show.html:42
 msgid "Role Name"
 msgstr "Rollenname"
 
-#: uffd/role/templates/role/show.html:54
+#: uffd/templates/role/show.html:54
 msgid "Moderator Group"
 msgstr "Moderationsgruppe"
 
-#: uffd/role/templates/role/show.html:56
+#: uffd/templates/role/show.html:56
 msgid "No Moderator Group"
 msgstr "Keine Moderationsgruppe"
 
-#: uffd/role/templates/role/show.html:63
+#: uffd/templates/role/show.html:63
 msgid "Moderators"
 msgstr "Accounts mit Moderationsrechten"
 
-#: uffd/role/templates/role/show.html:71
-#: uffd/rolemod/templates/rolemod/show.html:16
-#: uffd/user/templates/group/show.html:40
-msgid "Members"
-msgstr "Mitglieder"
-
-#: uffd/role/templates/role/show.html:81
+#: uffd/templates/role/show.html:81
 msgid "Roles to include groups from recursively"
 msgstr "Rollen, deren Gruppen rekursiv enthalten sein sollen"
 
-#: uffd/role/templates/role/show.html:86 uffd/role/templates/role/show.html:127
+#: uffd/templates/role/show.html:86 uffd/templates/role/show.html:127
 msgid "name"
 msgstr "Name"
 
-#: uffd/role/templates/role/show.html:87 uffd/role/templates/role/show.html:128
+#: uffd/templates/role/show.html:87 uffd/templates/role/show.html:128
 msgid "description"
 msgstr "Beschreibung"
 
-#: uffd/role/templates/role/show.html:88
+#: uffd/templates/role/show.html:88
 msgid "currently includes groups"
 msgstr "derzeit enthaltene Gruppen"
 
-#: uffd/role/templates/role/show.html:129
+#: uffd/templates/role/show.html:129
 msgid "2FA required"
 msgstr "2FA erforderlich"
 
-#: uffd/rolemod/views.py:23
-msgid "Moderation"
-msgstr "Moderation"
-
-#: uffd/rolemod/views.py:43
-msgid "Description too long"
-msgstr "Beschreibung zu lang"
-
-#: uffd/rolemod/views.py:60
-msgid "Member removed"
-msgstr "Mitglied entfernt"
-
-#: uffd/rolemod/templates/rolemod/show.html:7
+#: uffd/templates/rolemod/show.html:7
 msgid "Invite Members"
 msgstr "Mitglieder einladen"
 
-#: uffd/rolemod/templates/rolemod/show.html:13
+#: uffd/templates/rolemod/show.html:13
 msgid "Overview"
 msgstr "Übersicht"
 
-#: uffd/rolemod/templates/rolemod/show.html:22
+#: uffd/templates/rolemod/show.html:22
 msgid "Role name"
 msgstr "Rollenname"
 
-#: uffd/rolemod/templates/rolemod/show.html:30
+#: uffd/templates/rolemod/show.html:30
 msgid "Moderators:"
 msgstr "Accounts mit Moderationsrechten:"
 
-#: uffd/rolemod/templates/rolemod/show.html:40
+#: uffd/templates/rolemod/show.html:40
 msgid "Role members:"
 msgstr "Mitglieder:"
 
-#: uffd/rolemod/templates/rolemod/show.html:53
+#: uffd/templates/rolemod/show.html:53
 msgid "Remove"
 msgstr "Entfernen"
 
-#: uffd/selfservice/views.py:24
-msgid "Selfservice"
-msgstr "Selfservice"
-
-#: uffd/selfservice/views.py:35
-msgid "Display name changed."
-msgstr "Anzeigename geändert."
+#: uffd/templates/selfservice/forgot_password.html:6
+msgid "Forgot password"
+msgstr "Passwort vergessen"
 
-#: uffd/selfservice/views.py:37
-msgid "Display name is not valid."
-msgstr "Anzeigename ist nicht valide."
+#: uffd/templates/selfservice/forgot_password.html:9
+#: uffd/templates/selfservice/self.html:21 uffd/templates/session/login.html:12
+#: uffd/templates/signup/start.html:9 uffd/templates/user/list.html:18
+#: uffd/templates/user/show.html:62
+msgid "Login Name"
+msgstr "Anmeldename"
 
-#: uffd/selfservice/views.py:40
-msgid "We sent you an email, please verify your mail address."
-msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse."
+#: uffd/templates/selfservice/forgot_password.html:13
+msgid "Mail Address"
+msgstr "E-Mail-Adresse"
 
-#: uffd/selfservice/views.py:52
-msgid "Password changed"
-msgstr "Passwort geändert"
+#: uffd/templates/selfservice/forgot_password.html:17
+msgid "Send password reset mail"
+msgstr "Passwort-Zurücksetzen-Mail versenden"
 
-#: uffd/selfservice/views.py:54 uffd/signup/models.py:85
-msgid "Invalid password"
-msgstr "Passwort ungültig"
-
-#: uffd/selfservice/views.py:69
-#, python-format
-msgid ""
-"We received too many password reset requests for this user! Please wait "
-"at least %(delay)s."
-msgstr ""
-"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account! "
-"Bitte warte mindestens %(delay)s."
-
-#: uffd/selfservice/views.py:75
-msgid ""
-"We sent a mail to this user's mail address if you entered the correct "
-"mail and login name combination"
-msgstr ""
-"Falls E-Mail-Adresse und Anmeldename richtig waren, wurde eine E-Mail an "
-"die Adresse gesendet."
-
-#: uffd/selfservice/views.py:86 uffd/selfservice/views.py:112
-msgid "Link invalid or expired"
-msgstr "Link ist ungültig oder abgelaufen"
-
-#: uffd/selfservice/views.py:91
-msgid "You need to set a password, please try again."
-msgstr "Password fehlt, bitte versuche es erneut."
-
-#: uffd/selfservice/views.py:94
-msgid "Passwords do not match, please try again."
-msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut"
-
-#: uffd/selfservice/views.py:99
-msgid "Password ist not valid, please try again."
-msgstr "Ungültiges Passwort, bitte versuche es erneut"
-
-#: uffd/selfservice/views.py:103
-msgid "New password set"
-msgstr "Passwort geändert"
-
-#: uffd/selfservice/views.py:115
-msgid ""
-"This link was generated for another user. Login as the correct user to "
-"continue."
-msgstr ""
-"Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem "
-"richtigen Account an um Fortzufahren."
-
-#: uffd/selfservice/views.py:119
-msgid "New mail set"
-msgstr "E-Mail-Adresse geändert"
-
-#: uffd/selfservice/views.py:130
-#, python-format
-msgid "You left role %(role_name)s"
-msgstr "Rolle %(role_name)s verlassen"
-
-#: uffd/selfservice/views.py:140 uffd/selfservice/views.py:156
-#, python-format
-msgid "Mail to \"%(mail_address)s\" could not be sent!"
-msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!"
-
-#: uffd/selfservice/templates/selfservice/forgot_password.html:6
-msgid "Forgot password"
-msgstr "Passwort vergessen"
-
-#: uffd/selfservice/templates/selfservice/forgot_password.html:9
-#: uffd/selfservice/templates/selfservice/self.html:21
-#: uffd/session/templates/session/login.html:12
-#: uffd/signup/templates/signup/start.html:9
-#: uffd/user/templates/user/list.html:18 uffd/user/templates/user/show.html:47
-msgid "Login Name"
-msgstr "Anmeldename"
-
-#: uffd/selfservice/templates/selfservice/forgot_password.html:13
-msgid "Mail Address"
-msgstr "E-Mail-Adresse"
-
-#: uffd/selfservice/templates/selfservice/forgot_password.html:17
-msgid "Send password reset mail"
-msgstr "Passwort-Zurücksetzen-Mail versenden"
-
-#: uffd/selfservice/templates/selfservice/self.html:7
+#: uffd/templates/selfservice/self.html:7
 msgid ""
 "Some permissions require you to setup two-factor authentication.\n"
 "\tThese permissions are not in effect until you do that."
@@ -1177,12 +989,11 @@ msgstr ""
 "Authentifizierung.\n"
 "\tDiese Berechtigungen werden erst aktiv, wenn du dies getan hast."
 
-#: uffd/selfservice/templates/selfservice/self.html:14
-#: uffd/user/templates/user/show.html:17
+#: uffd/templates/selfservice/self.html:14 uffd/templates/user/show.html:32
 msgid "Profile"
 msgstr "Profil"
 
-#: uffd/selfservice/templates/selfservice/self.html:15
+#: uffd/templates/selfservice/self.html:15
 msgid ""
 "Your profile information is used by all services that are integrated into"
 " the Single-Sign-On. Your e-mail address is also used for password "
@@ -1192,39 +1003,99 @@ msgstr ""
 "Single-Sign-On angeschlossen sind. Die E-Mail-Adresse wird außerdem für "
 "die „Passwort vergessen“ genutzt."
 
-#: uffd/selfservice/templates/selfservice/self.html:16
+#: uffd/templates/selfservice/self.html:16
 msgid "Changes may take several minutes to be visible in all services."
 msgstr "Änderungen sind erst nach einigen Minuten in allen Diensten sichtbar."
 
-#: uffd/selfservice/templates/selfservice/self.html:25
-#: uffd/signup/templates/signup/start.html:22
-#: uffd/user/templates/user/list.html:19 uffd/user/templates/user/show.html:62
+#: uffd/templates/selfservice/self.html:25 uffd/templates/signup/start.html:22
+#: uffd/templates/user/list.html:19 uffd/templates/user/show.html:77
 msgid "Display Name"
 msgstr "Anzeigename"
 
-#: uffd/selfservice/templates/selfservice/self.html:29
-#: uffd/signup/templates/signup/start.html:29
-msgid "E-Mail Address"
-msgstr "E-Mail-Adresse"
+#: uffd/templates/selfservice/self.html:28
+msgid "Update Profile"
+msgstr "Änderungen speichern"
+
+#: uffd/templates/selfservice/self.html:37 uffd/templates/user/show.html:94
+msgid "E-Mail Addresses"
+msgstr "E-Mail-Adressen"
 
-#: uffd/selfservice/templates/selfservice/self.html:32
-msgid "We will send you a confirmation mail to this address if you change it"
+#: uffd/templates/selfservice/self.html:38
+msgid ""
+"Add and delete addresses associated with your account. You will need to "
+"verify new addresses by opening a link set to them."
 msgstr ""
-"Wir werden dir eine Bestätigungsmail zum Setzen der neuen E-Mail-Adresse "
-"senden."
+"Füge neue Adressen zu deinem Account hinzu oder löschen vorhandene. Neue "
+"Adressen müssen verifiziert werden, bevor sie verwendet werden können. "
+"Dazu wird ein Bestätigungslink an die Adresse geschickt."
 
-#: uffd/selfservice/templates/selfservice/self.html:35
-msgid "Update Profile"
-msgstr "Änderungen speichern"
+#: uffd/templates/selfservice/self.html:43
+msgid "Email"
+msgstr "E-Mail"
+
+#: uffd/templates/selfservice/self.html:44
+msgid "New E-Mail Address"
+msgstr "Neue E-Mail-Adresse"
+
+#: uffd/templates/selfservice/self.html:45
+msgid "Add address"
+msgstr "Adresse hinzufügen"
+
+#: uffd/templates/selfservice/self.html:55 uffd/templates/user/show.html:110
+msgid "primary"
+msgstr "primär"
+
+#: uffd/templates/selfservice/self.html:57
+msgid "unverified"
+msgstr "unverifiziert"
+
+#: uffd/templates/selfservice/self.html:62 uffd/views/selfservice.py:171
+msgid "Cannot delete primary e-mail address"
+msgstr "Primäre E-Mail-Adresse kann nicht gelöscht werden"
+
+#: uffd/templates/selfservice/self.html:65
+msgid "Retry verification"
+msgstr "Verifikation neustarten"
+
+#: uffd/templates/selfservice/self.html:79
+msgid "E-Mail Preferences"
+msgstr "E-Mail-Einstellungen"
+
+#: uffd/templates/selfservice/self.html:80
+msgid ""
+"Choose which of your verified address to use as your primary or recovery "
+"address."
+msgstr ""
+"Wähle aus deinen verifizierten Adressen die primäre Adresse und die "
+"Wiederherstellungsadresse."
+
+#: uffd/templates/selfservice/self.html:85
+msgid "Primary Address"
+msgstr "Primäre Adresse"
+
+#: uffd/templates/selfservice/self.html:93
+msgid "Recovery Address"
+msgstr "Wiederherstellungsadresse"
+
+#: uffd/templates/selfservice/self.html:95 uffd/templates/user/show.html:139
+msgid "Use primary address"
+msgstr "Verwende primäre Adresse"
 
-#: uffd/selfservice/templates/selfservice/self.html:44
-#: uffd/session/templates/session/login.html:16
-#: uffd/signup/templates/signup/start.html:36
-#: uffd/user/templates/user/show.html:76
+#: uffd/templates/selfservice/self.html:100
+msgid "Password reset e-mails will be sent to this address"
+msgstr "E-Mails zur zurücksetzen des Passworts werden an diese Adresse gesendet"
+
+#: uffd/templates/selfservice/self.html:102
+msgid "Update E-Mail Preferences"
+msgstr "E-Mail-Einstellungen speichern"
+
+#: uffd/templates/selfservice/self.html:111
+#: uffd/templates/session/login.html:16 uffd/templates/signup/start.html:36
+#: uffd/templates/user/show.html:148
 msgid "Password"
 msgstr "Passwort"
 
-#: uffd/selfservice/templates/selfservice/self.html:45
+#: uffd/templates/selfservice/self.html:112
 msgid ""
 "Your login password for the Single-Sign-On. Only enter it on the Single-"
 "Sign-On login page! No other legit websites will ask you for this "
@@ -1235,22 +1106,22 @@ msgstr ""
 " Webseite wird dich nach diesem Passwort fragen. Es wird auch niemals für"
 " Support-Anfragen benötigt."
 
-#: uffd/selfservice/templates/selfservice/self.html:50
-#: uffd/selfservice/templates/selfservice/set_password.html:9
+#: uffd/templates/selfservice/self.html:117
+#: uffd/templates/selfservice/set_password.html:9
 msgid "New Password"
 msgstr "Neues Passwort"
 
-#: uffd/selfservice/templates/selfservice/self.html:56
-#: uffd/selfservice/templates/selfservice/set_password.html:16
-#: uffd/signup/templates/signup/start.html:43
+#: uffd/templates/selfservice/self.html:123
+#: uffd/templates/selfservice/set_password.html:16
+#: uffd/templates/signup/start.html:43
 msgid "Repeat Password"
 msgstr "Passwort wiederholen"
 
-#: uffd/selfservice/templates/selfservice/self.html:58
+#: uffd/templates/selfservice/self.html:125
 msgid "Change Password"
 msgstr "Passwort ändern"
 
-#: uffd/selfservice/templates/selfservice/self.html:68
+#: uffd/templates/selfservice/self.html:135
 msgid ""
 "Setting up Two-Factor Authentication (2FA) adds an additional step to the"
 " Single-Sign-On login and increases the security of your account "
@@ -1260,11 +1131,17 @@ msgstr ""
 "Anmeldung im Single-Sign-On hinzu und verbessert damit die Sicherheit "
 "deines Accounts erheblich."
 
-#: uffd/selfservice/templates/selfservice/self.html:78
+#: uffd/templates/selfservice/self.html:145
 msgid "Manage two-factor authentication"
 msgstr "Zwei-Faktor-Authentifizierung (2FA) verwalten"
 
-#: uffd/selfservice/templates/selfservice/self.html:87
+#: uffd/templates/selfservice/self.html:153 uffd/templates/user/list.html:20
+#: uffd/templates/user/show.html:35 uffd/templates/user/show.html:173
+#: uffd/views/role.py:21
+msgid "Roles"
+msgstr "Rollen"
+
+#: uffd/templates/selfservice/self.html:154
 msgid ""
 "Aside from a set of base permissions, your roles determine the "
 "permissions of your account."
@@ -1272,7 +1149,7 @@ msgstr ""
 "Deine Berechtigungen werden, von einigen Basis-Berechtigungen abgesehen, "
 "von deinen Rollen bestimmt"
 
-#: uffd/selfservice/templates/selfservice/self.html:89
+#: uffd/templates/selfservice/self.html:156
 #, python-format
 msgid ""
 "See <a href=\"%(services_url)s\">Services</a> for an overview of your "
@@ -1281,13 +1158,13 @@ msgstr ""
 "Auf <a href=\"%(services_url)s\">Dienste</a> erhälst du einen Überblick "
 "über deine aktuellen Berechtigungen."
 
-#: uffd/selfservice/templates/selfservice/self.html:93
+#: uffd/templates/selfservice/self.html:160
 msgid "Administrators and role moderators can invite you to new roles."
 msgstr ""
 "Accounts mit Adminrechten oder Rollen-Moderationsrechten können dich zu "
 "Rollen einladen."
 
-#: uffd/selfservice/templates/selfservice/self.html:107
+#: uffd/templates/selfservice/self.html:175
 msgid ""
 "Some permissions in this role require you to setup two-factor "
 "authentication"
@@ -1295,83 +1172,81 @@ msgstr ""
 "Einige Berechtigungen dieser Rolle erfordern das Einrichten von Zwei-"
 "Faktor-Authentifikation"
 
-#: uffd/selfservice/templates/selfservice/self.html:113
+#: uffd/templates/selfservice/self.html:181
 msgid "Leave"
 msgstr "Verlassen"
 
-#: uffd/selfservice/templates/selfservice/self.html:120
+#: uffd/templates/selfservice/self.html:188
 msgid "You currently don't have any roles"
 msgstr "Du hast derzeit keine Rollen"
 
-#: uffd/selfservice/templates/selfservice/set_password.html:6
+#: uffd/templates/selfservice/set_password.html:6
 msgid "Reset password"
 msgstr "Passwort zurücksetzen"
 
-#: uffd/selfservice/templates/selfservice/set_password.html:20
+#: uffd/templates/selfservice/set_password.html:20
 msgid "Set password"
 msgstr "Passwort setzen"
 
-#: uffd/service/views.py:34
-msgid "Services"
-msgstr "Dienste"
-
-#: uffd/service/templates/service/api.html:20
+#: uffd/templates/service/api.html:20
 msgid "Authentication Username"
 msgstr "Authentifikations-Name"
 
-#: uffd/service/templates/service/api.html:25
+#: uffd/templates/service/api.html:25
 msgid "Authentication Password"
 msgstr "Authentifikations-Passwort"
 
-#: uffd/service/templates/service/api.html:37
+#: uffd/templates/service/api.html:37
 msgid "Access user and group data"
 msgstr "Zugriff auf Account- und Gruppen-Daten"
 
-#: uffd/service/templates/service/api.html:41
+#: uffd/templates/service/api.html:41
 msgid "Verify user passwords"
 msgstr "Passwörter von Nutzeraccounts verifizieren"
 
-#: uffd/service/templates/service/api.html:45
+#: uffd/templates/service/api.html:45
 msgid "Access mail aliases"
 msgstr "Zugriff auf Mail-Weiterleitungen"
 
-#: uffd/service/templates/service/api.html:49
+#: uffd/templates/service/api.html:49
 msgid "Resolve remailer addresses"
 msgstr "Auflösen von Remailer-Adressen"
 
-#: uffd/service/templates/service/api.html:51
-#: uffd/service/templates/service/show.html:38
+#: uffd/templates/service/api.html:51 uffd/templates/service/show.html:38
 msgid "This option has no effect: Remailer config options are unset"
 msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert"
 
-#: uffd/service/templates/service/oauth2.html:20
-#: uffd/service/templates/service/show.html:56
+#: uffd/templates/service/api.html:56
+msgid "Access uffd metrics"
+msgstr "Zugriff auf uffd-Metriken"
+
+#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:56
 msgid "Client ID"
 msgstr "Client-ID"
 
-#: uffd/service/templates/service/oauth2.html:25
+#: uffd/templates/service/oauth2.html:25
 msgid "Client Secret"
 msgstr "Client-Secret"
 
-#: uffd/service/templates/service/oauth2.html:34
+#: uffd/templates/service/oauth2.html:34
 msgid "Redirect URIs"
 msgstr "Redirect-URIs"
 
-#: uffd/service/templates/service/oauth2.html:37
+#: uffd/templates/service/oauth2.html:37
 msgid "One URI per line"
 msgstr "Eine URI pro Zeile"
 
-#: uffd/service/templates/service/oauth2.html:42
+#: uffd/templates/service/oauth2.html:42
 msgid "Logout URIs"
 msgstr "Abmelde-URIs"
 
-#: uffd/service/templates/service/oauth2.html:49
+#: uffd/templates/service/oauth2.html:49
 msgid "One URI per line, prefixed with space-separated method (GET/POST)"
 msgstr ""
 "Eine URI pro Zeile, vorangestellt die mit Leerzeichen getrennte HTTP-"
 "Methode (GET/POST)"
 
-#: uffd/service/templates/service/overview.html:11
+#: uffd/templates/service/overview.html:11
 msgid ""
 "Some services may not be publicly listed! Log in to see all services you "
 "have access to."
@@ -1379,106 +1254,62 @@ msgstr ""
 "Einige Dienste sind eventuell nicht öffentlich aufgelistet! Melde dich an"
 " um alle Dienste zu sehen, auf die du Zugriff hast."
 
-#: uffd/service/templates/service/overview.html:15
-#: uffd/session/templates/session/login.html:6
-#: uffd/session/templates/session/login.html:20 uffd/templates/base.html:106
-msgid "Login"
-msgstr "Anmelden"
-
-#: uffd/service/templates/service/overview.html:36
+#: uffd/templates/service/overview.html:36
 #, python-format
 msgid "Logo for %(service_title)s"
 msgstr "Logo für %(service_title)s"
 
-#: uffd/service/templates/service/overview.html:55
+#: uffd/templates/service/overview.html:55
 msgid "No access"
 msgstr "Kein Zugriff"
 
-#: uffd/service/templates/service/overview.html:75
+#: uffd/templates/service/overview.html:75
 msgid "Manage OAuth2 and API clients"
 msgstr "OAuth2- und API-Clients verwalten"
 
-#: uffd/service/templates/service/overview.html:95
-#: uffd/user/templates/user/list.html:58 uffd/user/templates/user/list.html:79
+#: uffd/templates/service/overview.html:95 uffd/templates/user/list.html:58
+#: uffd/templates/user/list.html:79
 msgid "Close"
 msgstr "Schließen"
 
-#: uffd/service/templates/service/show.html:24
+#: uffd/templates/service/show.html:24
 msgid "Access Restriction"
 msgstr "Zugriffsbeschränkungen"
 
-#: uffd/service/templates/service/show.html:26
+#: uffd/templates/service/show.html:26
 msgid "No user has access"
 msgstr "Kein Account hat Zugriff"
 
-#: uffd/service/templates/service/show.html:27
+#: uffd/templates/service/show.html:27
 msgid "All users have access (legacy)"
 msgstr "Alle Account haben Zugriff (veraltet)"
 
-#: uffd/service/templates/service/show.html:29
+#: uffd/templates/service/show.html:29
 #, python-format
 msgid "Members of group \"%(group_name)s\" have access"
 msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff"
 
-#: uffd/service/templates/service/show.html:36
+#: uffd/templates/service/show.html:36
 msgid "Hide mail addresses with remailer"
 msgstr "Verstecke Mailadressen mit dem Remailer"
 
-#: uffd/session/views.py:72
-#, python-format
-msgid ""
-"We received too many invalid login attempts for this user! Please wait at"
-" least %(delay)s."
-msgstr ""
-"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account "
-"erhalten! Bitte warte mindestens %(delay)s."
-
-#: uffd/session/views.py:80
-msgid "Login name or password is wrong"
-msgstr "Der Anmeldename oder das Passwort ist falsch"
-
-#: uffd/session/views.py:86
-msgid "You do not have access to this service"
-msgstr "Du hast keinen Zugriff auf diesen Service"
-
-#: uffd/session/views.py:98 uffd/session/views.py:109
-msgid "You need to login first"
-msgstr "Du musst dich erst anmelden"
-
-#: uffd/session/views.py:130 uffd/session/views.py:140
-msgid "Initiation code is no longer valid"
-msgstr "Startcode ist nicht mehr gültig"
-
-#: uffd/session/views.py:144
-msgid "Invalid confirmation code"
-msgstr "Ungültiger Bestätigungscode"
-
-#: uffd/session/views.py:156 uffd/session/views.py:167
-msgid "Invalid initiation code"
-msgstr "Ungültiger Startcode"
-
-#: uffd/session/templates/session/deviceauth.html:12
-#: uffd/templates/base.html:92
-msgid "Authorize Device Login"
-msgstr "Gerätelogin erlauben"
-
-#: uffd/session/templates/session/deviceauth.html:15
+#: uffd/templates/session/deviceauth.html:15
 msgid "Log into a service on another device without entering your password."
 msgstr ""
 "Melde dich an einem Dienst auf einem anderen Gerät an ohne dein Password "
 "eingeben zu müssen."
 
-#: uffd/session/templates/session/deviceauth.html:18
-#: uffd/session/templates/session/devicelogin.html:13
+#: uffd/templates/session/deviceauth.html:18
+#: uffd/templates/session/devicelogin.html:13
 msgid "Initiation Code"
 msgstr "Startcode"
 
-#: uffd/session/templates/session/deviceauth.html:27
-#: uffd/session/templates/session/devicelogin.html:18
+#: uffd/templates/session/deviceauth.html:27
+#: uffd/templates/session/devicelogin.html:18
 msgid "Confirmation Code"
 msgstr "Bestätigungscode"
 
-#: uffd/session/templates/session/deviceauth.html:33
+#: uffd/templates/session/deviceauth.html:33
 msgid ""
 "Start logging into a service on the other device and chose \"Device "
 "Login\" on the login page. Enter the displayed initiation code in the box"
@@ -1488,16 +1319,16 @@ msgstr ""
 "\"Gerätelogin\" auf der Anmeldeseite aus. Gib den angezeigten Startcode "
 "oben ein."
 
-#: uffd/session/templates/session/deviceauth.html:43
+#: uffd/templates/session/deviceauth.html:43
 #, python-format
 msgid "Authorize the login for service <b>%(service_name)s</b>?"
 msgstr "Anmeldung an Dienst <b>%(service_name)s</b> erlauben?"
 
-#: uffd/session/templates/session/deviceauth.html:46
+#: uffd/templates/session/deviceauth.html:46
 msgid "Authorize Login"
 msgstr "Anmeldung erlauben"
 
-#: uffd/session/templates/session/deviceauth.html:53
+#: uffd/templates/session/deviceauth.html:53
 msgid ""
 "Enter the confirmation code on the other device and complete the login. "
 "Click <em>Finish</em> afterwards."
@@ -1505,16 +1336,11 @@ msgstr ""
 "Gib den Bestätigungscode auf dem anderen Gerät ein und schließe die "
 "Anmeldung ab. Clicke danach auf <em>Abschließen</em>."
 
-#: uffd/session/templates/session/deviceauth.html:56
+#: uffd/templates/session/deviceauth.html:56
 msgid "Finish"
 msgstr "Beenden"
 
-#: uffd/session/templates/session/devicelogin.html:6
-#: uffd/templates/base.html:93
-msgid "Device Login"
-msgstr "Gerätelogin"
-
-#: uffd/session/templates/session/devicelogin.html:9
+#: uffd/templates/session/devicelogin.html:9
 msgid ""
 "Use a login session on another device (e.g. your laptop) to log into a "
 "service without entering your password."
@@ -1522,7 +1348,7 @@ msgstr ""
 "Nutze eine Login-Sitzung auf einem anderen Gerät (z.B. deinem Laptop) um "
 "dich bei einem Dienst anzumelden."
 
-#: uffd/session/templates/session/devicelogin.html:22
+#: uffd/templates/session/devicelogin.html:22
 #, python-format
 msgid ""
 "Open <code><a href=\"%(deviceauth_url)s\">%(deviceauth_url)s</a></code> "
@@ -1533,97 +1359,47 @@ msgstr ""
 "auf dem anderen Gerät und gib dort den obenstehenden Startcode ein. Geben"
 " anschließend den Bestätigungscode hier ein."
 
-#: uffd/session/templates/session/login.html:23
+#: uffd/templates/session/login.html:23
 msgid "- or -"
 msgstr "- oder -"
 
-#: uffd/session/templates/session/login.html:25
+#: uffd/templates/session/login.html:25
 msgid "Login with another device"
 msgstr "Über anderes Gerät anmelden"
 
-#: uffd/session/templates/session/login.html:30
+#: uffd/templates/session/login.html:30
 msgid "Register"
 msgstr "Registrieren"
 
-#: uffd/session/templates/session/login.html:32
+#: uffd/templates/session/login.html:32
 msgid "Forgot Password?"
 msgstr "Passwort vergessen?"
 
-#: uffd/signup/models.py:77 uffd/signup/models.py:102
-msgid "Invalid signup request"
-msgstr "Ungültiger Account-Registrierungs-Link"
-
-#: uffd/signup/models.py:79
-msgid "Login name is invalid"
-msgstr "Anmeldename ist ungültig"
-
-#: uffd/signup/models.py:81
-msgid "Display name is invalid"
-msgstr "Anzeigename ist ungültig"
-
-#: uffd/signup/models.py:83
-msgid "Mail address is invalid"
-msgstr "E-Mail-Adresse nicht valide"
-
-#: uffd/signup/models.py:87 uffd/signup/models.py:106
-msgid "A user with this login name already exists"
-msgstr "Ein Account mit diesem Anmeldenamen existiert bereits"
-
-#: uffd/signup/models.py:88
-msgid "Valid"
-msgstr "Gültig"
-
-#: uffd/signup/models.py:104 uffd/signup/views.py:106
-msgid "Wrong password"
-msgstr "Falsches Passwort"
-
-#: uffd/signup/views.py:23
-msgid "Signup not enabled"
-msgstr "Account-Registrierung ist deaktiviert"
-
-#: uffd/signup/views.py:86 uffd/signup/views.py:94
-msgid "Invalid signup link"
-msgstr "Ungültiger Account-Registrierungs-Link"
-
-#: uffd/signup/views.py:99
-#, python-format
-msgid "Too many failed attempts! Please wait %(delay)s."
-msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s."
-
-#: uffd/signup/views.py:114
-msgid "Your account was successfully created"
-msgstr "Account erfolgreich erstellt"
-
-#: uffd/signup/templates/signup/confirm.html:6
+#: uffd/templates/signup/confirm.html:6
 msgid "Complete Registration"
 msgstr "Account-Registrierung abschließen"
 
-#: uffd/signup/templates/signup/confirm.html:9
+#: uffd/templates/signup/confirm.html:9
 msgid "Please enter your password to complete the account registration"
 msgstr "Bitte gib dein Passwort ein, um die Account-Registrierung abzuschließen"
 
-#: uffd/signup/templates/signup/confirm.html:13
+#: uffd/templates/signup/confirm.html:13
 msgid "Complete Account Registration"
 msgstr "Account-Registrierung abschließen"
 
-#: uffd/signup/templates/signup/start.html:13
+#: uffd/templates/signup/start.html:13
 msgid "Check"
 msgstr "Überprüfen"
 
-#: uffd/signup/templates/signup/start.html:18
-#: uffd/user/templates/group/show.html:29
-msgid ""
-"At least one and at most 32 lower-case characters, digits, dashes (\"-\")"
-" or underscores (\"_\"). <b>Cannot be changed later!</b>"
-msgstr ""
-"1 bis 32 Kleinbuchstaben, Zahlen, Binde- (\"-\") und Unterstriche "
-"(\"_\"). <b>Kann später nicht geändert werden!</b>"
-
-#: uffd/signup/templates/signup/start.html:25
+#: uffd/templates/signup/start.html:25
 msgid "At least one and at most 128 characters, no other special requirements."
 msgstr "Mindestens 1 und maximal 128 Zeichen, keine weiteren Einschränkungen."
 
-#: uffd/signup/templates/signup/start.html:32
+#: uffd/templates/signup/start.html:29 uffd/templates/user/show.html:86
+msgid "E-Mail Address"
+msgstr "E-Mail-Adresse"
+
+#: uffd/templates/signup/start.html:32
 msgid ""
 "We will send a confirmation mail to this address that you need to "
 "complete the registration."
@@ -1631,27 +1407,27 @@ msgstr ""
 "Wir werden eine Bestätigungsmail an diese Adresse senden. Du benötigst "
 "sie, um die Account-Registrierung abzuschließen."
 
-#: uffd/signup/templates/signup/start.html:47
+#: uffd/templates/signup/start.html:47
 msgid "Create Account"
 msgstr "Account registrieren"
 
-#: uffd/signup/templates/signup/start.html:74
+#: uffd/templates/signup/start.html:74
 msgid "The name is already taken"
 msgstr "Dieser Name wird bereits verwendet"
 
-#: uffd/signup/templates/signup/start.html:77
+#: uffd/templates/signup/start.html:77
 msgid "Too many requests! Please wait a bit before trying again!"
 msgstr "Zu viele Anfragen! Bitte warte etwas, bevor du es erneut versuchst!"
 
-#: uffd/signup/templates/signup/start.html:80
+#: uffd/templates/signup/start.html:80
 msgid "The name is invalid"
 msgstr "Name ungültig"
 
-#: uffd/signup/templates/signup/submitted.html:5
+#: uffd/templates/signup/submitted.html:5
 msgid "Confirm your E-Mail Address"
 msgstr "E-Mail-Adresse bestätigen"
 
-#: uffd/signup/templates/signup/submitted.html:7
+#: uffd/templates/signup/submitted.html:7
 #, python-format
 msgid ""
 "We sent a confirmation mail to <b>%(signup_mail)s</b>. You need to "
@@ -1662,7 +1438,7 @@ msgstr ""
 "deine E-Mail-Adresse innerhalb von 48 Stunden bestätigen, um die Account-"
 "Registrierung abzuschließen."
 
-#: uffd/signup/templates/signup/submitted.html:8
+#: uffd/templates/signup/submitted.html:8
 msgid ""
 "If you mistyped your mail address or don't receive the confirmation mail "
 "for another reason, retry the registration procedure from the beginning."
@@ -1671,120 +1447,23 @@ msgstr ""
 " Gründen keine Bestätigungsmail erhalten hast, kannst du den Prozess "
 "einfach von Vorne beginnen."
 
-#: uffd/templates/403.html:10
-msgid "Access Denied"
-msgstr "Zugriff verweigert"
-
-#: uffd/templates/403.html:17
-msgid "You don't have the permission to access this page."
-msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen."
-
-#: uffd/templates/base.html:84
-msgid "Change"
-msgstr "Ändern"
-
-#: uffd/templates/base.html:142
-msgid "About uffd"
-msgstr "Über uffd"
-
-#: uffd/user/models.py:32
-#, python-format
-msgid ""
-"At least %(minlen)d and at most %(maxlen)d characters. Only letters, "
-"digits, spaces and some symbols (<code>%(symbols)s</code>) allowed. "
-"Please use a password manager."
-msgstr ""
-"%(minlen)d bis %(maxlen)d Zeichen. Nur Buchstaben, Ziffern, Leerzeichen "
-"und manche Symbole (<code>%(symbols)s</code>), keine Umlaute. Bitte "
-"verwende einen Passwort-Manager."
-
-#: uffd/user/views_group.py:23
-msgid "Groups"
-msgstr "Gruppen"
-
-#: uffd/user/views_group.py:42
-msgid "Invalid name"
-msgstr "Ungültiger Name"
-
-#: uffd/user/views_group.py:53
-msgid "Group with this name or id already exists"
-msgstr "Gruppe mit diesem Namen oder dieser ID existiert bereits"
-
-#: uffd/user/views_group.py:58
-msgid "Group created"
-msgstr "Gruppe erstellt"
-
-#: uffd/user/views_group.py:60
-msgid "Group updated"
-msgstr "Gruppe aktualisiert"
-
-#: uffd/user/views_group.py:69
-msgid "Deleted group"
-msgstr "Gruppe gelöscht"
-
-#: uffd/user/views_user.py:31
-msgid "Users"
-msgstr "Accounts"
-
-#: uffd/user/views_user.py:51
-msgid "Login name does not meet requirements"
-msgstr "Anmeldename entspricht nicht den Anforderungen"
-
-#: uffd/user/views_user.py:56
-msgid "Mail is invalid"
-msgstr "E-Mail-Adresse nicht valide"
-
-#: uffd/user/views_user.py:60
-msgid "Display name does not meet requirements"
-msgstr "Anzeigename entspricht nicht den Anforderungen"
-
-#: uffd/user/views_user.py:65
-msgid "Password is invalid"
-msgstr "Passwort ist ungültig"
-
-#: uffd/user/views_user.py:78
-msgid "Service user created"
-msgstr "Service-Account erstellt"
-
-#: uffd/user/views_user.py:81
-msgid "User created. We sent the user a password reset link by mail"
-msgstr ""
-"Account erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde "
-"versendet."
-
-#: uffd/user/views_user.py:83
-msgid "User updated"
-msgstr "Account aktualisiert"
-
-#: uffd/user/views_user.py:93
-msgid "Deleted user"
-msgstr "Account gelöscht"
-
-#: uffd/user/templates/group/list.html:14
-msgid "GID"
-msgstr "GID"
-
-#: uffd/user/templates/group/show.html:18
-msgid "Group ID"
-msgstr "Gruppen ID"
-
-#: uffd/user/templates/user/list.html:11
+#: uffd/templates/user/list.html:11
 msgid "CSV import"
 msgstr "CSV-Import"
 
-#: uffd/user/templates/user/list.html:17
+#: uffd/templates/user/list.html:17
 msgid "UID"
 msgstr "UID"
 
-#: uffd/user/templates/user/list.html:34 uffd/user/templates/user/show.html:29
+#: uffd/templates/user/list.html:34 uffd/templates/user/show.html:44
 msgid "service"
 msgstr "service"
 
-#: uffd/user/templates/user/list.html:57
+#: uffd/templates/user/list.html:57
 msgid "Import a csv formated list of users"
 msgstr "Importiere eine als CSV formatierte Liste von Accounts"
 
-#: uffd/user/templates/user/list.html:64
+#: uffd/templates/user/list.html:64
 msgid ""
 "The format should be \"loginname,mailaddres,roleid1;roleid2\". Neither "
 "setting the display name nor setting passwords is supported (yet). "
@@ -1794,27 +1473,31 @@ msgstr ""
 "Anzeigename oder das Password können (derzeit) nicht gesetzt werden. "
 "Beispiel:"
 
-#: uffd/user/templates/user/list.html:75 uffd/user/templates/user/show.html:57
+#: uffd/templates/user/list.html:75 uffd/templates/user/show.html:72
 msgid "Ignore login name blocklist"
 msgstr "Liste der nicht erlaubten Anmeldenamen ignorieren"
 
-#: uffd/user/templates/user/list.html:80
+#: uffd/templates/user/list.html:80
 msgid "Import"
 msgstr "Importieren"
 
-#: uffd/user/templates/user/show.html:27
+#: uffd/templates/user/show.html:6
+msgid "New address"
+msgstr "Neue Adresse"
+
+#: uffd/templates/user/show.html:42
 msgid "User ID"
 msgstr "Account ID"
 
-#: uffd/user/templates/user/show.html:35
+#: uffd/templates/user/show.html:50
 msgid "will be choosen"
 msgstr "wird automatisch bestimmt"
 
-#: uffd/user/templates/user/show.html:42
+#: uffd/templates/user/show.html:57
 msgid "Service User"
 msgstr "Service-Account"
 
-#: uffd/user/templates/user/show.html:50
+#: uffd/templates/user/show.html:65
 msgid ""
 "Only letters, numbers, dashes (\"-\") and underscores (\"_\") are "
 "allowed. At most 32, at least 2 characters. There is a word blocklist. "
@@ -1824,7 +1507,7 @@ msgstr ""
 "erlaubt. Maximal 32, mindestens 2 Zeichen. Es gibt eine Liste nicht "
 "erlaubter Namen. Muss einmalig sein."
 
-#: uffd/user/templates/user/show.html:65
+#: uffd/templates/user/show.html:80
 msgid ""
 "If you leave this empty it will be set to the login name. At most 128, at"
 " least 2 characters. No character restrictions."
@@ -1832,36 +1515,420 @@ msgstr ""
 "Wenn das Feld leer bleibt, wird der Anmeldename verwendet. Maximal 128, "
 "mindestens 2 Zeichen. Keine Zeichenbeschränkung."
 
-#: uffd/user/templates/user/show.html:69
-msgid "Mail"
-msgstr "E-Mail-Adresse"
+#: uffd/templates/user/show.html:89
+msgid ""
+"Make sure the address is correct! Services might use e-mail addresses as "
+"account identifiers and rely on them being unique and verified."
+msgstr ""
+"Stelle sicher, dass die Adresse korrekt ist! Manche Dienste verwenden die"
+" E-Mail-Adresse um Accounts zu identifizieren und verlassen sich darauf, "
+"dass diese verifiziert und einzigartig sind."
+
+#: uffd/templates/user/show.html:98
+msgid "Address"
+msgstr "Adresse"
+
+#: uffd/templates/user/show.html:99
+msgid "Verified"
+msgstr "Verifiziert"
 
-#: uffd/user/templates/user/show.html:72
+#: uffd/templates/user/show.html:125
 msgid ""
-"Check that the address is unique. A user can take over another account if"
-" both have the same mail address set."
+"Make sure that addresses you add are correct! Services might use e-mail "
+"addresses as account identifiers and rely on them being unique and "
+"verified."
 msgstr ""
-"Überprüfe, ob die E-Mail-Adresse noch unbenutzt ist! Ein Account kann "
-"einen anderen Account übernehmen, wenn beide die selbe E-Mail-Adresse "
-"verwenden."
+"Stelle sicher, dass Adressen, die du hinzufügst, korrekt sind! Manche "
+"Dienste verwenden die E-Mail-Adresse um Accounts zu identifizieren und "
+"verlassen sich darauf, dass diese verifiziert und einzigartig sind."
 
-#: uffd/user/templates/user/show.html:80
-msgid "mail to set it will be sent"
+#: uffd/templates/user/show.html:129
+msgid "Primary E-Mail Address"
+msgstr "Primäre E-Mail-Adresse"
+
+#: uffd/templates/user/show.html:137
+msgid "Recovery E-Mail Address"
+msgstr "Wiederherstellungs-E-Mail-Adresse"
+
+#: uffd/templates/user/show.html:152
+msgid "E-Mail to set it will be sent"
 msgstr "Mail zum Setzen wird versendet"
 
-#: uffd/user/templates/user/show.html:91
+#: uffd/templates/user/show.html:163
 msgid "Status:"
 msgstr "Status:"
 
-#: uffd/user/templates/user/show.html:91
+#: uffd/templates/user/show.html:163
 msgid "Enabled"
 msgstr "Aktiv"
 
-#: uffd/user/templates/user/show.html:94
+#: uffd/templates/user/show.html:166
 msgid "Reset 2FA"
 msgstr "2FA zurücksetzen"
 
-#: uffd/user/templates/user/show.html:134
+#: uffd/templates/user/show.html:206
 msgid "Resulting groups (only updated after save)"
 msgstr "Resultierende Gruppen (wird nur aktualisiert beim Speichern)"
 
+#: uffd/views/group.py:22
+msgid "Groups"
+msgstr "Gruppen"
+
+#: uffd/views/group.py:41
+msgid "Invalid name"
+msgstr "Ungültiger Name"
+
+#: uffd/views/group.py:52
+msgid "Group with this name or id already exists"
+msgstr "Gruppe mit diesem Namen oder dieser ID existiert bereits"
+
+#: uffd/views/group.py:57
+msgid "Group created"
+msgstr "Gruppe erstellt"
+
+#: uffd/views/group.py:59
+msgid "Group updated"
+msgstr "Gruppe aktualisiert"
+
+#: uffd/views/group.py:68
+msgid "Deleted group"
+msgstr "Gruppe gelöscht"
+
+#: uffd/views/invite.py:43
+msgid "Invites"
+msgstr "Einladungslinks"
+
+#: uffd/views/invite.py:75
+msgid "The \"Expires After\" date is too far in the future"
+msgstr "Das Ablaufdatum liegt zu weit in der Zukunft"
+
+#: uffd/views/invite.py:78
+msgid "You are not allowed to create invite links with these permissions"
+msgstr "Dir fehlen Berechtigungen um diesen Einladungslink zu erstellen"
+
+#: uffd/views/invite.py:81
+msgid "Invite link must either allow signup or grant at least one role"
+msgstr ""
+"Einladungslink muss entweder Account-Registrierung erlauben oder Rollen "
+"vergeben"
+
+#: uffd/views/invite.py:111 uffd/views/invite.py:146
+msgid "Invalid invite link"
+msgstr "Ungültiger Einladungslink"
+
+#: uffd/views/invite.py:129
+msgid "Roles successfully updated"
+msgstr "Rollen erfolgreich geändert"
+
+#: uffd/views/invite.py:149
+msgid "Invite link does not allow signup"
+msgstr "Einladungslink erlaubt keine Account-Registrierung"
+
+#: uffd/views/invite.py:175 uffd/views/selfservice.py:44
+#: uffd/views/signup.py:47
+msgid "Passwords do not match"
+msgstr "Die Passwörter stimmen nicht überein"
+
+#: uffd/views/invite.py:181 uffd/views/signup.py:53
+#, python-format
+msgid "Too many signup requests with this mail address! Please wait %(delay)s."
+msgstr ""
+"Zu viele Account-Registrierungen mit dieser E-Mail-Adresse! Bitte warte "
+"%(delay)s."
+
+#: uffd/views/invite.py:184 uffd/views/signup.py:56
+#, python-format
+msgid "Too many requests! Please wait %(delay)s."
+msgstr "Zu viele Anfragen! Bitte warte %(delay)s."
+
+#: uffd/views/invite.py:199 uffd/views/signup.py:74
+msgid "Could not send mail"
+msgstr "Mailversand fehlgeschlagen"
+
+#: uffd/views/mail.py:21
+msgid "Forwardings"
+msgstr "Weiterleitungen"
+
+#: uffd/views/mail.py:46
+#, python-format
+msgid "Invalid receive address: %(mail_address)s"
+msgstr "Ungültige Empfangsadresse: %(mail_address)s"
+
+#: uffd/views/mail.py:50
+msgid "Mail mapping updated."
+msgstr "Mailweiterleitung geändert."
+
+#: uffd/views/mail.py:59
+msgid "Deleted mail mapping."
+msgstr "Mailweiterleitung gelöscht."
+
+#: uffd/views/mfa.py:49
+msgid "Two-factor authentication was reset"
+msgstr "Zwei-Faktor-Authentifizierung wurde zurückgesetzt"
+
+#: uffd/views/mfa.py:78
+msgid "Generate recovery codes first!"
+msgstr "Generiere zuerst die Wiederherstellungscodes!"
+
+#: uffd/views/mfa.py:86
+msgid "Code is invalid"
+msgstr "Wiederherstellungscode ist ungültig"
+
+#: uffd/views/mfa.py:105
+#, python-format
+msgid ""
+"2FA WebAuthn support disabled because import of the fido2 module failed "
+"(%s)"
+msgstr ""
+"2FA WebAuthn Unterstützung deaktiviert, da das fido2 Modul nicht geladen "
+"werden konnte (%s)"
+
+#: uffd/views/mfa.py:214
+#, python-format
+msgid "We received too many invalid attempts! Please wait at least %s."
+msgstr "Wir haben zu viele fehlgeschlagene Versuche! Bitte warte mindestens %s."
+
+#: uffd/views/mfa.py:228
+msgid "You have exhausted your recovery codes. Please generate new ones now!"
+msgstr "Du hast keine Wiederherstellungscode mehr. Bitte generiere diese jetzt!"
+
+#: uffd/views/mfa.py:231
+msgid ""
+"You only have a few recovery codes remaining. Make sure to generate new "
+"ones before they run out."
+msgstr ""
+"Du hast nur noch wenige Wiederherstellungscodes übrig. Bitte generiere "
+"diese erneut bevor keine mehr übrig sind."
+
+#: uffd/views/mfa.py:235
+msgid "Two-factor authentication failed"
+msgstr "Zwei-Faktor-Authentifizierung fehlgeschlagen"
+
+#: uffd/views/oauth2.py:167 uffd/views/selfservice.py:66
+#: uffd/views/session.py:72
+#, python-format
+msgid ""
+"We received too many requests from your ip address/network! Please wait "
+"at least %(delay)s."
+msgstr ""
+"Wir haben zu viele Anfragen von deiner IP-Adresses bzw. aus deinem "
+"Netzwerk empfangen! Bitte warte mindestens %(delay)s."
+
+#: uffd/views/oauth2.py:175
+msgid "Device login is currently not available. Try again later!"
+msgstr "Geräte-Login ist gerade nicht verfügbar. Versuche es später nochmal!"
+
+#: uffd/views/oauth2.py:188
+msgid "Device login failed"
+msgstr "Gerätelogin fehlgeschlagen"
+
+#: uffd/views/oauth2.py:194
+msgid "You need to login to access this service"
+msgstr "Du musst dich anmelden, um auf diesen Dienst zugreifen zu können"
+
+#: uffd/views/oauth2.py:201
+#, python-format
+msgid ""
+"You don't have the permission to access the service "
+"<b>%(service_name)s</b>."
+msgstr ""
+"Du bist nicht berechtigt, auf den Dienst <b>%(service_name)s</b> "
+"zuzugreifen."
+
+#: uffd/views/role.py:68
+msgid "Locked roles cannot be deleted"
+msgstr "Gesperrte Rollen können nicht gelöscht werden"
+
+#: uffd/views/rolemod.py:22
+msgid "Moderation"
+msgstr "Moderation"
+
+#: uffd/views/rolemod.py:42
+msgid "Description too long"
+msgstr "Beschreibung zu lang"
+
+#: uffd/views/rolemod.py:59
+msgid "Member removed"
+msgstr "Mitglied entfernt"
+
+#: uffd/views/selfservice.py:22
+msgid "Selfservice"
+msgstr "Selfservice"
+
+#: uffd/views/selfservice.py:33
+msgid "Display name changed."
+msgstr "Anzeigename geändert."
+
+#: uffd/views/selfservice.py:35
+msgid "Display name is not valid."
+msgstr "Anzeigename ist nicht valide."
+
+#: uffd/views/selfservice.py:47
+msgid "Password changed"
+msgstr "Passwort geändert"
+
+#: uffd/views/selfservice.py:64
+#, python-format
+msgid ""
+"We received too many password reset requests for this user! Please wait "
+"at least %(delay)s."
+msgstr ""
+"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account! "
+"Bitte warte mindestens %(delay)s."
+
+#: uffd/views/selfservice.py:70
+msgid ""
+"We sent a mail to this user's mail address if you entered the correct "
+"mail and login name combination"
+msgstr ""
+"Falls E-Mail-Adresse und Anmeldename richtig waren, wurde eine E-Mail an "
+"die Adresse gesendet."
+
+#: uffd/views/selfservice.py:87 uffd/views/selfservice.py:138
+#: uffd/views/selfservice.py:143
+msgid "Link invalid or expired"
+msgstr "Link ist ungültig oder abgelaufen"
+
+#: uffd/views/selfservice.py:92
+msgid "You need to set a password, please try again."
+msgstr "Password fehlt, bitte versuche es erneut."
+
+#: uffd/views/selfservice.py:95
+msgid "Passwords do not match, please try again."
+msgstr "Die Passwörter stimmen nicht überein, bitte versuche es erneut"
+
+#: uffd/views/selfservice.py:100
+msgid "Password ist not valid, please try again."
+msgstr "Ungültiges Passwort, bitte versuche es erneut"
+
+#: uffd/views/selfservice.py:104
+msgid "New password set"
+msgstr "Passwort geändert"
+
+#: uffd/views/selfservice.py:117
+msgid "E-Mail address already exists"
+msgstr "E-Mail-Adresse existiert bereits"
+
+#: uffd/views/selfservice.py:124 uffd/views/selfservice.py:158
+#: uffd/views/selfservice.py:221
+#, python-format
+msgid "E-Mail to \"%(mail_address)s\" could not be sent!"
+msgstr "E-Mail an \"%(mail_address)s\" konnte nicht gesendet werden!"
+
+#: uffd/views/selfservice.py:126 uffd/views/selfservice.py:160
+msgid "We sent you an email, please verify your mail address."
+msgstr "Wir haben dir eine E-Mail gesendet, bitte prüfe deine E-Mail-Adresse."
+
+#: uffd/views/selfservice.py:141
+msgid ""
+"This link was generated for another user. Login as the correct user to "
+"continue."
+msgstr ""
+"Dieser Link wurde für einen anderen Account erstellt. Melde dich mit dem "
+"richtigen Account an um Fortzufahren."
+
+#: uffd/views/selfservice.py:148
+msgid "E-Mail address verified"
+msgstr "E-Mail-Adresse verifiziert"
+
+#: uffd/views/selfservice.py:173
+msgid "E-Mail address deleted"
+msgstr "E-Mail-Adresse gelöscht"
+
+#: uffd/views/selfservice.py:192
+msgid "E-Mail preferences updated"
+msgstr "E-Mail-Einstellungen geändert"
+
+#: uffd/views/selfservice.py:203
+#, python-format
+msgid "You left role %(role_name)s"
+msgstr "Rolle %(role_name)s verlassen"
+
+#: uffd/views/service.py:32
+msgid "Services"
+msgstr "Dienste"
+
+#: uffd/views/session.py:70
+#, python-format
+msgid ""
+"We received too many invalid login attempts for this user! Please wait at"
+" least %(delay)s."
+msgstr ""
+"Wir haben zu viele fehlgeschlagene Anmeldeversuche für diesen Account "
+"erhalten! Bitte warte mindestens %(delay)s."
+
+#: uffd/views/session.py:78
+msgid "Login name or password is wrong"
+msgstr "Der Anmeldename oder das Passwort ist falsch"
+
+#: uffd/views/session.py:84
+msgid "You do not have access to this service"
+msgstr "Du hast keinen Zugriff auf diesen Service"
+
+#: uffd/views/session.py:96 uffd/views/session.py:107
+msgid "You need to login first"
+msgstr "Du musst dich erst anmelden"
+
+#: uffd/views/session.py:128 uffd/views/session.py:138
+msgid "Initiation code is no longer valid"
+msgstr "Startcode ist nicht mehr gültig"
+
+#: uffd/views/session.py:142
+msgid "Invalid confirmation code"
+msgstr "Ungültiger Bestätigungscode"
+
+#: uffd/views/session.py:154 uffd/views/session.py:165
+msgid "Invalid initiation code"
+msgstr "Ungültiger Startcode"
+
+#: uffd/views/signup.py:21
+msgid "Signup not enabled"
+msgstr "Account-Registrierung ist deaktiviert"
+
+#: uffd/views/signup.py:84 uffd/views/signup.py:92
+msgid "Invalid signup link"
+msgstr "Ungültiger Account-Registrierungs-Link"
+
+#: uffd/views/signup.py:97
+#, python-format
+msgid "Too many failed attempts! Please wait %(delay)s."
+msgstr "Zu viele fehlgeschlagene Versuche! Bitte warte mindestens %(delay)s."
+
+#: uffd/views/signup.py:112
+msgid "Your account was successfully created"
+msgstr "Account erfolgreich erstellt"
+
+#: uffd/views/user.py:30
+msgid "Users"
+msgstr "Accounts"
+
+#: uffd/views/user.py:51
+msgid "Login name does not meet requirements"
+msgstr "Anmeldename entspricht nicht den Anforderungen"
+
+#: uffd/views/user.py:96
+msgid "Display name does not meet requirements"
+msgstr "Anzeigename entspricht nicht den Anforderungen"
+
+#: uffd/views/user.py:102
+msgid "Password is invalid"
+msgstr "Passwort ist ungültig"
+
+#: uffd/views/user.py:118
+msgid "Service user created"
+msgstr "Service-Account erstellt"
+
+#: uffd/views/user.py:121
+msgid "User created. We sent the user a password reset link by e-mail"
+msgstr ""
+"Account erstellt. E-Mail mit einem Link zum Setzen des Passworts wurde "
+"versendet."
+
+#: uffd/views/user.py:123
+msgid "User updated"
+msgstr "Account aktualisiert"
+
+#: uffd/views/user.py:133
+msgid "Deleted user"
+msgstr "Account gelöscht"
+
diff --git a/uffd/views/api.py b/uffd/views/api.py
index 0bfa71e7..cdab573d 100644
--- a/uffd/views/api.py
+++ b/uffd/views/api.py
@@ -97,6 +97,7 @@ def getusers():
 	if key is None or key == 'group':
 		# pylint: disable=no-member
 		query = query.options(db.joinedload(ServiceUser.user).selectinload(User.groups))
+		query = query.options(db.joinedload(ServiceUser.user).joinedload(User.primary_email))
 	return jsonify([generate_user_dict(user) for user in query])
 
 @bp.route('/checkpassword', methods=['POST'])
diff --git a/uffd/views/selfservice.py b/uffd/views/selfservice.py
index fee71b8d..95883536 100644
--- a/uffd/views/selfservice.py
+++ b/uffd/views/selfservice.py
@@ -2,12 +2,13 @@ import secrets
 
 from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort
 from flask_babel import gettext as _, lazy_gettext
+from sqlalchemy.exc import IntegrityError
 
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.sendmail import sendmail
 from uffd.database import db
-from uffd.models import User, PasswordToken, MailToken, Role, host_ratelimit, Ratelimit, format_delay
+from uffd.models import User, UserEmail, PasswordToken, Role, host_ratelimit, Ratelimit, format_delay
 from .session import login_required
 
 bp = Blueprint("selfservice", __name__, template_folder='templates', url_prefix='/self/')
@@ -32,9 +33,6 @@ def update_profile():
 			flash(_('Display name changed.'))
 		else:
 			flash(_('Display name is not valid.'))
-	if request.values['mail'] != request.user.mail:
-		send_mail_verification(request.user, request.values['mail'])
-		flash(_('We sent you an email, please verify your mail address.'))
 	db.session.commit()
 	return redirect(url_for('selfservice.index'))
 
@@ -71,7 +69,13 @@ def forgot_password():
 	host_ratelimit.log()
 	flash(_("We sent a mail to this user's mail address if you entered the correct mail and login name combination"))
 	user = User.query.filter_by(loginname=loginname).one_or_none()
-	if user and user.mail == mail and user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']):
+	if not user:
+		return redirect(url_for('session.login'))
+	matches = any(map(lambda email: secrets.compare_digest(email.address, mail), user.verified_emails))
+	if not matches:
+		return redirect(url_for('session.login'))
+	recovery_email = user.recovery_email or user.primary_email
+	if recovery_email.address == mail and user.is_in_group(current_app.config['ACL_SELFSERVICE_GROUP']):
 		send_passwordreset(user)
 	return redirect(url_for('session.login'))
 
@@ -100,20 +104,92 @@ def token_password(token_id, token):
 	flash(_('New password set'))
 	return redirect(url_for('session.login'))
 
-@bp.route("/token/mail_verification/<int:token_id>/<token>")
+@bp.route("/email/new", methods=['POST'])
 @login_required(selfservice_acl_check)
-def token_mail(token_id, token):
-	dbtoken = MailToken.query.get(token_id)
-	if not dbtoken or not secrets.compare_digest(dbtoken.token, token) or \
-			dbtoken.expired:
+def add_email():
+	email = UserEmail(user=request.user)
+	if not email.set_address(request.form['address']):
+		flash(_('E-Mail address is invalid'))
+		return redirect(url_for('selfservice.index'))
+	try:
+		db.session.flush()
+	except IntegrityError:
+		flash(_('E-Mail address already exists'))
+		return redirect(url_for('selfservice.index'))
+
+	secret = email.start_verification()
+	db.session.add(email)
+	db.session.commit()
+	if not sendmail(email.address, 'Mail verification', 'selfservice/mailverification.mail.txt', user=request.user, email=email, secret=secret):
+		flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address))
+	else:
+		flash(_('We sent you an email, please verify your mail address.'))
+	return redirect(url_for('selfservice.index'))
+
+@bp.route("/email/<int:email_id>/verify/<secret>")
+@bp.route("/token/mail_verification/<int:legacy_id>/<secret>")
+@login_required(selfservice_acl_check)
+def verify_email(secret, email_id=None, legacy_id=None):
+	if email_id is not None:
+		email = UserEmail.query.get(email_id)
+	else:
+		email = UserEmail.query.filter_by(verification_legacy_id=legacy_id).one()
+	if not email or email.verification_expired:
 		flash(_('Link invalid or expired'))
 		return redirect(url_for('selfservice.index'))
-	if dbtoken.user != request.user:
+	if email.user != request.user:
 		abort(403, description=_('This link was generated for another user. Login as the correct user to continue.'))
-	dbtoken.user.set_mail(dbtoken.newmail)
-	db.session.delete(dbtoken)
+	if not email.finish_verification(secret):
+		flash(_('Link invalid or expired'))
+		return redirect(url_for('selfservice.index'))
+	if legacy_id is not None:
+		request.user.primary_email = email
 	db.session.commit()
-	flash(_('New mail set'))
+	flash(_('E-Mail address verified'))
+	return redirect(url_for('selfservice.index'))
+
+@bp.route("/email/<int:email_id>/retry")
+@login_required(selfservice_acl_check)
+def retry_email_verification(email_id):
+	email = UserEmail.query.filter_by(id=email_id, user=request.user, verified=False).first_or_404()
+	secret = email.start_verification()
+	db.session.commit()
+	if not sendmail(email.address, 'E-Mail verification', 'selfservice/mailverification.mail.txt', user=request.user, email=email, secret=secret):
+		flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address))
+	else:
+		flash(_('We sent you an email, please verify your mail address.'))
+	return redirect(url_for('selfservice.index'))
+
+@bp.route("/email/<int:email_id>/delete", methods=['POST', 'GET'])
+@login_required(selfservice_acl_check)
+def delete_email(email_id):
+	email = UserEmail.query.filter_by(id=email_id, user=request.user).first_or_404()
+	try:
+		db.session.delete(email)
+		db.session.commit()
+	except IntegrityError:
+		flash(_('Cannot delete primary e-mail address'))
+		return redirect(url_for('selfservice.index'))
+	flash(_('E-Mail address deleted'))
+	return redirect(url_for('selfservice.index'))
+
+@bp.route("/email/preferences", methods=['POST'])
+@login_required(selfservice_acl_check)
+def update_email_preferences():
+	verified_emails = UserEmail.query.filter_by(user=request.user, verified=True)
+	email = verified_emails.filter_by(id=request.form['primary_email']).first()
+	if not email:
+		abort(400)
+	request.user.primary_email = email
+	if request.form['recovery_email'] == 'primary':
+		request.user.recovery_email = None
+	else:
+		email = verified_emails.filter_by(id=request.form['recovery_email']).first()
+		if not email:
+			abort(400)
+		request.user.recovery_email = email
+	db.session.commit()
+	flash(_('E-Mail preferences updated'))
 	return redirect(url_for('selfservice.index'))
 
 @bp.route("/leaverole/<int:roleid>", methods=(['POST']))
@@ -127,15 +203,6 @@ def leave_role(roleid):
 	flash(_('You left role %(role_name)s', role_name=role.name))
 	return redirect(url_for('selfservice.index'))
 
-def send_mail_verification(user, newmail):
-	MailToken.query.filter(MailToken.user == user).delete()
-	token = MailToken(user=user, newmail=newmail)
-	db.session.add(token)
-	db.session.commit()
-
-	if not sendmail(newmail, 'Mail verification', 'selfservice/mailverification.mail.txt', user=user, token=token):
-		flash(_('Mail to "%(mail_address)s" could not be sent!', mail_address=newmail))
-
 def send_passwordreset(user, new=False):
 	PasswordToken.query.filter(PasswordToken.user == user).delete()
 	token = PasswordToken(user=user)
@@ -149,5 +216,6 @@ def send_passwordreset(user, new=False):
 		template = 'selfservice/passwordreset.mail.txt'
 		subject = 'Password reset'
 
-	if not sendmail(user.mail, subject, template, user=user, token=token):
-		flash(_('Mail to "%(mail_address)s" could not be sent!', mail_address=user.mail))
+	email = user.recovery_email or user.primary_email
+	if not sendmail(email.address, subject, template, user=user, token=token):
+		flash(_('E-Mail to "%(mail_address)s" could not be sent!', mail_address=email.address))
diff --git a/uffd/views/user.py b/uffd/views/user.py
index 8e2ab638..3067a4cb 100644
--- a/uffd/views/user.py
+++ b/uffd/views/user.py
@@ -1,7 +1,7 @@
 import csv
 import io
 
-from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app
+from flask import Blueprint, render_template, request, url_for, redirect, flash, current_app, abort
 from flask_babel import gettext as _, lazy_gettext
 from sqlalchemy.exc import IntegrityError
 
@@ -9,7 +9,7 @@ from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.remailer import remailer
 from uffd.database import db
-from uffd.models import User, Role
+from uffd.models import User, UserEmail, Role
 from .selfservice import send_passwordreset
 from .session import login_required
 
@@ -41,6 +41,7 @@ def show(id=None):
 @bp.route("/new", methods=['POST'])
 @csrf_protect(blueprint=bp)
 def update(id=None):
+	# pylint: disable=too-many-branches,too-many-statements
 	if id is None:
 		user = User()
 		ignore_blocklist = request.form.get('ignore-loginname-blocklist', False)
@@ -49,21 +50,60 @@ def update(id=None):
 		if not user.set_loginname(request.form['loginname'], ignore_blocklist=ignore_blocklist):
 			flash(_('Login name does not meet requirements'))
 			return redirect(url_for('user.show'))
+		if not user.set_primary_email_address(request.form['email']):
+			flash(_('E-Mail address is invalid'))
+			return redirect(url_for('user.show'))
 	else:
 		user = User.query.get_or_404(id)
-	if user.mail != request.form['mail'] and not user.set_mail(request.form['mail']):
-		flash(_('Mail is invalid'))
-		return redirect(url_for('user.show', id=id))
+
+		for email in user.all_emails:
+			if f'email-{email.id}-present' in request.form:
+				email.verified = email.verified or (request.form.get(f'email-{email.id}-verified') == '1')
+
+		for key, value in request.form.items():
+			parts = key.split('-')
+			if not parts[0] == 'newemail' or not parts[2] == 'address' or not value:
+				continue
+			tmp_id = parts[1]
+			email = UserEmail(
+				user=user,
+				verified=(request.form.get(f'newemail-{tmp_id}-verified') == '1'),
+			)
+			if not email.set_address(value):
+				flash(_('E-Mail address is invalid'))
+				return redirect(url_for('user.show', id=id))
+			db.session.add(email)
+
+		verified_emails = UserEmail.query.filter_by(user=user, verified=True)
+		email = verified_emails.filter_by(id=request.form['primary_email']).first()
+		if not email:
+			abort(400)
+		user.primary_email = email
+		if request.form['recovery_email'] == 'primary':
+			user.recovery_email = None
+		else:
+			email = verified_emails.filter_by(id=request.form['recovery_email']).first()
+			if not email:
+				abort(400)
+			user.recovery_email = email
+
+		for email in user.all_emails:
+			if request.form.get(f'email-{email.id}-delete') == '1':
+				db.session.delete(email)
+
 	new_displayname = request.form['displayname'] if request.form['displayname'] else request.form['loginname']
 	if user.displayname != new_displayname and not user.set_displayname(new_displayname):
 		flash(_('Display name does not meet requirements'))
 		return redirect(url_for('user.show', id=id))
+
 	new_password = request.form.get('password')
 	if id is not None and new_password:
 		if not user.set_password(new_password):
 			flash(_('Password is invalid'))
 			return redirect(url_for('user.show', id=id))
+
 	db.session.add(user)
+
 	user.roles.clear()
 	for role in Role.query.all():
 		if not user.is_service_user and role.is_default:
@@ -71,13 +111,14 @@ def update(id=None):
 		if request.values.get('role-{}'.format(role.id), False):
 			user.roles.append(role)
 	user.update_groups()
+
 	db.session.commit()
 	if id is None:
 		if user.is_service_user:
 			flash(_('Service user created'))
 		else:
 			send_passwordreset(user, new=True)
-			flash(_('User created. We sent the user a password reset link by mail'))
+			flash(_('User created. We sent the user a password reset link by e-mail'))
 	else:
 		flash(_('User updated'))
 	return redirect(url_for('user.show', id=user.id))
@@ -114,8 +155,8 @@ def csvimport():
 			if not newuser.set_loginname(row[0], ignore_blocklist=ignore_blocklist) or not newuser.set_displayname(row[0]):
 				flash("invalid login name, skipped : {}".format(row))
 				continue
-			if not newuser.set_mail(row[1]):
-				flash("invalid mail address, skipped : {}".format(row))
+			if not newuser.set_primary_email_address(row[1]):
+				flash("invalid e-mail address, skipped : {}".format(row))
 				continue
 			db.session.add(newuser)
 			for role in roles:
-- 
GitLab