From 10e37c1780ee1280aef9b7f06ce59f9cd6c6ffce Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Wed, 20 Apr 2022 18:07:54 +0200
Subject: [PATCH] Remailer support

With this feature, uffd can be configured to hide mail addresses of users
from certain services while still allowing the services to send mails to the
users.

To these services uffd returns special remailer addresses instead of the real
mail addresses. When a service sends an email to a remailer address the mail
server queries uffd's API and replaces the remailer address with the real mail
address in both envelope and headers.

This feature requires additional mail server configuration (Postfix
canonical_maps) and support in uffd-socketmapd.
---
 README.md                                     |   1 +
 debian/control                                |   1 +
 setup.py                                      |   2 +-
 tests/test_api.py                             |  53 ++++++-
 tests/test_oauth2.py                          |  16 ++-
 tests/test_user.py                            | 133 +++++++++++++++++-
 uffd/api/models.py                            |   1 +
 uffd/api/views.py                             |  20 ++-
 uffd/default_config.cfg                       |  15 ++
 ...31c_remailer_setting_and_api_permission.py |  72 ++++++++++
 uffd/oauth2/views.py                          |   2 +-
 uffd/service/models.py                        |   2 +
 uffd/service/templates/service/api.html       |  13 ++
 uffd/service/templates/service/show.html      |  16 ++-
 uffd/service/views.py                         |   2 +
 uffd/translations/de/LC_MESSAGES/messages.mo  | Bin 33273 -> 34296 bytes
 uffd/translations/de/LC_MESSAGES/messages.po  |  58 +++++---
 uffd/user/models.py                           |  87 ++++++++++++
 uffd/user/views_user.py                       |   3 +-
 19 files changed, 463 insertions(+), 34 deletions(-)
 create mode 100644 uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py

diff --git a/README.md b/README.md
index a1a59daa..7cb76413 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@ Please note that we refer to Debian packages here and **not** pip packages.
 - python3-oauthlib
 - python3-flask-babel
 - python3-argon2
+- python3-itsdangerous (also a dependency of python3-flask)
 - python3-mysqldb or python3-pymysql for MySQL/MariaDB support
 
 Some of the dependencies (especially fido2) changed their API in recent versions, so make sure to install the versions from Debian Buster or Bullseye.
diff --git a/debian/control b/debian/control
index f9fef23b..ae3744ce 100644
--- a/debian/control
+++ b/debian/control
@@ -26,6 +26,7 @@ Depends:
  python3-oauthlib,
  python3-flask-babel,
  python3-argon2,
+ python3-itsdangerous,
  uwsgi,
  uwsgi-plugin-python3,
 Recommends:
diff --git a/setup.py b/setup.py
index ad1822a8..45f8ca63 100644
--- a/setup.py
+++ b/setup.py
@@ -41,6 +41,7 @@ setup(
 		'Flask-Babel==0.11.2',
 		'alembic==1.0.0',
 		'argon2-cffi==18.3.0',
+		'itsdangerous==0.24',
 
 		# The main dependencies on their own lead to version collisions and pip is
 		# not very good at resolving them, so we pin the versions from Debian Buster
@@ -52,7 +53,6 @@ setup(
 		'click==7.0',
 		'cryptography==2.6.1',
 		'idna==2.6',
-		'itsdangerous==0.24',
 		'Jinja2==2.10',
 		'MarkupSafe==1.1.0',
 		'oauthlib==2.1.0',
diff --git a/tests/test_api.py b/tests/test_api.py
index 4d1a0bf7..7e86379a 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -5,7 +5,7 @@ from flask import url_for
 from uffd.api.views import apikey_required
 from uffd.api.models import APIClient
 from uffd.service.models import Service
-from uffd.user.models import User
+from uffd.user.models import User, remailer
 from uffd.password_hash import PlaintextPasswordHash
 from uffd.database import db
 from utils import UffdTestCase, db_flush
@@ -140,6 +140,19 @@ class TestAPIGetusers(UffdTestCase):
 		self.assertEqual(r.status_code, 200)
 		self.assertEqual(r.json, [])
 
+	def test_with_remailer(self):
+		service = Service.query.filter_by(name='test').one()
+		service.use_remailer = True
+		db.session.commit()
+		user = self.get_user()
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		r = self.client.get(path=url_for('api.getusers', id=10000), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.filter_by(name='test').one()
+		self.assertEqual(self.fix_result(r.json), [
+			{'displayname': 'Test User', 'email': remailer.build_address(user, service), 'id': 10000, 'loginname': 'testuser', 'groups': ['uffd_access', 'users']},
+		])
+
 	def test_loginname(self):
 		r = self.client.get(path=url_for('api.getusers', loginname='testuser'), headers=[basic_auth('test', 'test')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
@@ -159,6 +172,19 @@ class TestAPIGetusers(UffdTestCase):
 			{'displayname': 'Test Admin', 'email': 'admin@example.com', 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']}
 		])
 
+	def test_email_with_remailer(self):
+		service = Service.query.filter_by(name='test').one()
+		service.use_remailer = True
+		db.session.commit()
+		user = self.get_admin()
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		r = self.client.get(path=url_for('api.getusers', email=remailer.build_address(user, service)), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.filter_by(name='test').one()
+		self.assertEqual(self.fix_result(r.json), [
+			{'displayname': 'Test Admin', 'email': remailer.build_address(user, service), 'id': 10001, 'loginname': 'testadmin', 'groups': ['uffd_access', 'uffd_admin', 'users']}
+		])
+
 	def test_email_empty(self):
 		r = self.client.get(path=url_for('api.getusers', email='foo@bar'), headers=[basic_auth('test', 'test')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
@@ -231,3 +257,28 @@ class TestAPIGetgroups(UffdTestCase):
 		r = self.client.get(path=url_for('api.getgroups', member='notauser'), headers=[basic_auth('test', 'test')], follow_redirects=True)
 		self.assertEqual(r.status_code, 200)
 		self.assertEqual(r.json, [])
+
+class TestAPIRemailerResolve(UffdTestCase):
+	def setUpDB(self):
+		db.session.add(APIClient(service=Service(name='test'), auth_username='test', auth_password='test', perm_remailer=True))
+		db.session.add(Service(name='service1'))
+		db.session.add(Service(name='service2', use_remailer=True))
+
+	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(user, service)), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(r.json, {'address': user.mail})
+		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})
+
+	def test_invalid(self):
+		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, 400)
+		r = self.client.get(path=url_for('api.resolve_remailer'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 400)
+		r = self.client.get(path=url_for('api.resolve_remailer', foo='bar'), headers=[basic_auth('test', 'test')], follow_redirects=True)
+		self.assertEqual(r.status_code, 400)
diff --git a/tests/test_oauth2.py b/tests/test_oauth2.py
index d3e68dfe..9021240c 100644
--- a/tests/test_oauth2.py
+++ b/tests/test_oauth2.py
@@ -6,7 +6,7 @@ from flask import url_for, session
 # These imports are required, because otherwise we get circular imports?!
 from uffd import user
 
-from uffd.user.models import User
+from uffd.user.models import User, remailer
 from uffd.password_hash import PlaintextPasswordHash
 from uffd.session.models import DeviceLoginConfirmation
 from uffd.service.models import Service
@@ -20,7 +20,7 @@ class TestViews(UffdTestCase):
 		db.session.add(OAuth2Client(service=Service(name='test', limit_access=False), client_id='test', client_secret='testsecret', redirect_uris=['http://localhost:5009/callback', 'http://localhost:5009/callback2']))
 		db.session.add(OAuth2Client(service=Service(name='test1', access_group=self.get_admin_group()), client_id='test1', client_secret='testsecret1', redirect_uris=['http://localhost:5008/callback']))
 
-	def assert_authorization(self, r):
+	def assert_authorization(self, r, mail=None):
 		while True:
 			if r.status_code != 302 or r.location.startswith('http://localhost:5009/callback'):
 				break
@@ -44,7 +44,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'], user.mail)
+		self.assertEqual(r.json['email'], mail or user.mail)
 		self.assertTrue(r.json.get('groups'))
 
 	def test_authorization(self):
@@ -52,6 +52,16 @@ class TestViews(UffdTestCase):
 		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False)
 		self.assert_authorization(r)
 
+	def test_authorization_with_remailer(self):
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		service = Service.query.filter_by(name='test').one()
+		service.use_remailer = True
+		db.session.commit()
+		self.login_as('user')
+		r = self.client.get(path=url_for('oauth2.authorize', response_type='code', client_id='test', state='teststate', redirect_uri='http://localhost:5009/callback', scope='profile'), follow_redirects=False)
+		service = Service.query.filter_by(name='test').one()
+		self.assert_authorization(r, mail=remailer.build_address(self.get_user(), service))
+
 	def test_authorization_client_secret_rehash(self):
 		OAuth2Client.query.delete()
 		db.session.add(OAuth2Client(service=Service(name='rehash_test', limit_access=False), client_id='test', client_secret=PlaintextPasswordHash.from_password('testsecret'), redirect_uris=['http://localhost:5009/callback', 'http://localhost:5009/callback2']))
diff --git a/tests/test_user.py b/tests/test_user.py
index 6a92ab6b..0990a74f 100644
--- a/tests/test_user.py
+++ b/tests/test_user.py
@@ -7,8 +7,9 @@ import sqlalchemy
 # These imports are required, because otherwise we get circular imports?!
 from uffd import user
 
-from uffd.user.models import User, Group
+from uffd.user.models import User, remailer, RemailerAddress, Group
 from uffd.role.models import Role, RoleGroup
+from uffd.service.models import Service
 from uffd import create_app, db
 
 from utils import dump, UffdTestCase
@@ -98,6 +99,136 @@ class TestUserModel(UffdTestCase):
 			db.session.add(user2)
 			db.session.commit()
 
+	def test_set_mail(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.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'))
+
+	def test_get_service_mail(self):
+		service1 = Service(name='service1')
+		service2 = Service(name='service2', use_remailer=True)
+		db.session.add_all([service1, service2])
+		db.session.commit()
+		user = self.get_user()
+		self.assertEqual(user.get_service_mail(service1), user.mail)
+		self.assertEqual(user.get_service_mail(service2), user.mail)
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		self.assertEqual(user.get_service_mail(service1), user.mail)
+		self.assertEqual(user.get_service_mail(service2), remailer.build_address(user, service2))
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin']
+		self.assertEqual(user.get_service_mail(service1), user.mail)
+		self.assertEqual(user.get_service_mail(service2), user.mail)
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin', 'testuser']
+		self.assertEqual(user.get_service_mail(service1), user.mail)
+		self.assertEqual(user.get_service_mail(service2), remailer.build_address(user, service2))
+
+	def test_filter_by_service_mail(self):
+		service1 = Service(name='service1')
+		service2 = Service(name='service2', use_remailer=True)
+		db.session.add_all([service1, service2])
+		db.session.commit()
+		user = self.get_user()
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, user.mail)).all(), [user])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service1))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service2))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, user.mail)).all(), [user])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service1))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service2))).all(), [])
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, user.mail)).all(), [user])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service1))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service2))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, user.mail)).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service2))).all(), [user])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service1))).all(), [])
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin']
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, user.mail)).all(), [user])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service1))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service2))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, user.mail)).all(), [user])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service1))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service2))).all(), [])
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin', 'testuser']
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, user.mail)).all(), [user])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service1))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service1, remailer.build_address(user, service2))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, user.mail)).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service1))).all(), [])
+		self.assertEqual(User.query.filter(User.filter_by_service_mail(service2, remailer.build_address(user, service2))).all(), [user])
+
+class TestRemailer(UffdTestCase):
+	def setUpDB(self):
+		self.service1 = Service(name='service1')
+		self.service2 = Service(name='service2', use_remailer=True)
+		db.session.add_all([self.service1, self.service2])
+
+	def test_is_remailer_domain(self):
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		self.assertTrue(remailer.is_remailer_domain('remailer.example.com'))
+		self.assertTrue(remailer.is_remailer_domain('REMAILER.EXAMPLE.COM'))
+		self.assertTrue(remailer.is_remailer_domain(' remailer.example.com '))
+		self.assertFalse(remailer.is_remailer_domain('other.remailer.example.com'))
+		self.assertFalse(remailer.is_remailer_domain('example.com'))
+		self.app.config['REMAILER_OLD_DOMAINS'] = [' OTHER.remailer.example.com ']
+		self.assertTrue(remailer.is_remailer_domain(' OTHER.remailer.example.com '))
+		self.assertTrue(remailer.is_remailer_domain('remailer.example.com'))
+		self.assertTrue(remailer.is_remailer_domain('other.remailer.example.com'))
+		self.assertFalse(remailer.is_remailer_domain('example.com'))
+
+	def test_build_address(self):
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		user = self.get_user()
+		self.assertTrue(remailer.build_address(user, self.service1).endswith('@remailer.example.com'))
+		self.assertTrue(remailer.build_address(user, self.service2).endswith('@remailer.example.com'))
+		self.assertLessEqual(len(remailer.build_local_part(user, self.service1)), 64)
+		self.assertLessEqual(len(remailer.build_address(user, self.service1)), 256)
+		self.assertEqual(remailer.build_address(user, self.service1), remailer.build_address(user, self.service1))
+		self.assertNotEqual(remailer.build_address(user, self.service1), remailer.build_address(user, self.service2))
+		addr = remailer.build_address(user, self.service1)
+		self.app.config['REMAILER_OLD_DOMAINS'] = ['old.remailer.example.com']
+		self.assertEqual(remailer.build_address(user, self.service1), addr)
+		self.assertTrue(remailer.build_address(user, self.service1).endswith('@remailer.example.com'))
+		self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY']
+		self.assertEqual(remailer.build_address(user, self.service1), addr)
+		self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY'
+		self.assertNotEqual(remailer.build_address(user, self.service1), addr)
+
+	def test_parse_address(self):
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		user = self.get_user()
+		addr = remailer.build_address(user, self.service2)
+		# REMAILER_DOMAIN behaviour
+		self.app.config['REMAILER_DOMAIN'] = None
+		self.assertIsNone(remailer.parse_address(addr))
+		self.assertIsNone(remailer.parse_address('foo@example.com'))
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		self.assertEqual(remailer.parse_address(addr), RemailerAddress(user, self.service2))
+		self.assertIsNone(remailer.parse_address('foo@example.com'))
+		self.assertIsNone(remailer.parse_address('foo@remailer.example.com'))
+		self.assertIsNone(remailer.parse_address('v1-foo@remailer.example.com'))
+		self.app.config['REMAILER_DOMAIN'] = 'new-remailer.example.com'
+		self.assertIsNone(remailer.parse_address(addr))
+		self.app.config['REMAILER_OLD_DOMAINS'] = ['remailer.example.com']
+		self.assertEqual(remailer.parse_address(addr), RemailerAddress(user, self.service2))
+		# REMAILER_SECRET_KEY behaviour
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		self.app.config['REMAILER_OLD_DOMAINS'] = []
+		self.assertEqual(remailer.parse_address(addr), RemailerAddress(user, self.service2))
+		self.app.config['REMAILER_SECRET_KEY'] = self.app.config['SECRET_KEY']
+		self.assertEqual(remailer.parse_address(addr), RemailerAddress(user, self.service2))
+		self.app.config['REMAILER_SECRET_KEY'] = 'REMAILER-DEBUGKEY'
+		self.assertIsNone(remailer.parse_address(addr))
+
 class TestUserViews(UffdTestCase):
 	def setUp(self):
 		super().setUp()
diff --git a/uffd/api/models.py b/uffd/api/models.py
index 391a0aed..f770ee1a 100644
--- a/uffd/api/models.py
+++ b/uffd/api/models.py
@@ -17,6 +17,7 @@ class APIClient(db.Model):
 	perm_users = Column(Boolean(), default=False, nullable=False)
 	perm_checkpassword = Column(Boolean(), default=False, nullable=False)
 	perm_mail_aliases = Column(Boolean(), default=False, nullable=False)
+	perm_remailer = Column(Boolean(), default=False, nullable=False)
 
 	@classmethod
 	def permission_exists(cls, name):
diff --git a/uffd/api/views.py b/uffd/api/views.py
index 1f406d24..33e7122a 100644
--- a/uffd/api/views.py
+++ b/uffd/api/views.py
@@ -2,7 +2,7 @@ import functools
 
 from flask import Blueprint, jsonify, request, abort
 
-from uffd.user.models import User, Group
+from uffd.user.models import User, remailer, Group
 from uffd.mail.models import Mail, MailReceiveAddress, MailDestinationAddress
 from uffd.api.models import APIClient
 from uffd.session.views import login_get_user, login_ratelimit
@@ -29,6 +29,7 @@ def apikey_required(permission=None):
 				db.session.commit()
 			if permission is not None and not client.has_permission(permission):
 				return 'Forbidden', 403
+			request.api_client = client
 			return func(*args, **kwargs)
 		return decorator
 	return wrapper
@@ -67,7 +68,7 @@ def generate_user_dict(user):
 	return {
 		'id': user.unix_uid,
 		'loginname': user.loginname,
-		'email': user.mail,
+		'email': user.get_service_mail(request.api_client.service),
 		'displayname': user.displayname,
 		'groups': [group.name for group in user.groups]
 	}
@@ -87,7 +88,7 @@ def getusers():
 	elif key == 'loginname' and len(values) == 1:
 		query = query.filter(User.loginname == values[0])
 	elif key == 'email' and len(values) == 1:
-		query = query.filter(User.mail == values[0])
+		query = query.filter(User.filter_by_service_mail(request.api_client.service, values[0]))
 	elif key == 'group' and len(values) == 1:
 		query = query.join(User.groups).filter(Group.name == values[0])
 	else:
@@ -141,3 +142,16 @@ def getmails():
 	else:
 		abort(400)
 	return jsonify([generate_mail_dict(mail) for mail in mails])
+
+@bp.route('/resolve-remailer', methods=['GET', 'POST'])
+@apikey_required('remailer')
+def resolve_remailer():
+	if list(request.values.keys()) != ['orig_address']:
+		abort(400)
+	values = request.values.getlist('orig_address')
+	if len(values) != 1:
+		abort(400)
+	remailer_address = remailer.parse_address(values[0])
+	if not remailer_address:
+		return jsonify(address=None)
+	return jsonify(address=remailer_address.user.mail)
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 9368ce27..3e25e576 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -36,6 +36,21 @@ MAIL_PASSWORD='*****'
 MAIL_USE_STARTTLS=True
 MAIL_FROM_ADDRESS='foo@bar.com'
 
+# Set to a domain name (e.g. "remailer.example.com") to enable remailer.
+# Requires special mail server configuration (see uffd-socketmapd). Can be
+# enabled/disabled per-service in the service settings. If enabled, services
+# no longer get real user mail addresses but instead special autogenerated
+# addresses that are replaced with the real mail addresses by the mail server.
+REMAILER_DOMAIN = ''
+REMAILER_OLD_DOMAINS = []
+# Secret used for construction and verification of remailer addresses.
+# If None, the value of SECRET_KEY is used.
+REMAILER_SECRET_KEY = None
+# Set to list of user loginnames to limit remailer to a small list of users.
+# Useful for debugging. If None remailer is active for all users (if
+# configured and enabled for a service).
+REMAILER_LIMIT_TO_USERS = None
+
 # Do not enable this on a public service! There is no spam protection implemented at the moment.
 SELF_SIGNUP=False
 
diff --git a/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py b/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py
new file mode 100644
index 00000000..7a401128
--- /dev/null
+++ b/uffd/migrations/versions/704d1245331c_remailer_setting_and_api_permission.py
@@ -0,0 +1,72 @@
+"""remailer setting and api permission
+
+Revision ID: 704d1245331c
+Revises: b9d3f7dac9db
+Create Date: 2022-04-19 17:32:52.304313
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+revision = '704d1245331c'
+down_revision = 'b9d3f7dac9db'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	api_client = sa.Table('api_client', meta,
+    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+    sa.Column('service_id', sa.Integer(), nullable=False),
+    sa.Column('auth_username', sa.String(length=40), nullable=False),
+    sa.Column('auth_password', sa.Text(), nullable=False),
+    sa.Column('perm_users', sa.Boolean(), nullable=False),
+    sa.Column('perm_checkpassword', sa.Boolean(), nullable=False),
+    sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False),
+    sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')),
+    sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username'))
+	)
+	service = sa.Table('service', meta,
+    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+    sa.Column('name', sa.String(length=255), nullable=False),
+    sa.Column('limit_access', sa.Boolean(), nullable=False),
+    sa.Column('access_group_id', sa.Integer(), nullable=True),
+    sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
+    sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
+    sa.UniqueConstraint('name', name=op.f('uq_service_name'))
+	)
+	with op.batch_alter_table('api_client', copy_from=api_client) as batch_op:
+		batch_op.add_column(sa.Column('perm_remailer', sa.Boolean(), nullable=False, default=False))
+	with op.batch_alter_table('service', copy_from=service) as batch_op:
+		batch_op.add_column(sa.Column('use_remailer', sa.Boolean(), nullable=False, default=False))
+
+def downgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	api_client = sa.Table('api_client', meta,
+    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+    sa.Column('service_id', sa.Integer(), nullable=False),
+    sa.Column('auth_username', sa.String(length=40), nullable=False),
+    sa.Column('auth_password', sa.Text(), nullable=False),
+    sa.Column('perm_users', sa.Boolean(), nullable=False),
+    sa.Column('perm_checkpassword', sa.Boolean(), nullable=False),
+    sa.Column('perm_mail_aliases', sa.Boolean(), nullable=False),
+    sa.Column('perm_remailer', sa.Boolean(), nullable=False),
+    sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_api_client_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
+    sa.PrimaryKeyConstraint('id', name=op.f('pk_api_client')),
+    sa.UniqueConstraint('auth_username', name=op.f('uq_api_client_auth_username'))
+	)
+	service = sa.Table('service', meta,
+    sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+    sa.Column('name', sa.String(length=255), nullable=False),
+    sa.Column('limit_access', sa.Boolean(), nullable=False),
+    sa.Column('access_group_id', sa.Integer(), nullable=True),
+    sa.Column('use_remailer', sa.Boolean(), nullable=False),
+    sa.ForeignKeyConstraint(['access_group_id'], ['group.id'], name=op.f('fk_service_access_group_id_group'), onupdate='CASCADE', ondelete='SET NULL'),
+    sa.PrimaryKeyConstraint('id', name=op.f('pk_service')),
+    sa.UniqueConstraint('name', name=op.f('uq_service_name'))
+	)
+	with op.batch_alter_table('service', copy_from=service) as batch_op:
+		batch_op.drop_column('use_remailer')
+	with op.batch_alter_table('api_client', copy_from=api_client) as batch_op:
+		batch_op.drop_column('perm_remailer')
diff --git a/uffd/oauth2/views.py b/uffd/oauth2/views.py
index d13fd42d..e53bf7cc 100644
--- a/uffd/oauth2/views.py
+++ b/uffd/oauth2/views.py
@@ -234,7 +234,7 @@ def userinfo():
 		id=user.unix_uid,
 		name=user.displayname,
 		nickname=user.loginname,
-		email=user.mail,
+		email=user.get_service_mail(request.oauth.client.service),
 		groups=[group.name for group in user.groups]
 	)
 
diff --git a/uffd/service/models.py b/uffd/service/models.py
index 7d3501e1..8584b1b0 100644
--- a/uffd/service/models.py
+++ b/uffd/service/models.py
@@ -23,6 +23,8 @@ class Service(db.Model):
 	oauth2_clients = relationship('OAuth2Client', back_populates='service', cascade='all, delete-orphan')
 	api_clients = relationship('APIClient', back_populates='service', cascade='all, delete-orphan')
 
+	use_remailer = Column(Boolean(), default=False, nullable=False)
+
 	def has_access(self, user):
 		return not self.limit_access or self.access_group in user.groups
 
diff --git a/uffd/service/templates/service/api.html b/uffd/service/templates/service/api.html
index 9e0f7a8b..50f9d7f9 100644
--- a/uffd/service/templates/service/api.html
+++ b/uffd/service/templates/service/api.html
@@ -44,8 +44,21 @@
 				<input class="form-check-input" type="checkbox" id="client-perm-mail-aliases" name="perm_mail_aliases" value="1" aria-label="enabled" {{ 'checked' if client.perm_mail_aliases }}>
 				<label class="form-check-label" for="client-perm-mail-aliases"><b>mail_aliases</b>: {{_('Access mail aliases')}}</label>
 			</div>
+			<div class="form-check">
+				<input class="form-check-input" type="checkbox" id="client-perm-remailer" name="perm_remailer" value="1" aria-label="enabled" {{ 'checked' if client.perm_remailer }}>
+				<label class="form-check-label" for="client-perm-remailer"><b>remailer</b>: {{_('Resolve remailer addresses')}}</label>
+				{% if not remailer.configured %}
+				<i class="fas fa-exclamation-triangle text-warning" data-toggle="tooltip" data-placement="top" title="{{ _('This option has no effect: Remailer config options are unset') }}"></i>
+				{% endif %}
+			</div>
 		</div>
 
 	</form>
 </div>
+
+<script>
+$(function () {
+  $('[data-toggle="tooltip"]').tooltip()
+})
+</script>
 {% endblock %}
diff --git a/uffd/service/templates/service/show.html b/uffd/service/templates/service/show.html
index 0bb892c2..c1f075de 100644
--- a/uffd/service/templates/service/show.html
+++ b/uffd/service/templates/service/show.html
@@ -30,6 +30,15 @@
 				{% endfor %}
 			</select>
 		</div>
+		<div class="form-group col">
+			<div class="form-check">
+				<input class="form-check-input" type="checkbox" id="service-use-remailer" name="use_remailer" value="1" aria-label="enabled" {{ 'checked' if service.use_remailer }}>
+				<label class="form-check-label" for="service-use-remailer">{{_('Hide mail addresses with remailer')}}</label>
+				{% if not remailer.configured %}
+				<i class="fas fa-exclamation-triangle text-warning" data-toggle="tooltip" data-placement="top" title="{{ _('This option has no effect: Remailer config options are unset') }}"></i>
+				{% endif %}
+			</div>
+		</div>
 	</form>
 
 	{% if service.id %}
@@ -85,7 +94,7 @@
 						</a>
 					</td>
 					<td>
-						{% for perm in ['users', 'checkpassword', 'mail_aliases'] if client.has_permission(perm) %}
+						{% for perm in ['users', 'checkpassword', 'mail_aliases', 'remailer'] if client.has_permission(perm) %}
 						{{ perm }}{{ ',' if not loop.last }}
 						{% endfor %}
 					</td>
@@ -98,4 +107,9 @@
 
 </div>
 
+<script>
+$(function () {
+  $('[data-toggle="tooltip"]').tooltip()
+})
+</script>
 {% endblock %}
diff --git a/uffd/service/views.py b/uffd/service/views.py
index cd015833..7411058d 100644
--- a/uffd/service/views.py
+++ b/uffd/service/views.py
@@ -73,6 +73,7 @@ def edit_submit(id=None):
 	else:
 		service.limit_access = True
 		service.access_group = Group.query.get(request.form['access-group'])
+	service.use_remailer = request.form.get('use_remailer') == '1'
 	db.session.commit()
 	return redirect(url_for('service.show', id=service.id))
 
@@ -157,6 +158,7 @@ def api_submit(service_id, id=None):
 	client.perm_users = request.form.get('perm_users') == '1'
 	client.perm_checkpassword = request.form.get('perm_checkpassword') == '1'
 	client.perm_mail_aliases = request.form.get('perm_mail_aliases') == '1'
+	client.perm_remailer = request.form.get('perm_remailer') == '1'
 	db.session.commit()
 	return redirect(url_for('service.show', id=service.id))
 
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index 837caf0baecd3654d28ed65210d9f6a8a1ea2098..b6723ca0244d78a396dc215299589e1d5d40bb87 100644
GIT binary patch
delta 6312
zcmey_%=Dw1ss5f2%Txvi28J+31_l`h28J_SARYqOh%zwnGcYjJiZU<=GcYjpiZU>;
zF)%PJ6lGxWVPIfbA<DqO!@$7sRg{4Nq?Jhw!siuZV9;V<V2~1nh`Wk0Fz_-kFa(G(
zFbFX)FvN+0&8uf96k}kJVqjos6=PsvXJBAhAjZHT0y0>PfkBahfnl#01A{#S1H*kW
z28Kcg1_pU?1_n6>28M~^5Cb=eGcZImFfi;BXJA+kvQUD7!JUDD;gAFaLoEXX1D7NN
zgAxM+Lx&^-gE#{N!)hpfSdxLkjiH`_;kG0LgCqk3gOU^jgBSw?gN+o#$H7tz3>FLw
z49QXuAIy=0_<T8(UI$gTRSM$3qf!hEtPBhctkMh&oD2*M+|m#Sib_Mom8BUN3>g?0
zG^HWtM@us>$ksD3Fl0+JFo-fRFmy^YFi0~nFwB=`U=U<rVAw6qz`()4z;IR?;;?Jd
z;2>joE6u>b$H2hAD8s<O&A`AQAOmrrj0{A*x(vjj1~L!_n9D%Sca?!eO*B+rh71Ek
zJt(M4pc0)>dYTNxqQx={43-QG4BMdweuT>Zkby)Et1Q$)S%|>~vJi`GWf>UM85kIX
zWg$UcEena_$+8R#DGUq@>!9j{<rwP0KGBqe_{c(zfkA<Rfx%slfx()AfgwW<60}R?
zAZg=?90Nlp0|UbwC_hXd;($1Lh>z3d85jf@7#Q;8AwgU(4+)`uc?Je%1_p-N^3V{L
zhoqt1^7RmlZplM}N=yM_po#*7uM4HE6&M&a85kHm6(ANBD?l7vr2q+uP6Y;rtDwZC
zz`#(!z`)R`$iOg>fq~(dBE-TTC5XNiN(>Cj3=9nGl^_nhT(1N%=rL5_lM*DgGAT1K
zY-V6!P*sK~yr~QcftSjV#QISg613bZkf>5tfkc713M2$vR3Huxh052fK+@1;6-YK;
zr2=tK{c#mY5S@c6yrlwh&^r}~&;F_~Fld0Xohk!^3IhX!hbqLNTvbTaw5UQHc0?6o
z;2Bj025klghRdpuRR3R<fq?~-E7Tylf=dk&#Ug4945|za^$ezJ5T8V=LE@xXje)_1
zfq`L?8YB^2SAzuEe>I4K%Ic8RuBi@@H&F*0#1NwnNqkM}3=HxN3=A{WAtALx9pbP{
z>JX3KREK!zsXE9Z^$ZMr8W0!iYe0g~L<8a=M-52OM`}O}&eULF&}Cp?sMla%kY!+C
zSgQdtfPvw(1|-DZXh1^ZH&h+7Cd8qJnh<krpnM-qh(p3P85lT0`9E2cfkB^vfgx8D
z;*hzT3=E+R3=E5)8h&U(QZKU>#DVNkT0{#H*NR$@BGpI>5>l>Okf6@gf`mk|79{E>
zL)Gno>N~B)z)<hVz`$@vi-Dn$fq_9z8&dQx&}Lwm!oa|=RhxlfBLf42mkuOX{DIP}
zx)6)`bRmgPUKf(sjCCPtBNoap(S`VQo-V}Ut8^i0X1gu}LplQk!*^YVdT<FAuLlXb
zY(0p9#d?q+ZP#O9$Y)?+n5M_T(8j>Pz@`t$mc3AVwm!sREA=53Z-R=S)MsF@Wnf^q
zt`CWOQ3FU6$QVFEM#lhRzL`NiB+<DVKuV|-14yk^4^_Cz0Ak@5DF2WFBoUr5fRv1v
zp!#?WA&E-e5E6v}h7gNG4H+0B7#J903?U)0)sTTff`NhIpdlngZq*w?g8aE5#3z3Y
zAueMzf+!F%VqloTz`&qr1d-om1aZ(|BS;inG=k`VWCXGByAdQU{4;_iK0#wh$jBH&
zs%v9oNCDMg3~^ZfPh$p#Nem1OK_(Cbo|-_S;tiDkVgjkP{+KW@v@$R-=$SGwOkiMO
z*kTIFUan@4D2q2^U|7t+z))<)z!1Q|z#wkUz+eL^!p#{N3>X*~mYPG-$Tf3F$bqU>
zcToPfvVa)WWC6)eb1WDbK#6j%1p|Wv0|Uc<3rMPVu!IC{iX|ipGA$w5GT)Mc;Vr2B
zU<t{V#a58qGSLbWLQ}0EA-c>8;=!#}3=DY;3=Dg$7#KVl>KPcctr-}+7#J8ztRX(%
zZw+zzacf8vu-HH>6ok^^HjucKwSkn7nl=yzdfPzKQoaquqCOjl$EMmq9J(GVe$)n%
z3(nXuFhqg!KaVY>1(Ob?58FbbMBa{pA(erFVUryLgBb$@gM>W;Lkt50gTFlkgDV3A
z!y0=?QTxfBf#Db^`#L~;_S6B?vSDBdab#d90o8hr3=GDgc7hWmBwU;zAsp<)zyPX$
z7^XTgFsLywFzj%GB$E413=BpL3=BHX5R2lSAw}#`X9jQs<+L*cLn#9T!*gdyNg3+`
zN&TfRkP>p93j;$Ws4eNjz>vtmz@X*|p*vh5=Dc!cU^oJD5UBkHYJFaHgQV(gcSw*`
zx<i6^vOC1W`R<Uo-|Y^`1qa+Asr#fm0|OHS1H%<}NYq`2()ZmV`Te;&14Az(1H*TB
zNQ<h~6Ove`dqT`v?O6{A(k-5l#3JYgu~^9q;vhXQ1_o15o6rklVUZUkd)0bDd_KVo
zl4w_WK|=7P7bLBG@PfqoH!nze!s87I5p!<_hB#2;)f*BLAL_j!`B%(`fgzcJfkE4c
zfnf~;14F+LBo1wS85oKf7#Ny;A$7zzUr0fv<_F2wj(!Xb!3+!x5q^-6TH^<?_=F$C
zq4)eC9{A`7@qoHNqy#kbhlE(YjX%WaF8&Y;L;WGCehyUON`FX5Z1#s3aL^x`di^1l
z3Qqt7xOr^|r9%QB4lECV1o@-@NN!jX05NAZRDNdw*yHsKR{|iZ@+Fl15dd)^dmto^
z1Op)slnR8jT(kor273fT92yk}Hi#h|%CCXaoq>=LnHC7?4{QpA`1lu8KT{B>V5(<e
z5D9|Bxhs?o41xr0Y!Cy39;jRhf+WhtL68DwZxEz*yd4CoUJHUDiE2VH#3M_B85ndJ
z7#KDOL$cX}U`P}xg+S6qSqP+KQWL_!zy-?xzd|5BVGV`2P$(2)pj0R%4)sDIso60U
zQnCexLK0hOC?vbpgfcLMFfcHz3WbEs|4>L&35G%R$%H}7(+PugXdJ>I_Ed&3Fw}#Z
z%Z*`>rqrY`28LG*3=F%%7#J!+Wp+3N!)j35F9K2roQ{Bm)ZYk5fy5aJaiBva#Nyyc
zNYtf9LUKiZBm=`C1_p)+kr0OjM?vJ1qaY!f8^ur$?)h{_K@!cvC`erIiGmn>HVP7y
z527Fr<c)^VrqQ6dV_=Alh6H&Zl%5+6F=tIQ#K*^@AqCm(Xh=5Xi-9Cs%NR&nii@d-
z)K=9o5Eq__f%x!F45R_^A_n4OwOB|2WDpBUl%}zeRBsmxaZn<Z-x&+ZrfXs$`TR~S
z1A`v}1A}!OBt*L6Ao^FwLG0O59|y^w2jU=!?OPlqv8l#Gd~P2P396iUh{mpXNLra3
z4^h7$9%AA7ct}Y6hthlrkkl^=r7aU6J`PP_V6b9fU?@s}R9^LK6CjD`d;%m6|0X~Z
z4}T&9g9)fRoe1IkCqm?-5+SKRClTVKo<vAcuT6yXqz)!RLh5fKq-14Ef<$F}5+v~!
zB|#iomIQ7I)ibn070gS56sa4NAeG6<B#2KxB|+lse-gx@D#>7t3_-~dgR_$v7{VDC
z80wN480r`p7_KElvTtY#1H(!N28Nsz28II+3=Cqa5RY6>g@ojzRFM9928P$E5DouQ
zAwepd2FYG7X^;j;b{fRRYtkTTWOo{<;lRLfIt^02iljplZD%^9q?-lhFHDDcXj3`^
zLk|N3!>M!zhII@K4B;7&l8`Nvfk6<I|3xw(K2^(vq+a7ph=qQc3=B>T3=FZEkVLp3
z6H?Nx0%>GmVA!7tarnhdNIT#~CM0S^vLFo@lPrjj3$q}Jtp>{P$bvX@aux$aJ*b;K
zD+`i1u4O^u{Bst>LXB()ZIsQxkPE5>vmpkp%7)Zl+p-}cawi)SC2z7J1q*8qC}<fN
zcyl1~3ONi6s~8v<400eoznuf|$jcmtdT?|2Uk)VIi|0aWFT-4j1@5_!M3<WjNdxt{
zkVLg4mw{mxXapn=Qk2fggS3*R@*zIkoDXrxu6&4t59LF0)46;GhM5cu48QXsEv%^p
z^^kn5Q3wfg??On46;%l7c*qt(qM)}3(kZ=A1Zl;J7Bet7GcYjN7c(#ffvV?XNH)Dz
z46*1-F$04)sM%h^z~Bk$+Lb^GqU|M+T=1#{l1tvzmq2{{xrBj1gpq-PvlNp0<;oxm
z-OC^bdzV4fWtKtGN_`mvLpB2gLr)n4gB=3{!;dnEMdsy@IBzUxU<d@smqUX3UO6Py
ze=diF%%5^d$kek`Kzt-n0ZIK<6_A1>vjSpKYXu~qPpW_<vdt9`3+`7ys?}E&kOqoT
zC1hx3X(gnC!C%F|umLpeRs|^uEvq5ziy74n3{{}vylSum>lvbIAZ7EK8U}_>pgvm-
zq;YwwmVqIPfq}uHj)9>A)QYWxBu<rjNScVOhcra8>miAEbv?u*C+Zm(d>I%RKGj1i
zDTfA#L(3Z=<;VU8NQk~|0HuX`1_tg%h>w>xLh9>-jSz#Fn;<^ZZGzN>3!r?dW{8Eo
z%?u2mLDg<EWT<9d3&deTt&nV-(h3Q|`c_E4pr;iQ=c`*GLB9b+tActWP>B<*kT^Qq
z3W=j@t&m)CuXXY&k%gKHrNya53gww4844vCnZ*h@`RSQ?3VDgSsS1fXMX8A?l?tg9
znZ+f=n>|I>urlUs{w6hxkv%i7EHNiDWplmEcP2%T%#>7x+{DZrg~XJUqSWHz)MAK%
zMX4as)S}I$@*kM_6^b*{^GXX8ic$+pQ;SO`$0|2X-mA<hlwOpWSE7)ZSE-PfTCPx(
zpOZRyy|TC(LKq}e%;1?<mRXXjkdv90t&mx)0CT}5WiBQz1b^}c)%8M9XCN!9RO9Cm
z&(A4KRe(7P;n>YBdSxu^!KKN`sl~;ce;Va6S%X3%zn~;DKTjbeu~;E5Um-OuEj78s
zN+Ag9CxztvytK@81&FF*g~Xy%h0?s@)RN7qrWGukuUp#i@S5lv87df>SQ(mY8yIa?
zaJ$Z^?3$UU;Fz48Uz%5<kegYekdm2NoSLiPn3tQHladMw_Pog#gM~MLcX#CAEX_+l
zyeFq5Gkvpqz%^#|u+*aBlGNnvR0ZF}%$!6>Q0GC6NzH}3Z1bPsR+h<;5%*)tQ!|T8
z6tYt@^HLS^QcF|w6oT?|a#HgYs!ADLGxKs1Q%dvFi@_m^tQ4mrkZCYCZB~z5!z791
zZUto7^vRc^e{bFxbDLAvu{15`@V4UAJcY9SJh&ru9l_4u+?h6!mD{x_Ek7r{C^a)V
zW3yz=0VWq0P^c*Q!y*9Y2glOl^30;_(!6w}cmP?OmzkVVqL2-ak<y~f)S?oGsL2~k
bB_{KgDow60<Jv5j|D1*09U7P{C0}^~)pWdK

delta 5474
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

diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index cedf033d..9f4f419d 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-03-20 13:07+0100\n"
+"POT-Creation-Date: 2022-04-21 14:17+0200\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -114,8 +114,8 @@ msgstr "Mailversand fehlgeschlagen"
 #: 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:41
-#: uffd/service/templates/service/show.html:69
+#: 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
 msgid "New"
 msgstr "Neu"
@@ -130,7 +130,7 @@ msgstr "Erstellt durch"
 
 #: uffd/invite/templates/invite/list.html:14
 #: uffd/service/templates/service/api.html:34
-#: uffd/service/templates/service/show.html:76
+#: uffd/service/templates/service/show.html:85
 msgid "Permissions"
 msgstr "Berechtigungen"
 
@@ -292,7 +292,7 @@ msgstr "Enthaltene Rollen"
 #: 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:75
+#: 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
@@ -1054,7 +1054,7 @@ msgstr "Passwort vergessen"
 
 #: uffd/selfservice/templates/selfservice/forgot_password.html:9
 #: uffd/selfservice/templates/selfservice/self.html:21
-#: uffd/session/templates/session/login.html:9
+#: uffd/session/templates/session/login.html:12
 #: uffd/signup/templates/signup/start.html:14
 #: uffd/user/templates/user/list.html:18 uffd/user/templates/user/show.html:47
 msgid "Login Name"
@@ -1118,7 +1118,7 @@ msgid "Update Profile"
 msgstr "Änderungen speichern"
 
 #: uffd/selfservice/templates/selfservice/self.html:44
-#: uffd/session/templates/session/login.html:13
+#: uffd/session/templates/session/login.html:16
 #: uffd/signup/templates/signup/start.html:41
 #: uffd/user/templates/user/show.html:76
 msgid "Password"
@@ -1235,8 +1235,17 @@ msgstr "Passwörter von Nutzeraccounts verifizieren"
 msgid "Access mail aliases"
 msgstr "Zugriff auf Mail-Weiterleitungen"
 
+#: uffd/service/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
+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:47
+#: uffd/service/templates/service/show.html:56
 msgid "Client ID"
 msgstr "Client-ID"
 
@@ -1262,7 +1271,7 @@ msgstr ""
 "Eine URI pro Zeile, vorangestellt die mit Leerzeichen getrennte HTTP-"
 "Methode (GET/POST)"
 
-#: uffd/service/templates/service/overview.html:8
+#: uffd/service/templates/service/overview.html:11
 msgid ""
 "Some services may not be publicly listed! Log in to see all services you "
 "have access to."
@@ -1270,15 +1279,21 @@ 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:44
+#: 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:55
 msgid "No access"
 msgstr "Kein Zugriff"
 
-#: uffd/service/templates/service/overview.html:64
+#: uffd/service/templates/service/overview.html:75
 msgid "Manage OAuth2 and API clients"
 msgstr "OAuth2- und API-Clients verwalten"
 
-#: uffd/service/templates/service/overview.html:84
+#: uffd/service/templates/service/overview.html:95
 #: uffd/user/templates/user/list.html:58 uffd/user/templates/user/list.html:79
 msgid "Close"
 msgstr "Schließen"
@@ -1300,6 +1315,10 @@ msgstr "Alle Account haben Zugriff (veraltet)"
 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
+msgid "Hide mail addresses with remailer"
+msgstr "Verstecke Mailadressen mit dem Remailer"
+
 #: uffd/session/views.py:71
 #, python-format
 msgid ""
@@ -1414,24 +1433,19 @@ 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:6
-#: uffd/session/templates/session/login.html:17
-msgid "Login"
-msgstr "Anmelden"
-
-#: uffd/session/templates/session/login.html:20
+#: uffd/session/templates/session/login.html:23
 msgid "- or -"
 msgstr "- oder -"
 
-#: uffd/session/templates/session/login.html:22
+#: uffd/session/templates/session/login.html:25
 msgid "Login with another device"
 msgstr "Über anderes Gerät anmelden"
 
-#: uffd/session/templates/session/login.html:27
+#: uffd/session/templates/session/login.html:30
 msgid "Register"
 msgstr "Registrieren"
 
-#: uffd/session/templates/session/login.html:29
+#: uffd/session/templates/session/login.html:32
 msgid "Forgot Password?"
 msgstr "Passwort vergessen?"
 
@@ -1569,7 +1583,7 @@ msgstr "Du bist nicht berechtigt, auf diese Seite zuzugreifen."
 msgid "Change"
 msgstr "Ändern"
 
-#: uffd/templates/base.html:135
+#: uffd/templates/base.html:142
 msgid "About uffd"
 msgstr "Über uffd"
 
diff --git a/uffd/user/models.py b/uffd/user/models.py
index 900ce743..8ff69508 100644
--- a/uffd/user/models.py
+++ b/uffd/user/models.py
@@ -2,6 +2,7 @@ import string
 import re
 
 from flask import current_app, escape
+import itsdangerous
 from flask_babel import lazy_gettext
 from sqlalchemy import Column, Integer, String, ForeignKey, Boolean, Text
 from sqlalchemy.orm import relationship
@@ -97,13 +98,99 @@ class User(db.Model):
 	def set_mail(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
 		return True
 
+	def get_service_mail(self, service):
+		if not remailer.configured or not service.use_remailer:
+			return self.mail
+		if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None and \
+				self.loginname not in current_app.config['REMAILER_LIMIT_TO_USERS']:
+			return self.mail
+		return remailer.build_address(self, service)
+
+	@classmethod
+	def filter_by_service_mail(cls, service, address):
+		if not remailer.configured or not service.use_remailer:
+			return cls.mail == address
+		remailer_address = remailer.parse_address(address)
+		if remailer_address and remailer_address.service == service and \
+				remailer_address.user.get_service_mail(service) == address:
+			return cls.id == remailer_address.user.id
+		if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None:
+			return db.and_(
+				db.not_(cls.loginname.in_(current_app.config['REMAILER_LIMIT_TO_USERS'])),
+				cls.mail == address
+			)
+		return False
+
 	# Somehow pylint non-deterministically fails to detect that .update_groups is set in invite.modes
 	def update_groups(self):
 		pass
 
+class RemailerAddress:
+	def __init__(self, user, service):
+		self.user = user
+		self.service = service
+
+	def __eq__(self, remailer_address):
+		return remailer_address is not None and self.user == remailer_address.user and self.service == remailer_address.service
+
+class Remailer:
+	'''The remailer feature improves user privacy by hiding real mail addresses
+	from services and instead providing them with autogenerated pseudonymous
+	remailer addresses. If a service sends a mail to a remailer address, the mail
+	service uses an uffd API endpoint to get the real mail address and rewrites
+	the remailer address with it. In case of a leak of user data from a service,
+	the remailer addresses are useless for third-parties.'''
+
+	# pylint: disable=no-self-use
+
+	@property
+	def configured(self):
+		return bool(current_app.config['REMAILER_DOMAIN'])
+
+	def get_serializer(self):
+		secret = current_app.config['REMAILER_SECRET_KEY'] or current_app.secret_key
+		return itsdangerous.URLSafeSerializer(secret, salt='remailer_address_v1')
+
+	def build_local_part(self, user, service):
+		return 'v1-' + self.get_serializer().dumps([service.id, user.id])
+
+	def build_address(self, user, service):
+		return self.build_local_part(user, service) + '@' + current_app.config['REMAILER_DOMAIN']
+
+	def is_remailer_domain(self, domain):
+		domains = {domain.lower().strip() for domain in current_app.config['REMAILER_OLD_DOMAINS']}
+		if current_app.config['REMAILER_DOMAIN']:
+			domains.add(current_app.config['REMAILER_DOMAIN'].lower().strip())
+		return domain.lower().strip() in domains
+
+	def parse_address(self, address):
+		# With a top-level import we get circular import problems
+		# pylint: disable=import-outside-toplevel
+		from uffd.service.models import Service
+		if '@' not in address:
+			return None
+		local_part, domain = address.rsplit('@', 1)
+		if not self.is_remailer_domain(domain) or not local_part.startswith('v1-'):
+			return None
+		data = local_part[len('v1-'):]
+		try:
+			service_id, user_id = self.get_serializer().loads(data)
+		except itsdangerous.BadData:
+			return None
+		service = Service.query.get(service_id)
+		user = User.query.get(user_id)
+		if not service or not user:
+			return None
+		return RemailerAddress(user, service)
+
+remailer = Remailer()
+
 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
 	# db.func.min(..., max_value): clip to range
diff --git a/uffd/user/views_user.py b/uffd/user/views_user.py
index 95f8643e..d9a8800b 100644
--- a/uffd/user/views_user.py
+++ b/uffd/user/views_user.py
@@ -12,11 +12,12 @@ from uffd.session import login_required
 from uffd.role.models import Role
 from uffd.database import db
 
-from .models import User
+from .models import User, remailer
 
 bp = Blueprint("user", __name__, template_folder='templates', url_prefix='/user/')
 
 bp.add_app_template_global(User, 'User')
+bp.add_app_template_global(remailer, 'remailer')
 
 def user_acl_check():
 	return request.user and request.user.is_in_group(current_app.config['ACL_ADMIN_GROUP'])
-- 
GitLab