test(mail): proving suite for serverkit-mail + changelog

56 hermetic tests (docker/HTTP/socket/DNS-provider all stubbed; pass on
Windows). Cover: manifest validity + lifecycle-key/jobs wiring (guards the
loader-key bug), models + no mailbox-password column, Stalwart install argv
(admin API loopback-only, mail ports, generated+persisted admin secret) and
Windows gating, preflight pass/block + persistence, the sending-activation
gate (refused without a passing preflight, force override audited), DKIM
keygen + exact SPF/DKIM/DMARC/MX/A record push with source='mail', fail2ban
jail, and blueprint auth/validation/503 paths.

CHANGELOG: document the Mail Server extension under Unreleased.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Juan DenisandClaude Opus 4.8 committed 2026-07-04 00:27:41 -04:00
1 parent d0d55fc2ea
commit d99d4db6a9
3 files changed
+1098

No files matched your search

+14
View File
@@ -28,6 +28,20 @@ awaiting a stable release:
### Added
- **Mail Server extension (`serverkit-mail`)** — an opt-in built-in that runs a
self-hosted mail server (Stalwart, in a managed Docker container) driven
through its HTTP admin API. Manages mail domains, mailboxes, forwarders,
autoresponders and catch-all; generates DKIM keys and deploys DKIM/SPF/DMARC/MX/A
records through the existing DNS-provider integrations; requests a Let's Encrypt
cert for the mail hostname; and shows the outbound queue. A **deliverability
preflight** (reverse-DNS/PTR match, port-25 egress, RBL listing, listening
ports) runs on a daily schedule and **blocks outbound sending until it passes**
(an explicit force override is audit-logged), because self-hosted mail on a VPS
is a real commitment — many providers block port 25 and a fresh IP can be
pre-burned. Ships brute-force auth jails and registers the mail store for
scheduled backups. Not installed by default; enable it from Marketplace. It runs
alongside the older Postfix/Dovecot `serverkit-email` extension with entirely
separate identifiers.
- **Configuration drift detection + repair ("doctor")** — a daily read-only
sweep re-renders the expected nginx vhost and compose override for every
managed resource from panel state and diffs it against disk; drift raises an
@@ -0,0 +1,688 @@
"""serverkit-mail extension tests — manifest, models, engine, blueprint.
Covers the Stalwart-based builtin mail extension the same way production loads a
builtin: ``plugin_service._ensure_builtin_backend_importable`` registers
``builtin-extensions/serverkit-mail/backend`` as the dashed package
``app.plugins.serverkit-mail``. The models module is imported at test-module top
level so its ``ext_serverkit_mail_*`` tables register on ``db.metadata`` before
the ``app`` fixture runs ``db.create_all()``.
This file proves: manifest validity + permissions + entry point + frontend
export, the SQLAlchemy models (create + ``to_dict`` + no mailbox password column +
the unique constraint), the StalwartService Docker/API choke-points (install argv
with a loopback-only admin API, status parsing, Windows gating, clean API error
dicts, best-effort reconcile methods), and the blueprint auth/validation paths.
Preflight, the activation gate, DKIM/DNS, and the fail2ban jail live in
``test_serverkit_mail_preflight_dns.py``.
"""
import importlib
import json
import os
from types import SimpleNamespace
import pytest
from app.services import plugin_service
SLUG = 'serverkit-mail'
EXT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'builtin-extensions', SLUG,
)
def _load_ext():
assert plugin_service._ensure_builtin_backend_importable(SLUG), (
f'builtin extension backend not importable from {EXT_DIR}')
# Import models at module top so the ext_serverkit_mail_* tables register on
# db.metadata before any app fixture calls db.create_all().
models = importlib.import_module(f'app.plugins.{SLUG}.models')
stalwart = importlib.import_module(f'app.plugins.{SLUG}.stalwart_service')
bp = importlib.import_module(f'app.plugins.{SLUG}.mail')
return models, stalwart, bp
models_mod, svc_mod, bp_mod = _load_ext()
StalwartService = svc_mod.StalwartService
MailDomain = models_mod.MailDomain
Mailbox = models_mod.Mailbox
Forwarder = models_mod.Forwarder
Autoresponder = models_mod.Autoresponder
PreflightResult = models_mod.PreflightResult
CFG = {'admin_password': 'test-admin-pw', 'hostname': 'mail.example.com'}
def _proc(returncode=0, stdout='', stderr=''):
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
class FakeResponse:
def __init__(self, status_code=200, data=None, text=''):
self.status_code = status_code
self._data = data
self.text = text or (json.dumps(data) if data is not None else '')
self.content = self.text.encode()
def json(self):
if self._data is None:
raise ValueError('no json')
return self._data
@pytest.fixture
def linux(monkeypatch):
"""Pretend we're on Linux with docker installed and admin creds saved."""
monkeypatch.setattr(svc_mod.os, 'name', 'posix')
monkeypatch.setattr(svc_mod, 'is_command_available', lambda c: True)
monkeypatch.setattr(StalwartService, '_config', classmethod(lambda cls: dict(CFG)))
# ---------------------------------------------------------------------------
# manifest
# ---------------------------------------------------------------------------
def test_manifest_passes_validator():
with open(os.path.join(EXT_DIR, 'plugin.json'), encoding='utf-8') as f:
manifest = json.load(f)
assert plugin_service._validate_manifest(manifest) is True
assert manifest['name'] == SLUG
assert manifest['category'] == 'integration'
assert manifest['entry_point'] == 'mail:mail_bp'
assert manifest['url_prefix'] == '/api/v1/mail'
assert manifest['models'] == 'models:register'
assert set(manifest['permissions']) == {'shell', 'filesystem', 'network', 'docker'}
nav = manifest['contributions']['nav'][0]
assert nav['route'] == '/mail'
assert nav['id'] == 'mail'
routes = manifest['contributions']['routes']
assert {'path': 'mail', 'component': 'MailPage'} in routes
assert manifest['contributions']['page_titles']['/mail'] == 'Mail Server'
def test_manifest_permissions_are_known():
from app.plugins_sdk import permissions as sdk_perms
with open(os.path.join(EXT_DIR, 'plugin.json'), encoding='utf-8') as f:
manifest = json.load(f)
assert sdk_perms.unknown_permissions(manifest['permissions']) == []
def test_lifecycle_hook_keys_match_loader():
"""The loader reads lifecycle.get('install')/('uninstall') — the manifest must
use those exact keys (not 'on_install'/'on_uninstall'), else the hooks that
register notify events and tear down the container silently never fire. The
_validate_manifest check only vets the VALUES, so this guards the KEYS and
proves each referenced hook actually resolves + is importable."""
import importlib
with open(os.path.join(EXT_DIR, 'plugin.json'), encoding='utf-8') as f:
lifecycle = json.load(f)['lifecycle']
assert set(lifecycle) <= {'install', 'upgrade', 'uninstall'}
assert 'install' in lifecycle and 'uninstall' in lifecycle
for ref in lifecycle.values():
module_name, func_name = ref.split(':')
mod = importlib.import_module(f'app.plugins.{SLUG}.{module_name}')
assert callable(getattr(mod, func_name, None)), ref
def test_manifest_jobs_and_schedule_resolve():
"""Job handlers + the daily preflight schedule are wired to real callables."""
import importlib
with open(os.path.join(EXT_DIR, 'plugin.json'), encoding='utf-8') as f:
manifest = json.load(f)
kinds = {j['kind'] for j in manifest['jobs']}
assert 'mail.preflight.run' in kinds
for job in manifest['jobs']:
module_name, func_name = job['handler'].split(':')
mod = importlib.import_module(f'app.plugins.{SLUG}.{module_name}')
assert callable(getattr(mod, func_name, None)), job['handler']
sched = manifest['schedules'][0]
assert sched['kind'] in kinds and sched.get('cron')
def test_entry_point_resolves_to_blueprint():
assert getattr(bp_mod, 'mail_bp', None) is not None
assert bp_mod.mail_bp.name == 'mail'
def test_models_register_is_a_noop_passthrough():
# Manifest declares models: "models:register"; the callable must exist and be
# a harmless passthrough (tables register as an import side effect).
from app import db
assert models_mod.register(db) is None
def test_frontend_exports_route_component():
index = os.path.join(EXT_DIR, 'frontend', 'index.jsx')
if not os.path.isfile(index):
pytest.skip('serverkit-mail frontend/index.jsx not present in this build')
with open(index, encoding='utf-8') as f:
src = f.read()
assert 'export { default as MailPage }' in src
# No module-level default export: PluginLoader legacy-auto-renders those.
assert 'export default' not in src
# ---------------------------------------------------------------------------
# models
# ---------------------------------------------------------------------------
def test_mail_domain_persists_and_to_dict(app):
from app import db
row = MailDomain(name='example.com', catch_all_target='root@example.com',
is_active=False, sync_state='pending')
db.session.add(row)
db.session.commit()
d = row.to_dict()
for key in ('id', 'name', 'is_active', 'catch_all_target', 'dkim_selector',
'dkim_public_key', 'dkim_configured', 'dns_deployed',
'dns_last_result', 'sync_state', 'sync_error', 'mailboxes_count',
'forwarders_count', 'created_at', 'updated_at'):
assert key in d, key
assert d['name'] == 'example.com'
assert d['is_active'] is False
assert d['dkim_selector'] == 'serverkit' # column default
assert d['dkim_configured'] is False
assert d['mailboxes_count'] == 0
def test_mailbox_has_no_password_and_to_dict(app):
from app import db
domain = MailDomain(name='mailbox-test.com')
db.session.add(domain)
db.session.commit()
box = Mailbox(domain_id=domain.id, local_part='alice', quota_mb=512,
display_name='Alice')
db.session.add(box)
db.session.commit()
# Passwords are NEVER persisted panel-side.
assert not hasattr(box, 'password')
assert 'password' not in Mailbox.__table__.columns
assert 'password_hash' not in Mailbox.__table__.columns
d = box.to_dict()
assert 'password' not in d
assert d['local_part'] == 'alice'
assert d['email'] == 'alice@mailbox-test.com'
assert d['quota_mb'] == 512
assert d['domain_name'] == 'mailbox-test.com'
def test_mailbox_unique_domain_local_part(app):
from app import db
from sqlalchemy.exc import IntegrityError
domain = MailDomain(name='dupe.com')
db.session.add(domain)
db.session.commit()
db.session.add(Mailbox(domain_id=domain.id, local_part='postmaster'))
db.session.commit()
db.session.add(Mailbox(domain_id=domain.id, local_part='postmaster'))
with pytest.raises(IntegrityError):
db.session.commit()
db.session.rollback()
def test_forwarder_and_autoresponder_persist(app):
from app import db
domain = MailDomain(name='fwd.com')
db.session.add(domain)
db.session.commit()
fwd = Forwarder(domain_id=domain.id, source_local_part='sales',
destination='team@elsewhere.org', keep_copy=True)
db.session.add(fwd)
db.session.commit()
fd = fwd.to_dict()
assert fd['source'] == 'sales@fwd.com'
assert fd['destination'] == 'team@elsewhere.org'
assert fd['keep_copy'] is True
box = Mailbox(domain_id=domain.id, local_part='bob')
db.session.add(box)
db.session.commit()
ar = Autoresponder(mailbox_id=box.id, enabled=True, subject='OOO', body='Away')
db.session.add(ar)
db.session.commit()
ad = ar.to_dict()
assert ad['mailbox_id'] == box.id
assert ad['enabled'] is True
assert ad['subject'] == 'OOO'
def test_preflight_result_persist_and_to_dict(app):
from app import db
row = PreflightResult(hostname='mail.example.com', server_ip='203.0.113.5',
ptr_ok=True, ptr_value='mail.example.com',
port25_ok=True, rbl_ok=True,
rbl_hits=json.dumps([]), ports_ok=True, passed=True,
detail=json.dumps({'ptr': {'ok': True}}))
db.session.add(row)
db.session.commit()
d = row.to_dict()
for key in ('id', 'hostname', 'server_ip', 'ptr_ok', 'ptr_value', 'port25_ok',
'rbl_ok', 'rbl_hits', 'ports_ok', 'passed', 'detail', 'checked_at'):
assert key in d, key
assert d['passed'] is True
assert d['rbl_hits'] == [] # JSON text decoded back to a list
assert d['detail'] == {'ptr': {'ok': True}}
# ---------------------------------------------------------------------------
# StalwartService: install / lifecycle argv
# ---------------------------------------------------------------------------
def test_install_builds_correct_docker_run(monkeypatch, linux):
calls = []
saved = {}
def fake_run(cmd, timeout=None, **kwargs):
calls.append(list(cmd))
if cmd[:2] == ['docker', 'inspect']:
return _proc(returncode=1, stderr='No such object') # not installed
return _proc(returncode=0, stdout='container123\n')
monkeypatch.setattr(svc_mod, 'run_privileged', fake_run)
monkeypatch.setattr(StalwartService, '_save_config',
classmethod(lambda cls, updates: saved.update(updates) or True))
result = StalwartService.install('Mail.Example.COM.')
assert result['success'] is True, result
# Data dir created, container started.
assert ['mkdir', '-p', svc_mod.DATA_DIR] in calls
run_cmd = next(c for c in calls if c[:2] == ['docker', 'run'])
# Mail ports published on the host.
for port in ('25', '465', '587', '993'):
assert f'{port}:{port}' in run_cmd, port
# Admin API published on loopback ONLY — never a bare 8080:8080.
assert f'{svc_mod.API_HOST}:{svc_mod.API_PORT}:{svc_mod.API_PORT}' in run_cmd
assert '127.0.0.1:8080:8080' in run_cmd
assert not any(a == '8080:8080' for a in run_cmd)
# Official image, restart policy, data volume.
assert svc_mod.IMAGE in run_cmd
assert '--restart' in run_cmd
assert f'{svc_mod.DATA_DIR}:{svc_mod.CONTAINER_DATA_DIR}' in run_cmd
# A generated admin password was seeded into the container AND persisted.
secret_args = [a for a in run_cmd if a.startswith('FALLBACK_ADMIN_SECRET=')]
assert len(secret_args) == 1
generated = secret_args[0].split('=', 1)[1]
assert generated # non-empty generated secret
assert saved['admin_password'] == generated
assert saved['hostname'] == 'mail.example.com' # normalized (lowercased, no dot)
def test_install_requires_hostname(monkeypatch, linux):
monkeypatch.setattr(
svc_mod, 'run_privileged',
lambda *a, **kw: pytest.fail('docker must not run on invalid input'))
assert StalwartService.install('')['success'] is False
assert StalwartService.install(' ')['success'] is False
def test_install_refuses_when_container_exists(monkeypatch, linux):
monkeypatch.setattr(
svc_mod, 'run_privileged',
lambda cmd, timeout=None, **kw: _proc(returncode=0, stdout='true\n'))
result = StalwartService.install('mail.example.com')
assert result['success'] is False
assert 'already exists' in result['error']
def test_uninstall_keep_data_vs_purge(monkeypatch, linux):
calls = []
def fake_run(cmd, timeout=None, **kwargs):
calls.append(list(cmd))
return _proc(returncode=0)
monkeypatch.setattr(svc_mod, 'run_privileged', fake_run)
monkeypatch.setattr(StalwartService, '_save_config',
classmethod(lambda cls, updates: True))
result = StalwartService.uninstall(keep_data=True)
assert result['success'] is True
assert ['docker', 'rm', '-f', svc_mod.CONTAINER_NAME] in calls
assert not any(c[:1] == ['rm'] for c in calls) # data dir untouched
calls.clear()
result = StalwartService.uninstall(keep_data=False)
assert result['success'] is True
assert ['rm', '-rf', svc_mod.DATA_DIR] in calls
def test_windows_is_unsupported(monkeypatch):
monkeypatch.setattr(svc_mod.os, 'name', 'nt')
assert StalwartService.is_installed() is False
result = StalwartService.install('mail.example.com')
assert result['success'] is False
assert 'Windows' in result['error']
status = StalwartService.get_status()
assert status['installed'] is False
assert status['running'] is False
# _api short-circuits on Windows without touching the network.
api = StalwartService._api('GET', '/principal')
assert api['success'] is False
assert 'Windows' in api['error']
# ---------------------------------------------------------------------------
# StalwartService: status + admin API choke-point
# ---------------------------------------------------------------------------
def test_status_running_reads_version(monkeypatch, linux):
monkeypatch.setattr(
svc_mod, 'run_privileged',
lambda cmd, timeout=None, **kw: _proc(returncode=0, stdout='true\n'))
monkeypatch.setattr(
svc_mod.requests, 'request',
lambda method, url, **kw: FakeResponse(200, {'version': '0.11.1'}))
status = StalwartService.get_status()
assert status['installed'] is True
assert status['running'] is True
assert status['version'] == '0.11.1'
assert status['engine'] == 'stalwart'
assert status['admin_api'] == f'{svc_mod.API_HOST}:{svc_mod.API_PORT}'
def test_status_not_installed(monkeypatch, linux):
monkeypatch.setattr(
svc_mod, 'run_privileged',
lambda cmd, timeout=None, **kw: _proc(returncode=1, stderr='No such object'))
status = StalwartService.get_status()
assert status['installed'] is False
assert status['running'] is False
def test_api_uses_basic_auth_and_returns_data(monkeypatch, linux):
seen = {}
def fake_request(method, url, auth=None, json=None, timeout=None):
seen.update(method=method, url=url, auth=auth, json=json)
return FakeResponse(200, {'ok': True})
monkeypatch.setattr(svc_mod.requests, 'request', fake_request)
res = StalwartService._api('POST', '/principal', {'name': 'a@b.co'})
assert res['success'] is True
assert res['data'] == {'ok': True}
assert seen['method'] == 'POST'
assert seen['url'] == svc_mod.API_BASE + '/principal'
assert seen['auth'] == (svc_mod.ADMIN_USER, CFG['admin_password'])
def test_api_error_status_is_clean(monkeypatch, linux):
monkeypatch.setattr(
svc_mod.requests, 'request',
lambda *a, **kw: FakeResponse(422, {'error': 'principal exists'}))
res = StalwartService._api('POST', '/principal', {})
assert res['success'] is False
assert res['status_code'] == 422
assert 'principal exists' in res['error']
def test_api_unreachable_is_clean(monkeypatch, linux):
def boom(*a, **kw):
raise svc_mod.requests.RequestException('connection refused')
monkeypatch.setattr(svc_mod.requests, 'request', boom)
res = StalwartService._api('GET', '/principal')
assert res['success'] is False
assert 'unreachable' in res['error']
def test_api_requires_admin_password(monkeypatch, linux):
monkeypatch.setattr(StalwartService, '_config', classmethod(lambda cls: {}))
monkeypatch.setattr(
svc_mod.requests, 'request',
lambda *a, **kw: pytest.fail('API must not be called without a password'))
res = StalwartService._api('GET', '/principal')
assert res['success'] is False
assert 'password is not configured' in res['error']
# ---------------------------------------------------------------------------
# StalwartService: best-effort reconcile methods (never raise)
# ---------------------------------------------------------------------------
def test_reconcile_methods_skip_when_not_installed(monkeypatch, linux):
monkeypatch.setattr(StalwartService, 'is_installed', classmethod(lambda cls: False))
monkeypatch.setattr(
svc_mod.requests, 'request',
lambda *a, **kw: pytest.fail('no API call when engine is absent'))
for res in (StalwartService.upsert_account('a@b.co', password='x'),
StalwartService.upsert_domain('b.co'),
StalwartService.delete_account('a@b.co'),
StalwartService.set_password('a@b.co', 'x'),
StalwartService.flush_queue()):
assert res['success'] is False
assert res.get('skipped') is True
# Queue introspection degrades to an empty list, not an error.
q = StalwartService.list_queue()
assert q == {'success': True, 'messages': []}
def test_reconcile_upsert_account_success_and_error(monkeypatch, linux):
monkeypatch.setattr(StalwartService, 'is_installed', classmethod(lambda cls: True))
calls = []
def ok_api(cls, method, path, payload=None):
calls.append((method, path, payload))
return {'success': True, 'data': None}
monkeypatch.setattr(StalwartService, '_api', classmethod(ok_api))
res = StalwartService.upsert_account('alice@example.com', password='pw',
quota_mb=100, display_name='Alice')
assert res == {'success': True, 'email': 'alice@example.com'}
method, path, payload = calls[0]
assert method == 'POST'
assert path == svc_mod.EP_PRINCIPAL
assert payload['type'] == 'individual'
assert payload['name'] == 'alice@example.com'
assert payload['secrets'] == ['pw']
assert payload['quota'] == 100 * 1024 * 1024
monkeypatch.setattr(
StalwartService, '_api',
classmethod(lambda cls, m, p, payload=None: {'success': False, 'error': 'boom'}))
res = StalwartService.upsert_account('bob@example.com')
assert res['success'] is False
assert 'boom' in res['error']
def test_list_queue_shapes(monkeypatch, linux):
monkeypatch.setattr(StalwartService, 'is_installed', classmethod(lambda cls: True))
monkeypatch.setattr(
StalwartService, '_api',
classmethod(lambda cls, m, p, payload=None: {'success': True,
'data': [{'id': 1}, {'id': 2}]}))
res = StalwartService.list_queue()
assert res['success'] is True
assert res['messages'] == [{'id': 1}, {'id': 2}]
# A dict payload with items is unwrapped; an API failure degrades to [].
monkeypatch.setattr(
StalwartService, '_api',
classmethod(lambda cls, m, p, payload=None: {'success': True,
'data': {'items': [{'id': 9}]}}))
assert StalwartService.list_queue()['messages'] == [{'id': 9}]
monkeypatch.setattr(
StalwartService, '_api',
classmethod(lambda cls, m, p, payload=None: {'success': False, 'error': 'down'}))
res = StalwartService.list_queue()
assert res['messages'] == []
assert res['note'] == 'down'
# ---------------------------------------------------------------------------
# blueprint routes
# ---------------------------------------------------------------------------
@pytest.fixture
def mail_app(app):
"""Register the extension blueprint on the test app (name-guarded)."""
if 'mail' not in app.blueprints:
app.register_blueprint(bp_mod.mail_bp, url_prefix='/api/v1/mail')
return app
@pytest.fixture
def mail_client(mail_app):
return mail_app.test_client()
def test_routes_require_auth(mail_client):
assert mail_client.get('/api/v1/mail/status').status_code == 401
assert mail_client.post('/api/v1/mail/install', json={}).status_code == 401
assert mail_client.get('/api/v1/mail/domains').status_code == 401
assert mail_client.post('/api/v1/mail/domains', json={}).status_code == 401
assert mail_client.get('/api/v1/mail/queue').status_code == 401
def test_status_route(mail_client, auth_headers, monkeypatch):
monkeypatch.setattr(
bp_mod.MailService, 'get_status',
classmethod(lambda cls: {
'installed': True, 'running': True, 'version': '0.11',
'engine': 'stalwart', 'preflight': None, 'domains_count': 0,
'docs_url': svc_mod.DOCS_URL,
}))
resp = mail_client.get('/api/v1/mail/status', headers=auth_headers)
assert resp.status_code == 200
data = resp.get_json()
assert data['installed'] is True
assert data['engine'] == 'stalwart'
def test_preflight_get_route(mail_client, auth_headers, monkeypatch):
monkeypatch.setattr(
bp_mod.PreflightService, 'latest',
classmethod(lambda cls: {'passed': True, 'hostname': 'mail.example.com'}))
resp = mail_client.get('/api/v1/mail/preflight', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['preflight']['passed'] is True
def test_preflight_post_route(mail_client, auth_headers, monkeypatch):
captured = {}
def fake_run(cls, hostname, server_ip=None):
captured.update(hostname=hostname, server_ip=server_ip)
return {'passed': False, 'hostname': hostname}
monkeypatch.setattr(bp_mod.PreflightService, 'run', classmethod(fake_run))
resp = mail_client.post('/api/v1/mail/preflight',
json={'hostname': 'mail.example.com', 'server_ip': '1.2.3.4'},
headers=auth_headers)
assert resp.status_code == 200
assert captured['hostname'] == 'mail.example.com'
assert captured['server_ip'] == '1.2.3.4'
# hostname is required.
resp = mail_client.post('/api/v1/mail/preflight', json={}, headers=auth_headers)
assert resp.status_code == 400
assert 'error' in resp.get_json()
def test_install_route(mail_client, auth_headers, monkeypatch):
captured = {}
def fake_install(cls, hostname):
captured['hostname'] = hostname
return {'success': True, 'message': 'ok', 'container': svc_mod.CONTAINER_NAME}
monkeypatch.setattr(bp_mod.StalwartService, 'install', classmethod(fake_install))
resp = mail_client.post('/api/v1/mail/install',
json={'hostname': 'mail.example.com'}, headers=auth_headers)
assert resp.status_code == 201
assert captured['hostname'] == 'mail.example.com'
resp = mail_client.post('/api/v1/mail/install', json={}, headers=auth_headers)
assert resp.status_code == 400
assert 'error' in resp.get_json()
def test_domains_routes_happy_path(mail_client, auth_headers, monkeypatch):
monkeypatch.setattr(
bp_mod.MailService, 'list_domains',
classmethod(lambda cls: [{'id': 1, 'name': 'example.com'}]))
resp = mail_client.get('/api/v1/mail/domains', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['domains'][0]['name'] == 'example.com'
captured = {}
def fake_add(cls, name, catch_all_target=None):
captured.update(name=name, catch_all_target=catch_all_target)
return {'success': True, 'domain': {'id': 1, 'name': name}}
monkeypatch.setattr(bp_mod.MailService, 'add_domain', classmethod(fake_add))
resp = mail_client.post('/api/v1/mail/domains',
json={'name': 'example.com', 'catch_all_target': 'root@example.com'},
headers=auth_headers)
assert resp.status_code == 201
assert captured == {'name': 'example.com', 'catch_all_target': 'root@example.com'}
# name is required.
resp = mail_client.post('/api/v1/mail/domains', json={}, headers=auth_headers)
assert resp.status_code == 400
assert 'error' in resp.get_json()
def test_add_domain_service_error_is_400(mail_client, auth_headers, monkeypatch):
monkeypatch.setattr(
bp_mod.MailService, 'add_domain',
classmethod(lambda cls, name, catch_all_target=None: {
'success': False, 'error': 'Domain example.com already exists'}))
resp = mail_client.post('/api/v1/mail/domains',
json={'name': 'example.com'}, headers=auth_headers)
assert resp.status_code == 400
assert 'already exists' in resp.get_json()['error']
def test_mailbox_create_validation(mail_client, auth_headers, monkeypatch):
captured = {}
def fake_add(cls, domain_id, local_part, password, quota_mb=0, display_name=None):
captured.update(domain_id=domain_id, local_part=local_part, password=password)
return {'success': True, 'mailbox': {'id': 1, 'local_part': local_part}}
monkeypatch.setattr(bp_mod.MailService, 'add_mailbox', classmethod(fake_add))
# Missing password -> 400 before the service runs.
resp = mail_client.post('/api/v1/mail/domains/1/mailboxes',
json={'local_part': 'alice'}, headers=auth_headers)
assert resp.status_code == 400
assert captured == {}
# Missing local_part -> 400.
resp = mail_client.post('/api/v1/mail/domains/1/mailboxes',
json={'password': 'x'}, headers=auth_headers)
assert resp.status_code == 400
# Happy path -> 201, password forwarded to the service (never persisted).
resp = mail_client.post('/api/v1/mail/domains/1/mailboxes',
json={'local_part': 'alice', 'password': 's3cret'},
headers=auth_headers)
assert resp.status_code == 201
assert captured['local_part'] == 'alice'
assert captured['password'] == 's3cret'
def test_queue_route_503_when_not_installed(mail_client, auth_headers, monkeypatch):
monkeypatch.setattr(bp_mod.StalwartService, 'is_installed', classmethod(lambda cls: False))
resp = mail_client.get('/api/v1/mail/queue', headers=auth_headers)
assert resp.status_code == 503
assert 'error' in resp.get_json()
def test_queue_route_ok_when_installed(mail_client, auth_headers, monkeypatch):
monkeypatch.setattr(bp_mod.StalwartService, 'is_installed', classmethod(lambda cls: True))
monkeypatch.setattr(
bp_mod.StalwartService, 'list_queue',
classmethod(lambda cls: {'success': True, 'messages': [{'id': 1}]}))
resp = mail_client.get('/api/v1/mail/queue', headers=auth_headers)
assert resp.status_code == 200
assert resp.get_json()['messages'] == [{'id': 1}]
@@ -0,0 +1,396 @@
"""serverkit-mail extension tests — preflight, activation gate, DKIM/DNS, jail.
Loads the builtin backend exactly like production
(``plugin_service._ensure_builtin_backend_importable`` -> dashed package
``app.plugins.serverkit-mail``) and imports the models module at test-module top
level so its tables register before the ``app`` fixture's ``db.create_all()``.
Proves the safety-critical behaviors:
* PreflightService: all-pass -> ``passed`` + a persisted row; an RBL hit or a
blocked port 25 -> ``passed=False`` with the failing check surfaced;
Windows/dev -> skipped and never raises; ``latest()`` returns the newest row.
* The activation gate: ``MailService.set_domain_active(id, True)`` is REFUSED
without a passing preflight and ALLOWED with ``force=True`` (audit path).
* DkimDnsService: DKIM keygen -> a ``v=DKIM1; k=rsa; p=...`` TXT value + stored
keys; the pure ``build_records`` shapes; ``deploy_dns`` calls
``DNSProviderService.set_record`` with the exact record types/names/values and
``source='mail'``, and the no-provider path returns manual instructions.
* MailJailService: unavailable on Windows; enable writes a ``serverkit-mail-*``
jail via a stubbed ``run_privileged(['tee', ...])`` and never raises.
"""
import importlib
import json
import os
from types import SimpleNamespace
import pytest
from app.services import plugin_service
SLUG = 'serverkit-mail'
EXT_DIR = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
'builtin-extensions', SLUG,
)
def _load_ext():
assert plugin_service._ensure_builtin_backend_importable(SLUG), (
f'builtin extension backend not importable from {EXT_DIR}')
models = importlib.import_module(f'app.plugins.{SLUG}.models')
preflight = importlib.import_module(f'app.plugins.{SLUG}.preflight_service')
dns = importlib.import_module(f'app.plugins.{SLUG}.dns_mail_service')
mailsvc = importlib.import_module(f'app.plugins.{SLUG}.mail_service')
jail = importlib.import_module(f'app.plugins.{SLUG}.mail_jail_service')
return models, preflight, dns, mailsvc, jail
models_mod, preflight_mod, dns_mod, mailsvc_mod, jail_mod = _load_ext()
PreflightService = preflight_mod.PreflightService
DkimDnsService = dns_mod.DkimDnsService
MailService = mailsvc_mod.MailService
MailJailService = jail_mod.MailJailService
MailDomain = models_mod.MailDomain
PreflightResult = models_mod.PreflightResult
def _proc(returncode=0, stdout='', stderr=''):
return SimpleNamespace(returncode=returncode, stdout=stdout, stderr=stderr)
def _patch_checks(monkeypatch, *, ptr=(True, 'mail.example.com', None),
port25=(True, None), rbl=(True, [], None), ports=(True, None)):
"""Stub every individual preflight helper so run() is hermetic."""
monkeypatch.setattr(PreflightService, '_check_ptr',
staticmethod(lambda hostname, server_ip: ptr))
monkeypatch.setattr(PreflightService, '_check_port25',
staticmethod(lambda: port25))
monkeypatch.setattr(PreflightService, '_check_rbl',
classmethod(lambda cls, server_ip: rbl))
monkeypatch.setattr(PreflightService, '_check_local_ports',
staticmethod(lambda: ports))
# Keep the verdict OS-independent even though the machine may be Windows.
monkeypatch.setattr(preflight_mod.os, 'name', 'posix')
# ---------------------------------------------------------------------------
# preflight
# ---------------------------------------------------------------------------
def test_preflight_all_pass_persists_row(app, monkeypatch):
_patch_checks(monkeypatch)
result = PreflightService.run('mail.example.com', server_ip='203.0.113.5')
assert result['passed'] is True
assert result['ptr_ok'] is True
assert result['port25_ok'] is True
assert result['rbl_ok'] is True
# A row was persisted and is the latest.
assert PreflightResult.query.count() == 1
latest = PreflightService.latest()
assert latest['passed'] is True
assert latest['hostname'] == 'mail.example.com'
def test_preflight_rbl_hit_fails(app, monkeypatch):
_patch_checks(monkeypatch, rbl=(False, ['zen.spamhaus.org'], 'Listed on: zen.spamhaus.org'))
result = PreflightService.run('mail.example.com', server_ip='203.0.113.5')
assert result['passed'] is False
assert result['rbl_ok'] is False
assert 'zen.spamhaus.org' in result['rbl_hits']
assert result['detail']['rbl']['hits'] == ['zen.spamhaus.org']
def test_preflight_blocked_port25_fails(app, monkeypatch):
_patch_checks(monkeypatch, port25=(False, 'provider blocks port 25'))
result = PreflightService.run('mail.example.com', server_ip='203.0.113.5')
assert result['passed'] is False
assert result['port25_ok'] is False
assert result['detail']['port25']['ok'] is False
def test_preflight_windows_skips_and_never_raises(app, monkeypatch):
# Do NOT stub the checks; force the real OS gate to skip host-dependent work.
monkeypatch.setattr(preflight_mod.os, 'name', 'nt')
result = PreflightService.run('mail.example.com', server_ip='203.0.113.5')
assert result['passed'] is False # skipped checks never green-light sending
assert 'dev_note' in result['detail']
def test_preflight_latest_returns_newest(app, monkeypatch):
from app import db
from datetime import datetime, timedelta
old = PreflightResult(hostname='old.example.com', passed=False,
checked_at=datetime.utcnow() - timedelta(hours=2))
new = PreflightResult(hostname='new.example.com', passed=True,
checked_at=datetime.utcnow())
db.session.add_all([old, new])
db.session.commit()
latest = PreflightService.latest()
assert latest['hostname'] == 'new.example.com'
def test_preflight_latest_none_when_empty(app):
assert PreflightService.latest() is None
# ---------------------------------------------------------------------------
# activation gate (the key safety property)
# ---------------------------------------------------------------------------
def _make_domain(active=False):
from app import db
row = MailDomain(name='gate.example.com', is_active=active, sync_state='pending')
db.session.add(row)
db.session.commit()
return row
def test_activation_refused_without_passing_preflight(app):
row = _make_domain()
result = MailService.set_domain_active(row.id, True)
assert result['success'] is False
assert result['code'] == 'preflight_required'
# The domain stays inactive.
from app import db
db.session.refresh(row)
assert row.is_active is False
def test_activation_allowed_with_force_and_audits(app, monkeypatch):
row = _make_domain()
audits = []
monkeypatch.setattr(
MailService, '_audit',
staticmethod(lambda action, target_id, details: audits.append(
(action, target_id, details))))
result = MailService.set_domain_active(row.id, True, force=True)
assert result['success'] is True
from app import db
db.session.refresh(row)
assert row.is_active is True
# The force override was recorded on the audit ledger.
assert audits and audits[0][0] == 'mail.domain.activate_forced'
assert audits[0][1] == row.id
def test_activation_allowed_with_passing_preflight(app):
from app import db
db.session.add(PreflightResult(hostname='gate.example.com', passed=True))
db.session.commit()
row = _make_domain()
result = MailService.set_domain_active(row.id, True) # no force needed
assert result['success'] is True
db.session.refresh(row)
assert row.is_active is True
def test_deactivation_is_never_gated(app):
row = _make_domain(active=True)
result = MailService.set_domain_active(row.id, False)
assert result['success'] is True
from app import db
db.session.refresh(row)
assert row.is_active is False
# ---------------------------------------------------------------------------
# DKIM keygen + record builder
# ---------------------------------------------------------------------------
def test_generate_dkim_stores_keys_and_txt_value(app):
from app import db
row = MailDomain(name='dkim.example.com')
db.session.add(row)
db.session.commit()
result = DkimDnsService.generate_dkim(row)
assert result['success'] is True, result
assert result['selector'] == 'serverkit'
assert result['dkim_value'].startswith('v=DKIM1; k=rsa; p=')
db.session.refresh(row)
assert row.dkim_private_key and 'PRIVATE KEY' in row.dkim_private_key
assert row.dkim_public_key
# The stored public key round-trips into the same TXT value.
assert result['dkim_value'].endswith(row.dkim_public_key)
def test_build_records_shapes():
domain = MailDomain(name='Example.COM', dkim_selector='serverkit',
dkim_public_key='PUBKEYB64')
records = DkimDnsService.build_records(domain, server_ip='203.0.113.5')
by_type = {}
for r in records:
by_type.setdefault(r['type'], []).append(r)
mx = by_type['MX'][0]
assert mx['name'] == 'example.com'
assert mx['value'] == '10 mail.example.com'
assert mx['priority'] == 10
txts = {r['name']: r['value'] for r in by_type['TXT']}
assert txts['example.com'] == 'v=spf1 mx a ~all'
assert txts['_dmarc.example.com'].startswith('v=DMARC1; p=quarantine')
assert txts['serverkit._domainkey.example.com'] == 'v=DKIM1; k=rsa; p=PUBKEYB64'
a = by_type['A'][0]
assert a['name'] == 'mail.example.com'
assert a['value'] == '203.0.113.5'
def test_build_records_omits_dkim_and_a_when_absent():
domain = MailDomain(name='bare.example.com', dkim_selector='serverkit',
dkim_public_key=None)
records = DkimDnsService.build_records(domain, server_ip=None)
names = {(r['type'], r['name']) for r in records}
assert ('A', 'mail.bare.example.com') not in names
assert not any(r['name'].startswith('serverkit._domainkey') for r in records)
# ---------------------------------------------------------------------------
# DNS deployment via DNSProviderService
# ---------------------------------------------------------------------------
def _patch_provider(monkeypatch, zone=('cfg', 'zone')):
"""Patch DNSProviderService.find_zone_for_domain + set_record; return the
captured set_record calls."""
from app.services.dns_provider_service import DNSProviderService
calls = []
config = SimpleNamespace(id=7, name='cloudflare')
zone_dict = {'id': 'zone-abc', 'name': 'example.com'}
monkeypatch.setattr(DNSProviderService, 'find_zone_for_domain',
classmethod(lambda cls, name: (config, zone_dict)))
def fake_set_record(cls, provider_id, zone_id, record_type, name, value,
ttl=3600, proxied=False, priority=None, source='provider'):
calls.append({'provider_id': provider_id, 'zone_id': zone_id,
'type': record_type, 'name': name, 'value': value,
'priority': priority, 'source': source})
return {'success': True}
monkeypatch.setattr(DNSProviderService, 'set_record', classmethod(fake_set_record))
return calls, config, zone_dict
def test_deploy_dns_pushes_exact_records(app, monkeypatch):
from app import db
row = MailDomain(name='example.com', dkim_selector='serverkit',
dkim_public_key='PUBKEYB64')
db.session.add(row)
db.session.commit()
calls, config, zone = _patch_provider(monkeypatch)
result = DkimDnsService.deploy_dns(row.id, server_ip='203.0.113.5')
assert result['success'] is True
assert result['deployed'] is True
assert result['manual'] is False
by = {(c['type'], c['name']): c for c in calls}
# Every write is tagged source='mail' for the ownership ledger.
assert all(c['source'] == 'mail' for c in calls)
assert all(c['provider_id'] == 7 and c['zone_id'] == 'zone-abc' for c in calls)
mx = by[('MX', 'example.com')]
assert mx['value'] == '10 mail.example.com'
assert mx['priority'] == 10
assert by[('TXT', 'example.com')]['value'] == 'v=spf1 mx a ~all'
assert by[('TXT', '_dmarc.example.com')]['value'].startswith('v=DMARC1; p=quarantine')
assert by[('TXT', 'serverkit._domainkey.example.com')]['value'] == \
'v=DKIM1; k=rsa; p=PUBKEYB64'
assert by[('A', 'mail.example.com')]['value'] == '203.0.113.5'
# The deployment ledger was recorded on the row.
db.session.refresh(row)
assert row.dns_deployed is True
ledger = json.loads(row.dns_last_result)
assert ledger['all_ok'] is True
assert ledger['provider'] == 'cloudflare'
def test_deploy_dns_no_provider_returns_manual(app, monkeypatch):
from app import db
from app.services.dns_provider_service import DNSProviderService
row = MailDomain(name='noprovider.example.com')
db.session.add(row)
db.session.commit()
monkeypatch.setattr(DNSProviderService, 'find_zone_for_domain',
classmethod(lambda cls, name: (None, None)))
monkeypatch.setattr(
DNSProviderService, 'set_record',
classmethod(lambda *a, **kw: pytest.fail('set_record must not run without a zone')))
result = DkimDnsService.deploy_dns(row.id, server_ip='203.0.113.5')
assert result['success'] is True
assert result['deployed'] is False
assert result['manual'] is True
assert isinstance(result['records'], list) and result['records']
def test_dns_instructions_shape(app):
from app import db
row = MailDomain(name='inst.example.com', dkim_selector='serverkit',
dkim_public_key='PUB')
db.session.add(row)
db.session.commit()
out = DkimDnsService.dns_instructions(row, server_ip='203.0.113.5')
assert out['domain'] == 'inst.example.com'
assert out['dns_deployed'] is False
assert out['dkim_configured'] is False # only public key set here, no private
assert any(r['type'] == 'MX' for r in out['records'])
def test_request_cert_skips_on_windows(monkeypatch):
monkeypatch.setattr(dns_mod.os, 'name', 'nt')
result = DkimDnsService.request_cert('mail.example.com')
assert result['success'] is False
assert result['skipped'] is True
# ---------------------------------------------------------------------------
# fail2ban jail
# ---------------------------------------------------------------------------
def test_jail_unavailable_on_windows(monkeypatch):
monkeypatch.setattr(jail_mod.os, 'name', 'nt')
assert MailJailService.available() is False
# Mutating calls degrade to a skipped descriptor, never raise.
res = MailJailService.enable_auth_jail()
assert res['success'] is True
assert res['skipped'] is True
assert res['available'] is False
def test_jail_enable_writes_serverkit_mail_conf(monkeypatch):
monkeypatch.setattr(jail_mod.os, 'name', 'posix')
monkeypatch.setattr(jail_mod, 'is_command_available', lambda c: True)
writes = []
def fake_run(cmd, timeout=None, input=None, **kwargs):
writes.append({'cmd': list(cmd), 'input': input})
return _proc(returncode=0)
monkeypatch.setattr(jail_mod, 'run_privileged', fake_run)
res = MailJailService.enable_auth_jail()
assert res['success'] is True
assert res['jail'] == 'serverkit-mail-auth'
assert res['path'].endswith('serverkit-mail-auth.conf')
tee = next(w for w in writes if w['cmd'][:1] == ['tee'])
assert tee['cmd'][1].endswith('serverkit-mail-auth.conf')
assert '/etc/fail2ban/jail.d' in tee['cmd'][1]
assert '[serverkit-mail-auth]' in tee['input']
assert 'enabled = true' in tee['input']
# fail2ban was reloaded after the write.
assert any(w['cmd'][:2] == ['fail2ban-client', 'reload'] for w in writes)
def test_jail_status_reports_availability(monkeypatch):
monkeypatch.setattr(jail_mod.os, 'name', 'nt')
info = MailJailService.status()
assert info['available'] is False
assert info['enabled'] is False
assert info['jail'] == 'serverkit-mail-auth'