mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-10-23 03:37:36 +09:00
The panel could already show a user their configuration, put it behind a share link or push it through the Telegram bot. Email was the one channel missing, and the one most admins fall back to a hand-written script for. Adds an SMTP block to /settings (host, port, none/STARTTLS/SSL, credentials, sender, Reply-To, timeout) with a one-click test message, a "send by email" action on every user card, and a mass send from the Users toolbar. What travels: AmneziaWG / WireGuard peers as .conf attachments plus the vpn:// key, Xray and Telemt as links -- their config *is* the link, so a .conf would be a file no client can import. Files and links are independent switches. The config text comes from the same manager call the share page and the bot already use, so a protocol that renders in the UI renders in the mail. The mass send runs in a worker thread over a single SMTP session (a fresh connection per message is what gets a sending account throttled) and the dialog polls its progress: sent, skipped -- no address, disabled, no connections yet -- and failed, each with the reason next to the name. One unreachable node costs that one connection, not the message and not the run. Details worth knowing: - The stored SMTP password is never echoed back to the page; an empty field on save keeps it, a blank one clears it. - Messages are text plus an HTML alternative styled with the panel's own light-theme tokens and appearance title/logo -- no third-party branding. - A multi-line subject is folded instead of raising: EmailMessage refuses linefeeds in headers. - If the SMTP settings go bad between the click and the worker thread, the job finishes with an error rather than staying "running" forever and blocking every later run with a 409. tests/test_mail_service.py covers the settings merge, the transport (STARTTLS, login, reconnect after a dropped session), message shape and attachment naming, the single send and every branch of the bulk counters, plus the API surface (auth, password round-trip, translated errors). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
656 lines
28 KiB
Python
656 lines
28 KiB
Python
"""Emailing configs and proxy links: settings, message shape, single and bulk sends.
|
|
|
|
Everything here runs against fakes -- no SMTP server, no SSH, no data.json --
|
|
because the parts worth guarding are the decisions: which users a mass send
|
|
skips and why, what ends up as an attachment versus a link, and that one dead
|
|
server or one refused address never takes the rest of the run with it.
|
|
"""
|
|
|
|
import copy
|
|
import json
|
|
import os
|
|
import shutil
|
|
import smtplib
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import unittest
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
import app as panel
|
|
from mail_service import (
|
|
DEFAULT_MAIL_SETTINGS,
|
|
MailError,
|
|
MailOptions,
|
|
MailService,
|
|
SmtpTransport,
|
|
build_message,
|
|
is_valid_email,
|
|
merge_mail_settings,
|
|
validate_mail_settings,
|
|
)
|
|
|
|
AWG_CONFIG = '[Interface]\nPrivateKey = aaa=\nAddress = 10.8.1.2/32\n\n[Peer]\nEndpoint = 198.51.100.1:55424\n'
|
|
XRAY_LINK = 'vless://uuid@198.51.100.1:443?type=tcp&security=reality#Phone'
|
|
|
|
|
|
# ===================== fakes =====================
|
|
|
|
class FakeSMTP:
|
|
"""The slice of smtplib the transport actually touches."""
|
|
|
|
def __init__(self, fail_first_send=False, fail_recipients=()):
|
|
self.sent = []
|
|
self.started_tls = False
|
|
self.login_as = None
|
|
self.quit_called = False
|
|
self._fail_first_send = fail_first_send
|
|
self._fail_recipients = set(fail_recipients)
|
|
|
|
def starttls(self, context=None):
|
|
self.started_tls = True
|
|
|
|
def ehlo(self):
|
|
pass
|
|
|
|
def login(self, username, password):
|
|
self.login_as = (username, password)
|
|
|
|
def send_message(self, message):
|
|
if self._fail_first_send:
|
|
self._fail_first_send = False
|
|
raise smtplib.SMTPServerDisconnected('connection reset')
|
|
if message['To'] in self._fail_recipients:
|
|
raise smtplib.SMTPRecipientsRefused({message['To']: (550, b'no such user')})
|
|
self.sent.append(message)
|
|
|
|
def quit(self):
|
|
self.quit_called = True
|
|
|
|
|
|
class FakeSSH:
|
|
def __init__(self, host):
|
|
self.host = host
|
|
self.connects = 0
|
|
|
|
def connect(self):
|
|
self.connects += 1
|
|
|
|
def disconnect(self):
|
|
pass
|
|
|
|
|
|
class FakeManager:
|
|
def __init__(self, configs):
|
|
self.configs = configs
|
|
|
|
def get_client_config(self, protocol, client_id, host, port):
|
|
return self.configs.get(client_id, '')
|
|
|
|
|
|
def base_data():
|
|
return {
|
|
'settings': {
|
|
'appearance': {'title': 'Amnezia', 'logo': 'AW'},
|
|
'mail': dict(DEFAULT_MAIL_SETTINGS, enabled=True, host='smtp.example.test',
|
|
from_email='vpn@example.test', from_name='', username='vpn',
|
|
password='s3cret'),
|
|
},
|
|
'users': [
|
|
{'id': 'u1', 'username': 'alice', 'email': 'alice@example.test', 'enabled': True},
|
|
{'id': 'u2', 'username': 'bob', 'email': '', 'enabled': True},
|
|
{'id': 'u3', 'username': 'carol', 'email': 'carol@example.test', 'enabled': False},
|
|
{'id': 'u4', 'username': 'dave', 'email': 'dave@@example', 'enabled': True},
|
|
],
|
|
'servers': [
|
|
{'name': 'Paris', 'host': '198.51.100.1',
|
|
'protocols': {'awg': {'port': '55424'}, 'xray': {'port': '443'}}},
|
|
{'name': 'Berlin', 'host': '203.0.113.9', 'protocols': {'awg': {'port': '55424'}}},
|
|
],
|
|
'user_connections': [
|
|
{'id': 'c1', 'user_id': 'u1', 'server_id': 0, 'protocol': 'awg',
|
|
'client_id': 'peer-1', 'name': 'Laptop'},
|
|
{'id': 'c2', 'user_id': 'u1', 'server_id': 0, 'protocol': 'xray',
|
|
'client_id': 'peer-2', 'name': 'Phone'},
|
|
{'id': 'c3', 'user_id': 'u3', 'server_id': 0, 'protocol': 'awg',
|
|
'client_id': 'peer-3', 'name': 'Tablet'},
|
|
],
|
|
}
|
|
|
|
|
|
def make_service(data, smtp_client=None, dead_hosts=(), **kwargs):
|
|
"""A MailService wired to fakes. `dead_hosts` refuse SSH like a downed node."""
|
|
client = smtp_client if smtp_client is not None else FakeSMTP()
|
|
configs = {'peer-1': AWG_CONFIG, 'peer-2': XRAY_LINK, 'peer-3': AWG_CONFIG}
|
|
|
|
def get_ssh(server):
|
|
if server['host'] in dead_hosts:
|
|
raise ConnectionError(f"SSH to {server['host']} recently failed")
|
|
return FakeSSH(server['host'])
|
|
|
|
service = MailService(
|
|
load_data=lambda: copy.deepcopy(data),
|
|
get_ssh=get_ssh,
|
|
get_protocol_manager=lambda ssh, protocol: FakeManager(configs),
|
|
manager_call=lambda manager, method, protocol, *a, **kw: getattr(manager, method)(protocol, *a, **kw),
|
|
config_payloads=lambda config, server, protocol: {
|
|
'config': config, 'vpn_link': 'vpn://KEY-' + protocol, 'vpn_qr_chunks': [],
|
|
'vpn_name': protocol},
|
|
protocol_display_name=lambda protocol: protocol.upper(),
|
|
protocol_base=lambda protocol: protocol.rstrip('0123456789') or protocol,
|
|
translate=lambda key, lang='en': key,
|
|
smtp_factory=lambda settings: client,
|
|
**kwargs,
|
|
)
|
|
return service, client
|
|
|
|
|
|
def plain_text(message):
|
|
return message.get_body(preferencelist=('plain',)).get_content()
|
|
|
|
|
|
def html_text(message):
|
|
body = message.get_body(preferencelist=('html',))
|
|
return body.get_content() if body else ''
|
|
|
|
|
|
def attachments(message):
|
|
return [(part.get_filename(), part.get_content()) for part in message.iter_attachments()]
|
|
|
|
|
|
# ===================== settings =====================
|
|
|
|
class MailSettingsTests(unittest.TestCase):
|
|
def test_blank_password_keeps_the_stored_one(self):
|
|
"""The settings page never echoes the password back, so an empty field
|
|
in the payload means "unchanged" -- otherwise every unrelated save
|
|
would silently wipe the SMTP credentials."""
|
|
merged = merge_mail_settings({'password': 'stored'}, {'host': 'smtp.test', 'password': ''})
|
|
self.assertEqual(merged['password'], 'stored')
|
|
|
|
def test_whitespace_password_clears_it(self):
|
|
merged = merge_mail_settings({'password': 'stored'}, {'password': ' '})
|
|
self.assertEqual(merged['password'], '')
|
|
|
|
def test_values_are_trimmed_and_clamped(self):
|
|
merged = merge_mail_settings({}, {'host': ' smtp.test ', 'port': 999999,
|
|
'timeout_seconds': 1, 'from_email': ' vpn@example.test '})
|
|
self.assertEqual(merged['host'], 'smtp.test')
|
|
self.assertEqual(merged['from_email'], 'vpn@example.test')
|
|
self.assertEqual(merged['port'], 65535)
|
|
self.assertEqual(merged['timeout_seconds'], 5)
|
|
|
|
def test_unknown_security_mode_is_ignored(self):
|
|
merged = merge_mail_settings({'security': 'ssl'}, {'security': 'whatever'})
|
|
self.assertEqual(merged['security'], 'ssl')
|
|
|
|
def test_validation_names_the_missing_piece(self):
|
|
with self.assertRaises(MailError) as ctx:
|
|
validate_mail_settings(dict(DEFAULT_MAIL_SETTINGS))
|
|
self.assertEqual(str(ctx.exception), 'mail_error_disabled')
|
|
|
|
with self.assertRaises(MailError) as ctx:
|
|
validate_mail_settings(dict(DEFAULT_MAIL_SETTINGS, enabled=True))
|
|
self.assertEqual(str(ctx.exception), 'mail_error_no_host')
|
|
|
|
with self.assertRaises(MailError) as ctx:
|
|
validate_mail_settings(dict(DEFAULT_MAIL_SETTINGS, enabled=True, host='smtp.test'))
|
|
self.assertEqual(str(ctx.exception), 'mail_error_no_sender')
|
|
|
|
def test_address_check_rejects_the_unusable(self):
|
|
self.assertTrue(is_valid_email('alice@example.test'))
|
|
self.assertFalse(is_valid_email('alice@@example'))
|
|
self.assertFalse(is_valid_email('alice at example.test'))
|
|
self.assertFalse(is_valid_email(''))
|
|
|
|
|
|
# ===================== transport =====================
|
|
|
|
class SmtpTransportTests(unittest.TestCase):
|
|
def settings(self, **kw):
|
|
return dict(DEFAULT_MAIL_SETTINGS, enabled=True, host='smtp.test',
|
|
from_email='vpn@example.test', **kw)
|
|
|
|
def test_starttls_and_login_run_once_per_session(self):
|
|
client = FakeSMTP()
|
|
with SmtpTransport(self.settings(username='vpn', password='pw'),
|
|
lambda settings: client) as transport:
|
|
transport.send(build_stub_message('a@example.test'))
|
|
transport.send(build_stub_message('b@example.test'))
|
|
self.assertTrue(client.started_tls)
|
|
self.assertEqual(client.login_as, ('vpn', 'pw'))
|
|
self.assertEqual(len(client.sent), 2)
|
|
self.assertTrue(client.quit_called)
|
|
|
|
def test_no_login_without_a_username(self):
|
|
client = FakeSMTP()
|
|
with SmtpTransport(self.settings(security='none'), lambda settings: client) as transport:
|
|
transport.send(build_stub_message('a@example.test'))
|
|
self.assertIsNone(client.login_as)
|
|
self.assertFalse(client.started_tls)
|
|
|
|
def test_a_dropped_session_is_reopened_once(self):
|
|
"""Providers close idle sessions mid-run; the message must still go out."""
|
|
clients = []
|
|
|
|
def factory(settings):
|
|
client = FakeSMTP(fail_first_send=not clients)
|
|
clients.append(client)
|
|
return client
|
|
|
|
with SmtpTransport(self.settings(), factory) as transport:
|
|
transport.send(build_stub_message('a@example.test'))
|
|
self.assertEqual(len(clients), 2)
|
|
self.assertEqual(len(clients[1].sent), 1)
|
|
|
|
|
|
def build_stub_message(recipient):
|
|
message, _ = build_message(
|
|
settings={'from_email': 'vpn@example.test', 'from_name': ''},
|
|
recipient=recipient,
|
|
username='alice',
|
|
items=[],
|
|
options=MailOptions(),
|
|
translate=lambda key: key,
|
|
)
|
|
return message
|
|
|
|
|
|
# ===================== message =====================
|
|
|
|
class MessageBuildingTests(unittest.TestCase):
|
|
def items(self):
|
|
return [
|
|
{'name': 'Laptop', 'protocol': 'awg', 'protocol_name': 'AWG', 'server_name': 'Paris',
|
|
'config': AWG_CONFIG, 'link': 'vpn://KEY'},
|
|
{'name': 'Phone', 'protocol': 'xray', 'protocol_name': 'XRAY', 'server_name': 'Paris',
|
|
'config': '', 'link': XRAY_LINK},
|
|
]
|
|
|
|
def build(self, options=None, appearance=None):
|
|
return build_message(
|
|
settings={'from_email': 'vpn@example.test', 'from_name': '', 'reply_to': 'help@example.test'},
|
|
recipient='alice@example.test',
|
|
username='alice',
|
|
items=self.items(),
|
|
options=options or MailOptions(),
|
|
translate=lambda key: key,
|
|
appearance=appearance if appearance is not None else {'title': 'Amnezia', 'logo': 'AW'},
|
|
)
|
|
|
|
def test_files_are_attached_and_links_are_in_the_body(self):
|
|
message, attached = self.build()
|
|
self.assertEqual(attached, 1) # only the AWG peer carries a file
|
|
self.assertEqual(attachments(message), [('Laptop.conf', AWG_CONFIG)])
|
|
text = plain_text(message)
|
|
self.assertIn('vpn://KEY', text)
|
|
self.assertIn(XRAY_LINK, text)
|
|
self.assertIn('Laptop.conf', text)
|
|
|
|
def test_the_html_half_carries_the_panel_appearance(self):
|
|
message, _ = self.build()
|
|
html = html_text(message)
|
|
self.assertIn('AW Amnezia', html)
|
|
self.assertIn('#7c3aed', html) # the panel's accent, inlined
|
|
self.assertIn('vpn://KEY', html)
|
|
|
|
def test_configs_can_be_left_out(self):
|
|
message, attached = self.build(MailOptions(include_configs=False))
|
|
self.assertEqual(attached, 0)
|
|
self.assertEqual(attachments(message), [])
|
|
self.assertIn('vpn://KEY', plain_text(message))
|
|
|
|
def test_links_can_be_left_out(self):
|
|
message, attached = self.build(MailOptions(include_links=False))
|
|
self.assertEqual(attached, 1)
|
|
self.assertNotIn('vpn://KEY', plain_text(message))
|
|
self.assertNotIn(XRAY_LINK, plain_text(message))
|
|
|
|
def test_asking_for_neither_is_refused(self):
|
|
with self.assertRaises(MailError):
|
|
MailOptions(include_configs=False, include_links=False)
|
|
|
|
def test_subject_headers_and_reply_to(self):
|
|
message, _ = self.build()
|
|
self.assertEqual(message['Subject'], 'mail_default_subject — Amnezia')
|
|
self.assertEqual(message['To'], 'alice@example.test')
|
|
self.assertEqual(message['Reply-To'], 'help@example.test')
|
|
self.assertEqual(message['Auto-Submitted'], 'auto-generated')
|
|
|
|
message, _ = self.build(MailOptions(subject='Custom'))
|
|
self.assertEqual(message['Subject'], 'Custom')
|
|
|
|
def test_a_multiline_subject_is_folded_not_fatal(self):
|
|
"""Header values may not carry linefeeds and EmailMessage raises on
|
|
them, so a subject pasted out of a chat must not fail the send."""
|
|
options = MailOptions(subject='Your keys\nand links ')
|
|
self.assertEqual(options.subject, 'Your keys and links')
|
|
message, _ = self.build(options)
|
|
self.assertEqual(message['Subject'], 'Your keys and links')
|
|
|
|
def test_filenames_are_sanitised_and_unique(self):
|
|
items = [
|
|
{'name': 'Laptop / home', 'protocol': 'awg', 'protocol_name': 'AWG', 'server_name': '',
|
|
'config': AWG_CONFIG, 'link': ''},
|
|
{'name': 'Laptop / home', 'protocol': 'awg', 'protocol_name': 'AWG', 'server_name': '',
|
|
'config': AWG_CONFIG, 'link': ''},
|
|
]
|
|
message, attached = build_message(
|
|
settings={'from_email': 'vpn@example.test'}, recipient='alice@example.test',
|
|
username='alice', items=items, options=MailOptions(), translate=lambda key: key)
|
|
self.assertEqual(attached, 2)
|
|
self.assertEqual([name for name, _ in attachments(message)],
|
|
['Laptop_home.conf', 'Laptop_home_2.conf'])
|
|
|
|
def test_an_admin_note_reaches_both_halves(self):
|
|
message, _ = self.build(MailOptions(message='Line one\nLine two'))
|
|
self.assertIn('Line one\nLine two', plain_text(message))
|
|
self.assertIn('Line one<br>Line two', html_text(message))
|
|
|
|
|
|
# ===================== collecting and sending for one user =====================
|
|
|
|
class SendToUserTests(unittest.TestCase):
|
|
def test_a_user_gets_every_connection_they_own(self):
|
|
service, client = make_service(base_data())
|
|
result = service.send_to_user('u1', MailOptions())
|
|
self.assertEqual(result['status'], 'sent')
|
|
self.assertEqual(result['email'], 'alice@example.test')
|
|
self.assertEqual(result['connections'], 2)
|
|
self.assertEqual(result['attachments'], 1)
|
|
self.assertEqual(len(client.sent), 1)
|
|
self.assertEqual(client.sent[0]['To'], 'alice@example.test')
|
|
|
|
def test_a_link_protocol_travels_as_a_link_not_a_file(self):
|
|
service, _ = make_service(base_data())
|
|
data = base_data()
|
|
items, errors = service.collect_items(data, data['users'][0])
|
|
self.assertEqual(errors, [])
|
|
by_name = {item['name']: item for item in items}
|
|
self.assertEqual(by_name['Phone']['config'], '')
|
|
self.assertEqual(by_name['Phone']['link'], XRAY_LINK)
|
|
self.assertEqual(by_name['Laptop']['config'], AWG_CONFIG)
|
|
|
|
def test_a_dead_server_costs_one_connection_not_the_message(self):
|
|
data = base_data()
|
|
data['user_connections'].append({'id': 'c4', 'user_id': 'u1', 'server_id': 1,
|
|
'protocol': 'awg', 'client_id': 'peer-9', 'name': 'Berlin peer'})
|
|
service, client = make_service(data, dead_hosts={'203.0.113.9'})
|
|
result = service.send_to_user('u1', MailOptions())
|
|
self.assertEqual(result['connections'], 2)
|
|
self.assertEqual(len(result['warnings']), 1)
|
|
self.assertIn('Berlin peer', result['warnings'][0])
|
|
self.assertEqual(len(client.sent), 1)
|
|
|
|
def test_the_recipient_can_be_overridden_for_one_send(self):
|
|
service, client = make_service(base_data())
|
|
service.send_to_user('u1', MailOptions(recipient='other@example.test'))
|
|
self.assertEqual(client.sent[0]['To'], 'other@example.test')
|
|
|
|
def test_a_user_without_an_address_is_refused(self):
|
|
service, _ = make_service(base_data())
|
|
with self.assertRaises(MailError) as ctx:
|
|
service.send_to_user('u2', MailOptions())
|
|
self.assertEqual(str(ctx.exception), 'mail_error_no_recipient')
|
|
|
|
def test_a_user_without_connections_is_refused(self):
|
|
data = base_data()
|
|
data['user_connections'] = []
|
|
service, _ = make_service(data)
|
|
with self.assertRaises(MailError) as ctx:
|
|
service.send_to_user('u1', MailOptions())
|
|
self.assertEqual(str(ctx.exception), 'mail_error_no_connections')
|
|
|
|
def test_an_unknown_user_is_a_404(self):
|
|
service, _ = make_service(base_data())
|
|
with self.assertRaises(MailError) as ctx:
|
|
service.send_to_user('nobody', MailOptions())
|
|
self.assertEqual(ctx.exception.status_code, 404)
|
|
|
|
def test_settings_must_be_usable(self):
|
|
data = base_data()
|
|
data['settings']['mail']['enabled'] = False
|
|
service, _ = make_service(data)
|
|
with self.assertRaises(MailError) as ctx:
|
|
service.send_to_user('u1', MailOptions())
|
|
self.assertEqual(str(ctx.exception), 'mail_error_disabled')
|
|
|
|
|
|
# ===================== the mass send =====================
|
|
|
|
class BulkSendTests(unittest.TestCase):
|
|
def run_bulk(self, data, client=None, **kwargs):
|
|
service, client = make_service(data, smtp_client=client)
|
|
service.start_bulk(MailOptions(**kwargs), background=False)
|
|
return service.bulk_status(), client
|
|
|
|
def test_counters_explain_every_user(self):
|
|
"""alice is sent to; bob has no address; carol is disabled; dave's
|
|
address is malformed; a user with no connections is skipped, not failed."""
|
|
data = base_data()
|
|
data['users'].append({'id': 'u5', 'username': 'erin', 'email': 'erin@example.test',
|
|
'enabled': True})
|
|
status, client = self.run_bulk(data)
|
|
self.assertFalse(status['running'])
|
|
self.assertEqual(status['total'], 5)
|
|
self.assertEqual(status['sent'], 1)
|
|
self.assertEqual(status['skipped'], 4)
|
|
self.assertEqual(status['failed'], 0)
|
|
reasons = {row['username']: row.get('reason') for row in status['results']
|
|
if row['status'] == 'skipped'}
|
|
self.assertEqual(reasons['bob'], 'mail_skip_no_email')
|
|
self.assertEqual(reasons['carol'], 'mail_skip_disabled')
|
|
self.assertEqual(reasons['dave'], 'mail_skip_bad_email')
|
|
self.assertEqual(reasons['erin'], 'mail_error_no_connections')
|
|
self.assertEqual([m['To'] for m in client.sent], ['alice@example.test'])
|
|
|
|
def test_disabled_users_are_included_when_asked(self):
|
|
status, client = self.run_bulk(base_data(), only_enabled=False)
|
|
self.assertEqual(status['sent'], 2)
|
|
self.assertEqual(sorted(m['To'] for m in client.sent),
|
|
['alice@example.test', 'carol@example.test'])
|
|
|
|
def test_one_refused_address_does_not_stop_the_run(self):
|
|
client = FakeSMTP(fail_recipients={'alice@example.test'})
|
|
data = base_data()
|
|
data['users'].append({'id': 'u5', 'username': 'erin', 'email': 'erin@example.test',
|
|
'enabled': True})
|
|
data['user_connections'].append({'id': 'c5', 'user_id': 'u5', 'server_id': 0,
|
|
'protocol': 'awg', 'client_id': 'peer-1', 'name': 'Desktop'})
|
|
status, client = self.run_bulk(data, client=client)
|
|
self.assertEqual(status['failed'], 1)
|
|
self.assertEqual(status['sent'], 1)
|
|
self.assertEqual([m['To'] for m in client.sent], ['erin@example.test'])
|
|
|
|
def test_one_session_carries_the_whole_run(self):
|
|
"""Reconnecting per message is what gets a sending account throttled."""
|
|
data = base_data()
|
|
for index in range(3):
|
|
uid = f'bulk-{index}'
|
|
data['users'].append({'id': uid, 'username': uid, 'email': f'{uid}@example.test',
|
|
'enabled': True})
|
|
data['user_connections'].append({'id': f'conn-{index}', 'user_id': uid, 'server_id': 0,
|
|
'protocol': 'awg', 'client_id': 'peer-1', 'name': 'PC'})
|
|
opened = []
|
|
|
|
def factory(settings):
|
|
client = FakeSMTP()
|
|
opened.append(client)
|
|
return client
|
|
|
|
service = make_service(data)[0]
|
|
service._smtp_factory = factory
|
|
service.start_bulk(MailOptions(), background=False)
|
|
self.assertEqual(len(opened), 1)
|
|
self.assertEqual(len(opened[0].sent), 4)
|
|
self.assertTrue(opened[0].quit_called)
|
|
|
|
def test_explicit_user_ids_narrow_the_run(self):
|
|
service, client = make_service(base_data())
|
|
service.start_bulk(MailOptions(), user_ids=['u2'], background=False)
|
|
status = service.bulk_status()
|
|
self.assertEqual(status['total'], 1)
|
|
self.assertEqual(status['skipped'], 1)
|
|
self.assertEqual(client.sent, [])
|
|
|
|
def test_a_second_run_is_refused_while_one_is_in_flight(self):
|
|
release = threading.Event()
|
|
|
|
class BlockingSMTP(FakeSMTP):
|
|
def send_message(self, message):
|
|
release.wait(5)
|
|
super().send_message(message)
|
|
|
|
service, client = make_service(base_data(), smtp_client=BlockingSMTP())
|
|
service.start_bulk(MailOptions())
|
|
try:
|
|
with self.assertRaises(MailError) as ctx:
|
|
service.start_bulk(MailOptions())
|
|
self.assertEqual(ctx.exception.status_code, 409)
|
|
finally:
|
|
release.set()
|
|
deadline = time.time() + 5
|
|
while service.bulk_status()['running'] and time.time() < deadline:
|
|
time.sleep(0.02)
|
|
self.assertFalse(service.bulk_status()['running'])
|
|
self.assertEqual(service.bulk_status()['sent'], 1)
|
|
|
|
def test_a_run_whose_settings_break_mid_flight_still_ends(self):
|
|
"""The worker thread re-reads the settings. If they went bad in between
|
|
the job must finish with an error -- a job stuck on "running" would
|
|
also block every later run with a 409."""
|
|
data = base_data()
|
|
reads = []
|
|
|
|
def load_data():
|
|
reads.append(1)
|
|
snapshot = copy.deepcopy(data)
|
|
if len(reads) > 1: # start_bulk already validated once
|
|
snapshot['settings']['mail']['enabled'] = False
|
|
return snapshot
|
|
|
|
service, _ = make_service(data)
|
|
service._load_data = load_data
|
|
service.start_bulk(MailOptions(), background=False)
|
|
status = service.bulk_status()
|
|
self.assertFalse(status['running'])
|
|
self.assertEqual(status['error'], 'mail_error_disabled')
|
|
|
|
def test_nobody_to_send_to_is_an_error_not_an_empty_run(self):
|
|
data = base_data()
|
|
data['users'] = []
|
|
service, _ = make_service(data)
|
|
with self.assertRaises(MailError) as ctx:
|
|
service.start_bulk(MailOptions(), background=False)
|
|
self.assertEqual(str(ctx.exception), 'mail_error_no_recipients')
|
|
|
|
|
|
# ===================== the API surface =====================
|
|
|
|
class MailApiTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.mkdtemp()
|
|
self._data_file = panel.DATA_FILE
|
|
self._mail_svc = panel.mail_svc
|
|
panel.DATA_FILE = os.path.join(self.tmp, 'data.json')
|
|
with open(panel.DATA_FILE, 'w', encoding='utf-8') as f:
|
|
json.dump({'servers': [], 'users': [], 'user_connections': []}, f)
|
|
self._monitor_flag = panel._conn_monitor_started
|
|
panel._conn_monitor_started = True
|
|
|
|
def tearDown(self):
|
|
panel.DATA_FILE = self._data_file
|
|
panel.mail_svc = self._mail_svc
|
|
panel._conn_monitor_started = self._monitor_flag
|
|
shutil.rmtree(self.tmp, ignore_errors=True)
|
|
|
|
def settings_payload(self, **mail):
|
|
return {
|
|
'appearance': {'title': 'Amnezia', 'logo': 'AW', 'subtitle': 'Web Panel'},
|
|
'sync': {},
|
|
'captcha': {'enabled': False},
|
|
'telegram': {'token': '', 'enabled': False},
|
|
'ssl': {'enabled': False},
|
|
'mail': dict({'enabled': True, 'host': 'smtp.example.test',
|
|
'from_email': 'vpn@example.test', 'username': 'vpn',
|
|
'password': 's3cret'}, **mail),
|
|
}
|
|
|
|
def login(self, client):
|
|
response = client.post('/api/auth/login', json={'username': 'admin', 'password': 'admin'})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
|
|
def test_old_data_files_get_the_mail_block(self):
|
|
self.assertEqual(panel.load_data()['settings']['mail'], dict(DEFAULT_MAIL_SETTINGS))
|
|
|
|
def test_settings_round_trip_keeps_the_password(self):
|
|
with TestClient(panel.app) as client:
|
|
self.login(client)
|
|
saved = client.post('/api/settings/save', json=self.settings_payload())
|
|
self.assertEqual(saved.status_code, 200, saved.text)
|
|
self.assertEqual(panel.load_data()['settings']['mail']['password'], 's3cret')
|
|
|
|
# a later save with the field left empty must not wipe it
|
|
again = client.post('/api/settings/save', json=self.settings_payload(password=''))
|
|
self.assertEqual(again.status_code, 200, again.text)
|
|
stored = panel.load_data()['settings']['mail']
|
|
self.assertEqual(stored['password'], 's3cret')
|
|
self.assertEqual(stored['host'], 'smtp.example.test')
|
|
|
|
def test_every_mail_endpoint_needs_a_session(self):
|
|
with TestClient(panel.app) as client:
|
|
for method, url in (('post', '/api/settings/mail/test'),
|
|
('post', '/api/users/u1/mail/send'),
|
|
('post', '/api/users/mail/bulk'),
|
|
('get', '/api/users/mail/bulk/status')):
|
|
response = (client.get(url) if method == 'get'
|
|
else client.post(url, json={}))
|
|
self.assertEqual(response.status_code, 403, f'{method} {url}: {response.text}')
|
|
|
|
def test_sending_reports_the_error_in_the_admin_language(self):
|
|
with TestClient(panel.app) as client:
|
|
self.login(client)
|
|
data = panel.load_data()
|
|
data['users'].append({'id': 'u9', 'username': 'nomail', 'email': '',
|
|
'role': 'none', 'enabled': True})
|
|
panel.save_data(data)
|
|
client.post('/api/settings/save', json=self.settings_payload())
|
|
|
|
response = client.post('/api/users/u9/mail/send', json={})
|
|
self.assertEqual(response.status_code, 400, response.text)
|
|
body = response.json()
|
|
self.assertEqual(body['code'], 'mail_error_no_recipient')
|
|
self.assertNotEqual(body['error'], body['code']) # translated, not the raw key
|
|
|
|
def test_a_single_send_goes_out_through_the_configured_smtp(self):
|
|
with TestClient(panel.app) as client:
|
|
self.login(client)
|
|
data = panel.load_data()
|
|
data['servers'] = [{'name': 'Paris', 'host': '198.51.100.1', 'ssh_port': 22,
|
|
'username': 'root', 'password': 'x',
|
|
'protocols': {'awg': {'installed': True, 'port': '55424'}}}]
|
|
data['users'].append({'id': 'u9', 'username': 'alice', 'email': 'alice@example.test',
|
|
'role': 'none', 'enabled': True})
|
|
data['user_connections'] = [{'id': 'c9', 'user_id': 'u9', 'server_id': 0,
|
|
'protocol': 'awg', 'client_id': 'peer-1', 'name': 'Laptop'}]
|
|
panel.save_data(data)
|
|
client.post('/api/settings/save', json=self.settings_payload())
|
|
|
|
service, smtp = make_service(panel.load_data())
|
|
panel.mail_svc = service
|
|
|
|
response = client.post('/api/users/u9/mail/send',
|
|
json={'include_configs': True, 'include_links': True})
|
|
self.assertEqual(response.status_code, 200, response.text)
|
|
self.assertEqual(response.json()['email'], 'alice@example.test')
|
|
self.assertEqual([m['To'] for m in smtp.sent], ['alice@example.test'])
|
|
|
|
status = client.get('/api/users/mail/bulk/status')
|
|
self.assertEqual(status.status_code, 200, status.text)
|
|
self.assertFalse(status.json()['running'])
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|