From 05f68ec88101cf184b23c3c99e4298a3e922de82 Mon Sep 17 00:00:00 2001
From: Julian Rother <julian@cccv.de>
Date: Sun, 6 Nov 2022 02:10:23 +0100
Subject: [PATCH] Add per-service setting for testing remailer

This setting is more flexible than the existing REMAILER_LIMIT_TO_USERS config
option. The config option is therefore deprecated and will be removed in the
next major version.
---
 tests/models/test_services.py                 | 186 ++++++--------
 tests/views/test_services.py                  | 232 ++++++++++++++++++
 uffd/default_config.cfg                       |   3 +-
 .../e249233e2a31_remailer_mode_overwrite.py   |  44 ++++
 uffd/models/service.py                        |  46 ++--
 uffd/templates/service/show.html              |  26 +-
 uffd/translations/de/LC_MESSAGES/messages.mo  | Bin 40147 -> 40588 bytes
 uffd/translations/de/LC_MESSAGES/messages.po  |  40 ++-
 uffd/views/service.py                         |  31 ++-
 9 files changed, 461 insertions(+), 147 deletions(-)
 create mode 100644 uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py

diff --git a/tests/models/test_services.py b/tests/models/test_services.py
index 82808db4..d6836b5f 100644
--- a/tests/models/test_services.py
+++ b/tests/models/test_services.py
@@ -1,3 +1,5 @@
+import itertools
+
 from uffd.remailer import remailer
 from uffd.tasks import cleanup_task
 from uffd.database import db
@@ -39,6 +41,24 @@ class TestServiceUser(UffdTestCase):
 		db.session.commit()
 		self.assertEqual(ServiceUser.query.count(), service_count  * user_count)
 
+	def test_effective_remailer_mode(self):
+		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
+		user = self.get_user()
+		service = Service.query.filter_by(name='service1').first()
+		service.remailer_mode = RemailerMode.ENABLED_V2
+		service_user = ServiceUser.query.get((service.id, user.id))
+		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.ENABLED_V2)
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testadmin']
+		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.DISABLED)
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser']
+		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.ENABLED_V2)
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = None
+		service_user.remailer_overwrite_mode = RemailerMode.ENABLED_V1
+		service.remailer_mode = RemailerMode.DISABLED
+		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.ENABLED_V1)
+		self.app.config['REMAILER_DOMAIN'] = ''
+		self.assertEqual(service_user.effective_remailer_mode, RemailerMode.DISABLED)
+
 	def test_service_email(self):
 		user = self.get_user()
 		service = Service.query.filter_by(name='service1').first()
@@ -99,119 +119,59 @@ class TestServiceUser(UffdTestCase):
 		# 5. remailer setup + remailer enabled + REMAILER_LIMIT_TO_USERS includes user
 		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser']
 		self.assertEqual(service_user.email, remailer_email)
+		# 6. remailer setup + remailer disabled + user overwrite
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = None
+		service.remailer_mode = RemailerMode.DISABLED
+		service_user.remailer_overwrite_mode = RemailerMode.ENABLED_V1
+		self.assertEqual(service_user.email, remailer_email)
+		# 7. remailer setup + remailer enabled + user overwrite
+		self.app.config['REMAILER_LIMIT_TO_USERS'] = None
+		service.remailer_mode = RemailerMode.ENABLED_V1
+		service_user.remailer_overwrite_mode = RemailerMode.DISABLED
+		self.assertEqual(service_user.email, user.primary_email.address)
 
 	def test_filter_query_by_email(self):
-		def run_query(value):
-			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', 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() # remailer disabled
-		service2 = Service.query.filter_by(name='service2').first() # remailer enabled
-		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		remailer_email1_1 = remailer.build_v1_address(service1.id, user1.id)
-		remailer_email2_1 = remailer.build_v1_address(service2.id, user1.id)
-		remailer_email1_2 = remailer.build_v1_address(service1.id, user2.id)
-		remailer_email2_2 = remailer.build_v1_address(service2.id, user2.id)
-
-		# 1. remailer disabled
-		self.app.config['REMAILER_DOMAIN'] = ''
-		self.assertEqual(run_query(user1.primary_email.address), {
-			(service1.id, user1.id), (service1.id, user2.id),
-			(service2.id, user1.id), (service2.id, user2.id),
-		})
-		self.assertEqual(run_query(remailer_email1_1), set())
-		self.assertEqual(run_query(remailer_email2_1), set())
-		self.assertEqual(run_query('invalid'), set())
-
-		# 2. remailer enabled + REMAILER_LIMIT_TO_USERS unset
-		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		self.assertEqual(run_query(user1.primary_email.address), {
-			(service1.id, user1.id), (service1.id, user2.id),
-		})
-		self.assertEqual(run_query(remailer_email1_1), set())
-		self.assertEqual(run_query(remailer_email2_1), {
-			(service2.id, user1.id),
-		})
-		self.assertEqual(run_query(remailer_email2_1 + ' '), set())
-		self.assertEqual(run_query('invalid'), set())
-
-		# 3. remailer enabled + REMAILER_LIMIT_TO_USERS includes testuser
-		self.app.config['REMAILER_LIMIT_TO_USERS'] = ['testuser']
-		self.assertEqual(run_query(user1.primary_email.address), {
-			(service1.id, user1.id), (service1.id, user2.id),
-			(service2.id, user2.id),
-		})
-		self.assertEqual(run_query(remailer_email1_1), set())
-		self.assertEqual(run_query(remailer_email2_1), {
-			(service2.id, user1.id),
-		})
-		self.assertEqual(run_query(remailer_email2_1 + ' '), set())
-		self.assertEqual(run_query(remailer_email1_2), set())
-		self.assertEqual(run_query(remailer_email2_2), set())
-		self.assertEqual(run_query('invalid'), set())
-
-		# 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.primary_email.address), {
-			(service1.id, user1.id), (service1.id, user2.id),
-			(service2.id, user1.id), (service2.id, user2.id),
-		})
-		self.assertEqual(run_query(remailer_email1_1), set())
-		self.assertEqual(run_query(remailer_email2_1), set())
-		self.assertEqual(run_query('invalid'), set())
-
-	def test_filter_query_by_email_prefs(self):
-		def run_query(value):
-			return {(su.service_id, su.user_id) for su in ServiceUser.filter_query_by_email(ServiceUser.query, value)}
-
-		user1 = self.get_user()
-		service1 = Service.query.filter_by(name='service1').first() # remailer disabled
-		service2 = Service.query.filter_by(name='service2').first() # remailer enabled
-		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		remailer_email1_1 = remailer.build_v1_address(service1.id, user1.id)
-		remailer_email2_1 = remailer.build_v1_address(service2.id, user1.id)
-
-		self.app.config['REMAILER_DOMAIN'] = ''
-		self.assertEqual(run_query(user1.primary_email.address), {
-			(service1.id, user1.id),
-			(service2.id, user1.id),
-		})
-		self.assertEqual(run_query('addr1-1@example.com'), set())
-		self.assertEqual(run_query('addr2-1@example.com'), set())
-		self.assertEqual(run_query(remailer_email1_1), set())
-		self.assertEqual(run_query(remailer_email2_1), set())
-
-		ServiceUser.query.get((service1.id, user1.id)).service_email = UserEmail(user=user1, verified=True, address='addr1-1@example.com')
-		ServiceUser.query.get((service2.id, user1.id)).service_email = UserEmail(user=user1, verified=True, address='addr2-1@example.com')
-		self.assertEqual(run_query(user1.primary_email.address), {
-			(service1.id, user1.id),
-			(service2.id, user1.id),
-		})
-		self.assertEqual(run_query('addr1-1@example.com'), set())
-		self.assertEqual(run_query('addr2-1@example.com'), set())
-		self.assertEqual(run_query(remailer_email1_1), set())
-		self.assertEqual(run_query(remailer_email2_1), set())
-		service1.enable_email_preferences = True
-		service2.enable_email_preferences = True
-		self.assertEqual(run_query(user1.primary_email.address), set())
-		self.assertEqual(run_query('addr1-1@example.com'), {
-			(service1.id, user1.id),
-		})
-		self.assertEqual(run_query('addr2-1@example.com'), {
-			(service2.id, user1.id),
-		})
-		self.assertEqual(run_query(remailer_email1_1), set())
-		self.assertEqual(run_query(remailer_email2_1), set())
+		service = Service.query.filter_by(name='service1').first()
+		user = self.get_user()
 		self.app.config['REMAILER_DOMAIN'] = 'remailer.example.com'
-		self.assertEqual(run_query(user1.primary_email.address), set())
-		self.assertEqual(run_query('addr1-1@example.com'), {
-			(service1.id, user1.id),
-		})
-		self.assertEqual(run_query('addr2-1@example.com'), set())
-		self.assertEqual(run_query(remailer_email1_1), set())
-		self.assertEqual(run_query(remailer_email2_1), {
-			(service2.id, user1.id),
-		})
+		remailer_email_v1 = remailer.build_v1_address(service.id, user.id)
+		remailer_email_v2 = remailer.build_v2_address(service.id, user.id)
+		email1 = user.primary_email
+		email2 = UserEmail(user=user, address='test2@example.com', verified=True)
+		db.session.add(email2)
+		service_user = ServiceUser.query.get((service.id, user.id))
+		all_service_users = ServiceUser.query.all()
+		cases = itertools.product(
+			# Input values
+			[
+				'test@example.com',
+				'test2@example.com',
+				'other@example.com',
+				remailer_email_v1,
+				remailer_email_v2,
+			],
+			# REMAILER_DOMAIN config
+			[None, 'remailer.example.com'],
+			# REMAILER_LIMIT config
+			[None, ['testuser', 'otheruser'], ['testadmin', 'otheruser']],
+			# service.remailer_mode
+			[RemailerMode.DISABLED, RemailerMode.ENABLED_V1, RemailerMode.ENABLED_V2],
+			# service.enable_email_preferences
+			[True, False],
+			# service_user.service_email
+			[None, email1, email2],
+			# service_user.remailer_overwrite_mode
+			[None, RemailerMode.DISABLED, RemailerMode.ENABLED_V1, RemailerMode.ENABLED_V2],
+		)
+		for options in cases:
+			value = options[0]
+			self.app.config['REMAILER_DOMAIN'] = options[1]
+			self.app.config['REMAILER_LIMIT_TO_USERS'] = options[2]
+			service.remailer_mode = options[3]
+			service.enable_email_preferences = options[4]
+			service_user.service_email = options[5]
+			service_user.remailer_overwrite_mode = options[6]
+			a = {result for result in all_service_users if result.email == value}
+			b = set(ServiceUser.filter_query_by_email(ServiceUser.query, value).all())
+			if a != b:
+				self.fail(f'{a} != {b} with ' + repr(options))
diff --git a/tests/views/test_services.py b/tests/views/test_services.py
index 1e57d8be..a40e447f 100644
--- a/tests/views/test_services.py
+++ b/tests/views/test_services.py
@@ -1,5 +1,7 @@
 from flask import url_for
 
+from uffd.database import db
+from uffd.models import Service, ServiceUser, OAuth2Client, APIClient, RemailerMode
 from tests.utils import dump, UffdTestCase
 
 class TestServices(UffdTestCase):
@@ -96,3 +98,233 @@ class TestServices(UffdTestCase):
 		r = self.client.get(path=url_for('service.overview'), follow_redirects=True)
 		dump('service_overview_public_admin', r)
 		self.assertEqual(r.status_code, 200)
+
+class TestServiceAdminViews(UffdTestCase):
+	def setUpDB(self):
+		db.session.add(Service(
+			name='test1',
+			oauth2_clients=[OAuth2Client(client_id='test1_oauth2_client1', client_secret='test'), OAuth2Client(client_id='test1_oauth2_client2', client_secret='test')],
+			api_clients=[APIClient(auth_username='test1_api_client1', auth_password='test'), APIClient(auth_username='test1_api_client2', auth_password='test')],
+		))
+		db.session.add(Service(name='test2'))
+		db.session.add(Service(name='test3'))
+		db.session.commit()
+		self.service_id = Service.query.filter_by(name='test1').one().id
+
+	def test_index(self):
+		self.login_as('admin')
+		r = self.client.get(path=url_for('service.index'), follow_redirects=True)
+		dump('service_index', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_show(self):
+		self.login_as('admin')
+		r = self.client.get(path=url_for('service.show', id=self.service_id), follow_redirects=True)
+		dump('service_show', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_new(self):
+		self.login_as('admin')
+		r = self.client.get(path=url_for('service.show'), follow_redirects=True)
+		dump('service_new', r)
+		self.assertEqual(r.status_code, 200)
+
+	def test_new_submit(self):
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit'),
+			follow_redirects=True,
+			data={
+				'name': 'new-service',
+				'access-group': '',
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': '',
+			},
+		)
+		dump('service_new_submit', r)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.filter_by(name='new-service').one_or_none()
+		self.assertIsNotNone(service)
+		self.assertEqual(service.limit_access, True)
+		self.assertEqual(service.access_group, None)
+		self.assertEqual(service.remailer_mode, RemailerMode.DISABLED)
+		self.assertEqual(service.enable_email_preferences, False)
+
+	def test_edit(self):
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'new-name',
+				'access-group': '',
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': '',
+			},
+		)
+		dump('service_edit_submit', r)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.get(self.service_id)
+		self.assertEqual(service.name, 'new-name')
+		self.assertEqual(service.limit_access, True)
+		self.assertEqual(service.access_group, None)
+		self.assertEqual(service.remailer_mode, RemailerMode.DISABLED)
+		self.assertEqual(service.enable_email_preferences, False)
+
+	def test_edit_access_all(self):
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'test1',
+				'access-group': 'all',
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': '',
+			},
+		)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.get(self.service_id)
+		self.assertEqual(service.limit_access, False)
+		self.assertEqual(service.access_group, None)
+
+	def test_edit_access_group(self):
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'test1',
+				'access-group': str(self.get_users_group().id),
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': '',
+			},
+		)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.get(self.service_id)
+		self.assertEqual(service.limit_access, True)
+		self.assertEqual(service.access_group, self.get_users_group())
+
+	def test_edit_email_preferences(self):
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'test1',
+				'access-group': '',
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': '',
+				'enable_email_preferences': '1',
+			},
+		)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.get(self.service_id)
+		self.assertEqual(service.enable_email_preferences, True)
+
+	def test_edit_remailer_mode(self):
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'test1',
+				'access-group': '',
+				'remailer-mode': 'ENABLED_V2',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': '',
+			},
+		)
+		self.assertEqual(r.status_code, 200)
+		service = Service.query.get(self.service_id)
+		self.assertEqual(service.remailer_mode, RemailerMode.ENABLED_V2)
+
+	def test_edit_remailer_overwrite_enable(self):
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'test1',
+				'access-group': '',
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': 'testuser, testadmin',
+			},
+		)
+		self.assertEqual(r.status_code, 200)
+		service_user1 = ServiceUser.query.get((self.service_id, self.get_user().id))
+		service_user2 = ServiceUser.query.get((self.service_id, self.get_admin().id))
+		self.assertEqual(service_user1.remailer_overwrite_mode, RemailerMode.ENABLED_V2)
+		self.assertEqual(service_user2.remailer_overwrite_mode, RemailerMode.ENABLED_V2)
+		self.assertEqual(
+			set(ServiceUser.query.filter(
+				ServiceUser.service_id == self.service_id,
+				ServiceUser.remailer_overwrite_mode != None
+			).all()),
+			{service_user1, service_user2}
+		)
+
+	def test_edit_remailer_overwrite_change(self):
+		service_user = ServiceUser.query.get((self.service_id, self.get_user().id))
+		service_user.remailer_overwrite_mode = RemailerMode.ENABLED_V2
+		db.session.commit()
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'test1',
+				'access-group': '',
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V1',
+				'remailer-overwrite-users': ', testadmin',
+			},
+		)
+		self.assertEqual(r.status_code, 200)
+		service_user = ServiceUser.query.get((self.service_id, self.get_admin().id))
+		self.assertEqual(service_user.remailer_overwrite_mode, RemailerMode.ENABLED_V1)
+		self.assertEqual(
+			ServiceUser.query.filter(
+				ServiceUser.service_id == self.service_id,
+				ServiceUser.remailer_overwrite_mode != None
+			).all(),
+			[service_user]
+		)
+
+	def test_edit_remailer_overwrite_disable(self):
+		service_user = ServiceUser.query.get((self.service_id, self.get_user().id))
+		service_user.remailer_overwrite_mode = RemailerMode.ENABLED_V2
+		db.session.commit()
+		self.login_as('admin')
+		r = self.client.post(
+			path=url_for('service.edit_submit', id=self.service_id),
+			follow_redirects=True,
+			data={
+				'name': 'test1',
+				'access-group': '',
+				'remailer-mode': 'DISABLED',
+				'remailer-overwrite-mode': 'ENABLED_V2',
+				'remailer-overwrite-users': '',
+			},
+		)
+		self.assertEqual(r.status_code, 200)
+		self.assertEqual(
+			ServiceUser.query.filter(
+				ServiceUser.service_id == self.service_id,
+				ServiceUser.remailer_overwrite_mode != None
+			).all(),
+			[]
+		)
+
+	def test_delete(self):
+		self.login_as('admin')
+		r = self.client.get(path=url_for('service.delete', id=self.service_id), follow_redirects=True)
+		dump('service_delete', r)
+		self.assertEqual(r.status_code, 200)
+		self.assertIsNone(Service.query.get(self.service_id))
diff --git a/uffd/default_config.cfg b/uffd/default_config.cfg
index 7b01157f..3ac6ee21 100644
--- a/uffd/default_config.cfg
+++ b/uffd/default_config.cfg
@@ -53,7 +53,8 @@ REMAILER_OLD_DOMAINS = []
 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).
+# configured and enabled for a service). This option is deprecated. Use the
+# per-service setting in the web interface instead.
 REMAILER_LIMIT_TO_USERS = None
 
 # Do not enable this on a public service! There is no spam protection implemented at the moment.
diff --git a/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py b/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py
new file mode 100644
index 00000000..10c617ee
--- /dev/null
+++ b/uffd/migrations/versions/e249233e2a31_remailer_mode_overwrite.py
@@ -0,0 +1,44 @@
+"""Remailer mode overwrite
+
+Revision ID: e249233e2a31
+Revises: aeb07202a6c8
+Create Date: 2022-11-05 03:42:38.036623
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+# revision identifiers, used by Alembic.
+revision = 'e249233e2a31'
+down_revision = 'aeb07202a6c8'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	service_user = sa.Table('service_user', meta,
+		sa.Column('service_id', sa.Integer(), nullable=False),
+		sa.Column('user_id', sa.Integer(), nullable=False),
+		sa.Column('service_email_id', sa.Integer(), nullable=True),
+		sa.ForeignKeyConstraint(['service_email_id'], ['user_email.id'], name=op.f('fk_service_user_service_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_service_user_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_service_user_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('service_id', 'user_id', name=op.f('pk_service_user'))
+	)
+	with op.batch_alter_table('service_user', copy_from=service_user) as batch_op:
+		batch_op.add_column(sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=True))
+
+def downgrade():
+	meta = sa.MetaData(bind=op.get_bind())
+	service_user = sa.Table('service_user', meta,
+		sa.Column('service_id', sa.Integer(), nullable=False),
+		sa.Column('user_id', sa.Integer(), nullable=False),
+		sa.Column('remailer_overwrite_mode', sa.Enum('DISABLED', 'ENABLED_V1', 'ENABLED_V2', name='remailermode'), nullable=True),
+		sa.Column('service_email_id', sa.Integer(), nullable=True),
+		sa.ForeignKeyConstraint(['service_email_id'], ['user_email.id'], name=op.f('fk_service_user_service_email_id_user_email'), onupdate='CASCADE', ondelete='SET NULL'),
+		sa.ForeignKeyConstraint(['service_id'], ['service.id'], name=op.f('fk_service_user_service_id_service'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.ForeignKeyConstraint(['user_id'], ['user.id'], name=op.f('fk_service_user_user_id_user'), onupdate='CASCADE', ondelete='CASCADE'),
+		sa.PrimaryKeyConstraint('service_id', 'user_id', name=op.f('pk_service_user'))
+	)
+	with op.batch_alter_table('service_user', copy_from=service_user) as batch_op:
+		batch_op.drop_column('remailer_overwrite_mode')
diff --git a/uffd/models/service.py b/uffd/models/service.py
index c5e5cbcc..2143e674 100644
--- a/uffd/models/service.py
+++ b/uffd/models/service.py
@@ -58,6 +58,19 @@ class ServiceUser(db.Model):
 	def has_access(self):
 		return not self.service.limit_access or self.service.access_group in self.user.groups
 
+	remailer_overwrite_mode = Column(Enum(RemailerMode), default=None, nullable=True)
+
+	@property
+	def effective_remailer_mode(self):
+		if not remailer.configured:
+			return RemailerMode.DISABLED
+		if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None:
+			if self.user.loginname not in current_app.config['REMAILER_LIMIT_TO_USERS']:
+				return RemailerMode.DISABLED
+		if self.remailer_overwrite_mode is not None:
+			return self.remailer_overwrite_mode
+		return self.service.remailer_mode
+
 	service_email_id = Column(Integer(), ForeignKey('user_email.id', onupdate='CASCADE', ondelete='SET NULL'))
 	service_email = relationship('UserEmail')
 
@@ -92,13 +105,9 @@ class ServiceUser(db.Model):
 	# E-Mail address as seen by the service
 	@property
 	def email(self):
-		if current_app.config['REMAILER_LIMIT_TO_USERS'] is None:
-			use_remailer = remailer.configured
-		else:
-			use_remailer = remailer.configured and self.user.loginname in current_app.config['REMAILER_LIMIT_TO_USERS']
-		if use_remailer and self.service.remailer_mode == RemailerMode.ENABLED_V1:
+		if self.effective_remailer_mode == RemailerMode.ENABLED_V1:
 			return remailer.build_v1_address(self.service_id, self.user_id)
-		if use_remailer and self.service.remailer_mode == RemailerMode.ENABLED_V2:
+		if self.effective_remailer_mode == RemailerMode.ENABLED_V2:
 			return remailer.build_v2_address(self.service_id, self.user_id)
 		return self.real_email
 
@@ -106,7 +115,7 @@ class ServiceUser(db.Model):
 	def filter_query_by_email(cls, query, email):
 		'''Filter query of ServiceUser by ServiceUser.email'''
 		# pylint completely fails to understand SQLAlchemy's query functions
-		# pylint: disable=no-member,invalid-name
+		# pylint: disable=no-member,invalid-name,singleton-comparison
 		service_user = cls.get_by_remailer_email(email)
 		if service_user and service_user.email == email:
 			return query.filter(cls.user_id == service_user.user_id, cls.service_id == service_user.service_id)
@@ -121,23 +130,26 @@ class ServiceUser(db.Model):
 		query = query.outerjoin(cls.service_email.of_type(AliasedServiceEmail))
 		query = query.join(cls.service.of_type(AliasedService))
 
-		remailer_enabled_expr = db.and_(
-			AliasedService.remailer_mode != RemailerMode.DISABLED,
-			remailer.configured
+		remailer_enabled = db.case(
+			whens=[
+				(db.not_(remailer.configured), False),
+				(
+					db.not_(AliasedUser.loginname.in_(current_app.config['REMAILER_LIMIT_TO_USERS']))
+						if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None else db.and_(False),
+					False
+				),
+				(cls.remailer_overwrite_mode != None, cls.remailer_overwrite_mode != RemailerMode.DISABLED)
+			],
+			else_=(AliasedService.remailer_mode != RemailerMode.DISABLED)
 		)
-		if current_app.config['REMAILER_LIMIT_TO_USERS'] is not None:
-			remailer_enabled_expr = db.and_(
-				remailer_enabled_expr,
-				AliasedUser.loginname.in_(current_app.config['REMAILER_LIMIT_TO_USERS']),
-			)
-		real_email_matches_expr = db.case(
+		real_email_matches = db.case(
 			whens=[
 				# pylint: disable=singleton-comparison
 				(db.and_(AliasedService.enable_email_preferences, cls.service_email != None), AliasedServiceEmail.address == email),
 			],
 			else_=(AliasedPrimaryEmail.address == email)
 		)
-		return query.filter(db.and_(db.not_(remailer_enabled_expr), real_email_matches_expr))
+		return query.filter(db.and_(db.not_(remailer_enabled), real_email_matches))
 
 @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/templates/service/show.html b/uffd/templates/service/show.html
index 6c6fca94..5277fa41 100644
--- a/uffd/templates/service/show.html
+++ b/uffd/templates/service/show.html
@@ -21,7 +21,7 @@
 			<input type="text" class="form-control" id="service-name" name="name" value="{{ service.name or '' }}" required>
 		</div>
 		<div class="form-group col">
-			<label for="moderator-group">{{ _('Access Restriction') }}</label>
+			<label for="access-group">{{ _('Access Restriction') }}</label>
 			<select class="form-control" id="access-group" name="access-group">
 				<option value="" class="text-muted">{{ _('No user has access') }}</option>
 				<option value="all" class="text-muted" {{ 'selected' if not service.limit_access }}>{{ _('All users have access (legacy)') }}</option>
@@ -54,6 +54,30 @@
 			</small>
 		</div>
 
+		<div class="form-group col">
+			<p class="mb-2">
+				{{ _('Overwrite remailer setting for specific users') }}
+			</p>
+			<div class="input-group" id="remailer-mode-overwrite">
+				<input class="form-control" name="remailer-overwrite-users" placeholder="{{ _('Login names') }}" value="{{ remailer_overwrites|map(attribute='user')|map(attribute='loginname')|sort|join(', ') }}">
+				<select class="form-control" name="remailer-overwrite-mode">
+					{% set remailer_overwrite_mode = remailer_overwrites|map(attribute='remailer_overwrite_mode')|first or RemailerMode.ENABLED_V2 %}
+					<option value="{{ RemailerMode.DISABLED.name }}" {{ 'selected' if remailer_overwrite_mode == RemailerMode.DISABLED }}>
+						{{ _('Remailer disabled') }}
+					</option>
+					<option value="{{ RemailerMode.ENABLED_V2.name }}" {{ 'selected' if remailer_overwrite_mode == RemailerMode.ENABLED_V2 }}>
+						{{ _('Remailer enabled') }}
+					</option>
+					<option value="{{ RemailerMode.ENABLED_V1.name }}" {{ 'selected' if remailer_overwrite_mode == RemailerMode.ENABLED_V1 }}>
+						{{ _('Remailer enabled (deprecated, case-sensitive format)') }}
+					</option>
+				</select>
+			</div>
+			<small class="form-text text-muted">
+				{{ _('Useful for testing remailer before enabling it for all users. Specify users as a comma-seperated list of login names.') }}
+			</small>
+		</div>
+
 		<div class="form-group col">
 			<div class="form-check">
 				<input class="form-check-input" type="checkbox" id="service-enable-email-preferences" name="enable_email_preferences" value="1" aria-label="enabled" {{ 'checked' if service.enable_email_preferences }}>
diff --git a/uffd/translations/de/LC_MESSAGES/messages.mo b/uffd/translations/de/LC_MESSAGES/messages.mo
index f7010b8fa6548e32d0a8e5ebeb97cc081ca8d9c0..0897f2eba472b656935ffde9314eb66162e0fef2 100644
GIT binary patch
delta 7026
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
WdTL2gYF=K6LSku}-sYdZxA*|Ez3h(w

delta 6582
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
I`)~3A069xPnE(I)

diff --git a/uffd/translations/de/LC_MESSAGES/messages.po b/uffd/translations/de/LC_MESSAGES/messages.po
index 52d52102..5738094a 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-11-01 00:38+0100\n"
+"POT-Creation-Date: 2022-11-06 01:46+0100\n"
 "PO-Revision-Date: 2021-05-25 21:18+0200\n"
 "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 "Language: de\n"
@@ -98,7 +98,7 @@ msgstr "Falsches Passwort"
 msgid "Login name or e-mail address is already in use"
 msgstr "Der Anmeldename oder die E-Mail-Adresse wird bereits verwendet"
 
-#: uffd/models/user.py:46
+#: uffd/models/user.py:119
 #, python-format
 msgid ""
 "At least %(minlen)d and at most %(maxlen)d characters. Only letters, "
@@ -144,8 +144,8 @@ msgstr "Über uffd"
 
 #: 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:75
-#: uffd/templates/service/show.html:103 uffd/templates/user/list.html:8
+#: uffd/templates/service/index.html:8 uffd/templates/service/show.html:99
+#: uffd/templates/service/show.html:127 uffd/templates/user/list.html:8
 msgid "New"
 msgstr "Neu"
 
@@ -162,7 +162,7 @@ msgstr "GID"
 #: uffd/templates/rolemod/list.html:9 uffd/templates/rolemod/show.html:44
 #: uffd/templates/selfservice/self.html:189
 #: uffd/templates/service/index.html:14 uffd/templates/service/show.html:20
-#: uffd/templates/service/show.html:109 uffd/templates/user/show.html:193
+#: uffd/templates/service/show.html:133 uffd/templates/user/show.html:193
 #: uffd/templates/user/show.html:225
 msgid "Name"
 msgstr "Name"
@@ -238,7 +238,7 @@ msgid "Created by"
 msgstr "Erstellt durch"
 
 #: uffd/templates/invite/list.html:14 uffd/templates/service/api.html:34
-#: uffd/templates/service/show.html:110
+#: uffd/templates/service/show.html:134
 msgid "Permissions"
 msgstr "Berechtigungen"
 
@@ -1239,7 +1239,7 @@ msgstr "Diese Option hat keine Auswirkung: Remailer ist nicht konfiguriert"
 msgid "Access uffd metrics"
 msgstr "Zugriff auf uffd-Metriken"
 
-#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:81
+#: uffd/templates/service/oauth2.html:20 uffd/templates/service/show.html:105
 msgid "Client ID"
 msgstr "Client-ID"
 
@@ -1312,15 +1312,15 @@ msgstr "Mitglieder der Gruppe \"%(group_name)s\" haben Zugriff"
 msgid "Hide e-mail addresses with remailer"
 msgstr "E-Mail-Adressen mit Remailer verstecken"
 
-#: uffd/templates/service/show.html:43
+#: uffd/templates/service/show.html:43 uffd/templates/service/show.html:66
 msgid "Remailer disabled"
 msgstr "Remailer deaktiviert"
 
-#: uffd/templates/service/show.html:46
+#: uffd/templates/service/show.html:46 uffd/templates/service/show.html:69
 msgid "Remailer enabled"
 msgstr "Remailer aktiviert"
 
-#: uffd/templates/service/show.html:49
+#: uffd/templates/service/show.html:49 uffd/templates/service/show.html:72
 msgid "Remailer enabled (deprecated, case-sensitive format)"
 msgstr ""
 "Remailer aktiviert (veraltetes, Groß-/Kleinschreibung-unterscheidendes "
@@ -1337,13 +1337,29 @@ msgstr ""
 "-Mail-Adressen aller Nutzer aus und kann zu massenhaftem Versand von "
 "Benachrichtigungs-E-Mails führen."
 
-#: uffd/templates/service/show.html:60
+#: uffd/templates/service/show.html:59
+msgid "Overwrite remailer setting for specific users"
+msgstr "Überschreibe Remailer-Einstellung für ausgewählte Nutzer"
+
+#: uffd/templates/service/show.html:62
+msgid "Login names"
+msgstr "Anmeldenamen"
+
+#: uffd/templates/service/show.html:77
+msgid ""
+"Useful for testing remailer before enabling it for all users. Specify "
+"users as a comma-seperated list of login names."
+msgstr ""
+"Hilfreich zum Testen des Remailers vor dem Aktivieren für alle Nutzer. Um"
+" Nutzer auszuwählen, liste ihre Anmeldenamen mit Komma getrennt auf."
+
+#: uffd/templates/service/show.html:84
 msgid "Allow users to select a different e-mail address for this service"
 msgstr ""
 "Ermögliche Nutzern für diesen Dienst eine andere E-Mail-Adresse "
 "auszuwählen"
 
-#: uffd/templates/service/show.html:62
+#: uffd/templates/service/show.html:86
 msgid "If disabled, the service always uses the primary e-mail address."
 msgstr "Wenn deaktiviert, wird immer die primäre E-Mail-Adresse verwendet."
 
diff --git a/uffd/views/service.py b/uffd/views/service.py
index 7f5b36e0..37fa1f67 100644
--- a/uffd/views/service.py
+++ b/uffd/views/service.py
@@ -6,7 +6,7 @@ from flask_babel import lazy_gettext
 from uffd.navbar import register_navbar
 from uffd.csrf import csrf_protect
 from uffd.database import db
-from uffd.models import Service, get_services, Group, OAuth2Client, OAuth2LogoutURI, APIClient, RemailerMode
+from uffd.models import User, Service, ServiceUser, get_services, Group, OAuth2Client, OAuth2LogoutURI, APIClient, RemailerMode
 
 from .session import login_required
 
@@ -50,8 +50,15 @@ def index():
 @login_required(admin_acl)
 def show(id=None):
 	service = Service() if id is None else Service.query.get_or_404(id)
+	remailer_overwrites = []
+	if id is not None:
+		# pylint: disable=singleton-comparison
+		remailer_overwrites = ServiceUser.query.filter(
+			ServiceUser.service_id == id,
+			ServiceUser.remailer_overwrite_mode != None
+		).all()
 	all_groups = Group.query.all()
-	return render_template('service/show.html', service=service, all_groups=all_groups)
+	return render_template('service/show.html', service=service, all_groups=all_groups, remailer_overwrites=remailer_overwrites)
 
 @bp.route('/service/new', methods=['POST'])
 @bp.route('/service/<int:id>', methods=['POST'])
@@ -73,8 +80,26 @@ def edit_submit(id=None):
 	else:
 		service.limit_access = True
 		service.access_group = Group.query.get(request.form['access-group'])
-	service.remailer_mode = RemailerMode[request.form['remailer-mode']]
 	service.enable_email_preferences = request.form.get('enable_email_preferences') == '1'
+	service.remailer_mode = RemailerMode[request.form['remailer-mode']]
+	remailer_overwrite_mode = RemailerMode[request.form['remailer-overwrite-mode']]
+	remailer_overwrite_user_ids = [
+		User.query.filter_by(loginname=loginname.strip()).one().id
+		for loginname in request.form['remailer-overwrite-users'].split(',') if loginname.strip()
+	]
+	# pylint: disable=singleton-comparison
+	service_users = ServiceUser.query.filter(
+		ServiceUser.service == service,
+		db.or_(
+			ServiceUser.user_id.in_(remailer_overwrite_user_ids),
+			ServiceUser.remailer_overwrite_mode != None,
+		)
+	)
+	for service_user in service_users:
+		if service_user.user_id in remailer_overwrite_user_ids:
+			service_user.remailer_overwrite_mode = remailer_overwrite_mode
+		else:
+			service_user.remailer_overwrite_mode = None
 	db.session.commit()
 	return redirect(url_for('service.show', id=service.id))
 
-- 
GitLab