mirror of
https://github.com/PRVTPRO/Amnezia-Web-Panel.git
synced 2026-10-23 03:37:36 +09:00
feat(mail): email configs and proxy links to one user or to everyone
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>
This commit is contained in:
1 parent
02c1182284
commit
71aea08c39
9 files changed
+1980
-3
No files matched your search
@@ -104,6 +104,13 @@ Configuration panel for system parameters and preferences:
|
||||
* **Remnawave Sync**: Automatically import and sync users from Remnawave.
|
||||
* **Simple Backup**: Effortless JSON-based export and restore of all panel data.
|
||||
* **Backup / Migrate protocols (Alpha)**: Move protocol configurations between nodes for maintenance, recovery, and migration workflows.
|
||||
* **📧 Email Delivery of Configurations**:
|
||||
* Send one user their configuration files and proxy links straight from their card, or mail every user that has an address in a single run.
|
||||
* AmneziaWG / WireGuard peers travel as `.conf` attachments plus the `vpn://` key; Xray and Telemt travel as links, since their config *is* the link. Files and links are independent switches — send either or both.
|
||||
* SMTP is configured in `/settings` (host, port, none/STARTTLS/SSL, credentials, sender name and address, Reply-To, timeout) with a one-click test message. The stored password is never echoed back to the page: leaving the field empty on save keeps it.
|
||||
* A mass send runs in the background over a single SMTP session and reports progress per user — sent, skipped (no address, disabled, no connections yet) or failed, with the reason next to each name. One unreachable node costs that one connection, not the message and not the run.
|
||||
* Messages are text plus an HTML alternative dressed in the panel's own appearance (title, logo, colours) — no third-party branding anywhere.
|
||||
|
||||
* **🔗 Public Sharing**:
|
||||
* Generate password-protected links for users to download their configurations without panel access.
|
||||
* **🔐 Self-Service Security**:
|
||||
@@ -299,6 +306,7 @@ curl -H "Authorization: Bearer $TOKEN" http://your-panel:5000/api/servers/0/ping
|
||||
web-panel/
|
||||
├── app.py # FastAPI entry point + all routes
|
||||
├── telegram_bot.py # Optional Telegram bot integration
|
||||
├── mail_service.py # SMTP delivery of configs/links (single and bulk)
|
||||
├── managers/ # Protocol & service managers (one file per protocol)
|
||||
│ ├── ssh_manager.py # SSH abstraction (Paramiko wrapper)
|
||||
│ ├── awg_manager.py # AmneziaWG / AWG 2.0 / AWG Legacy
|
||||
@@ -322,6 +330,7 @@ web-panel/
|
||||
* **SSH Keys**: Use SSH keys rather than passwords for connecting to your VPN servers.
|
||||
* **Secret Key**: Set a custom `SECRET_KEY` environment variable for secure session management (see [Environment Variables](#-environment-variables)).
|
||||
* **IPv6**: if your servers have global IPv6 but Docker is IPv4-only, leave `AWG_IPV6` at `auto` — the panel probes the container and keeps tunnels IPv4-only rather than blackholing client IPv6. Set `AWG_IPV6=off` to disable dual-stack everywhere.
|
||||
* **SMTP Password**: stored in `data.json` next to the other credentials. Use a mailbox dedicated to the panel (or an app password), not your personal account, and keep `data.json` readable only by the user the panel runs as.
|
||||
* **API Tokens**: Treat each token like a password — store it in your integration's secret manager. Revoke it from `/settings` if it leaks or the integration is decommissioned. Rotate periodically; tokens inherit admin rights.
|
||||
|
||||
## 📱 Progressive Web App (PWA)
|
||||
|
||||
@@ -49,6 +49,13 @@ from managers.backup_manager import BackupManager
|
||||
import telegram_bot as tg_bot
|
||||
|
||||
from exit_link_service import ExitLinkError, ExitLinkService
|
||||
from mail_service import (
|
||||
DEFAULT_MAIL_SETTINGS,
|
||||
MailError,
|
||||
MailOptions,
|
||||
MailService,
|
||||
merge_mail_settings,
|
||||
)
|
||||
from pwa import build_manifest
|
||||
from connection_service import (
|
||||
ConnectionService,
|
||||
@@ -74,6 +81,7 @@ OPENAPI_TAGS = [
|
||||
{"name": "Self-service", "description": "Endpoints called by a regular user for their own data (the /my surface)."},
|
||||
{"name": "Sharing", "description": "Public, token-protected configuration sharing for end users — no panel session required."},
|
||||
{"name": "Settings", "description": "Panel-wide settings, Telegram bot, Remnawave sync, JSON backup/restore."},
|
||||
{"name": "Email", "description": "SMTP delivery of configs and proxy links — to one user or to every user with an address."},
|
||||
{"name": "API Tokens", "description": "Bearer tokens for external integrations. Send the token in `Authorization: Bearer <token>`; tokens have admin-equivalent rights and are tied to the admin user that created them."},
|
||||
]
|
||||
|
||||
@@ -234,6 +242,9 @@ def load_data():
|
||||
settings.setdefault('captcha', {'enabled': False})
|
||||
settings.setdefault('exit_nodes', {'default_exit_uid': ''})
|
||||
settings.setdefault('telegram', {'token': '', 'enabled': False})
|
||||
mail = settings.setdefault('mail', dict(DEFAULT_MAIL_SETTINGS))
|
||||
for key, value in DEFAULT_MAIL_SETTINGS.items():
|
||||
mail.setdefault(key, value)
|
||||
settings.setdefault('ssl', {
|
||||
'enabled': False,
|
||||
'domain': '',
|
||||
@@ -1559,6 +1570,25 @@ self_service_connections = ConnectionService(
|
||||
)
|
||||
|
||||
|
||||
mail_svc = MailService(
|
||||
load_data=load_data,
|
||||
get_ssh=get_ssh,
|
||||
get_protocol_manager=get_protocol_manager,
|
||||
manager_call=_manager_call,
|
||||
config_payloads=config_payloads,
|
||||
protocol_display_name=protocol_display_name,
|
||||
protocol_base=protocol_base,
|
||||
translate=_t,
|
||||
)
|
||||
|
||||
|
||||
def _mail_error_response(exc: MailError, lang: str):
|
||||
"""MailError carries a translation key, so the browser gets the message in
|
||||
the admin's language and the machine-readable key next to it."""
|
||||
return JSONResponse({'error': _t(str(exc), lang), 'code': str(exc)},
|
||||
status_code=exc.status_code)
|
||||
|
||||
|
||||
def _exit_manager_factory(ssh):
|
||||
from managers.exit_manager import ExitManager
|
||||
return ExitManager(ssh)
|
||||
@@ -2286,6 +2316,20 @@ class TelegramSettings(BaseModel):
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
class MailSettings(BaseModel):
|
||||
enabled: bool = False
|
||||
host: str = ''
|
||||
port: int = Field(587, ge=1, le=65535)
|
||||
security: str = 'starttls' # none | starttls | ssl
|
||||
username: str = ''
|
||||
# Empty keeps the stored password: the settings page never echoes it back.
|
||||
password: str = ''
|
||||
from_email: str = ''
|
||||
from_name: str = ''
|
||||
reply_to: str = ''
|
||||
timeout_seconds: int = Field(30, ge=5, le=300)
|
||||
|
||||
|
||||
class AutoBackupSettings(BaseModel):
|
||||
enabled: bool = False
|
||||
interval_hours: int = 24
|
||||
@@ -2334,6 +2378,7 @@ class SaveSettingsRequest(BaseModel):
|
||||
auto_backup: AutoBackupSettings = AutoBackupSettings()
|
||||
self_service: SelfServiceSettings = SelfServiceSettings()
|
||||
exit_nodes: ExitNodesSettings = ExitNodesSettings()
|
||||
mail: MailSettings = MailSettings()
|
||||
|
||||
|
||||
class ToggleUserRequest(BaseModel):
|
||||
@@ -2364,6 +2409,30 @@ class AddUserConnectionRequest(BaseModel):
|
||||
telemt_max_conns: Optional[int] = None
|
||||
|
||||
|
||||
class MailSendRequest(BaseModel):
|
||||
include_configs: bool = True
|
||||
include_links: bool = True
|
||||
subject: str = ''
|
||||
message: str = ''
|
||||
# Overrides the address on the user record for this one send.
|
||||
recipient: Optional[str] = None
|
||||
|
||||
|
||||
class MailBulkRequest(BaseModel):
|
||||
include_configs: bool = True
|
||||
include_links: bool = True
|
||||
subject: str = ''
|
||||
message: str = ''
|
||||
# None means "every user that has an address".
|
||||
user_ids: Optional[List[str]] = None
|
||||
only_enabled: bool = True
|
||||
skip_without_connections: bool = True
|
||||
|
||||
|
||||
class MailTestRequest(BaseModel):
|
||||
recipient: str = ''
|
||||
|
||||
|
||||
class CreateApiTokenRequest(BaseModel):
|
||||
name: str
|
||||
|
||||
@@ -5548,6 +5617,70 @@ def api_my_connection_config(request: Request, connection_id: str):
|
||||
return JSONResponse({'error': str(e)}, status_code=500)
|
||||
|
||||
|
||||
# ======================== EMAIL DELIVERY (admin) ========================
|
||||
|
||||
@app.post('/api/settings/mail/test', tags=["Email"])
|
||||
async def api_mail_test(request: Request, req: MailTestRequest):
|
||||
"""Send a one-liner through the stored SMTP settings to prove they work."""
|
||||
admin = _check_admin(request)
|
||||
if not admin:
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
lang = request.cookies.get('lang', 'en')
|
||||
recipient = (req.recipient or '').strip() or str(admin.get('email') or '').strip()
|
||||
try:
|
||||
return await asyncio.to_thread(mail_svc.send_test, recipient, lang)
|
||||
except MailError as exc:
|
||||
return _mail_error_response(exc, lang)
|
||||
except Exception as exc:
|
||||
logger.exception("Mail: test send failed")
|
||||
return JSONResponse({'error': str(exc)}, status_code=502)
|
||||
|
||||
|
||||
@app.post('/api/users/mail/bulk', tags=["Email"])
|
||||
def api_mail_send_bulk(request: Request, req: MailBulkRequest):
|
||||
"""Start a mass send. Returns immediately with the initial job state --
|
||||
reading a few hundred configs over SSH takes minutes, far longer than a
|
||||
browser waits, so the run continues in a worker thread and the page polls
|
||||
/api/users/mail/bulk/status."""
|
||||
admin = _check_admin(request)
|
||||
if not admin:
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
lang = request.cookies.get('lang', 'en')
|
||||
try:
|
||||
options = MailOptions.from_dict(req.dict())
|
||||
return mail_svc.start_bulk(options, lang=lang, user_ids=req.user_ids,
|
||||
started_by=admin.get('username', ''))
|
||||
except MailError as exc:
|
||||
return _mail_error_response(exc, lang)
|
||||
except Exception as exc:
|
||||
logger.exception("Mail: bulk send failed to start")
|
||||
return JSONResponse({'error': str(exc)}, status_code=500)
|
||||
|
||||
|
||||
@app.get('/api/users/mail/bulk/status', tags=["Email"])
|
||||
def api_mail_bulk_status(request: Request):
|
||||
"""Progress of the current (or last) mass send."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
return mail_svc.bulk_status()
|
||||
|
||||
|
||||
@app.post('/api/users/{user_id}/mail/send', tags=["Email"])
|
||||
async def api_mail_send_user(request: Request, user_id: str, req: MailSendRequest):
|
||||
"""Email one user their configs and/or proxy links."""
|
||||
if not _check_admin(request):
|
||||
return JSONResponse({'error': 'Forbidden'}, status_code=403)
|
||||
lang = request.cookies.get('lang', 'en')
|
||||
try:
|
||||
options = MailOptions.from_dict(req.dict())
|
||||
return await asyncio.to_thread(mail_svc.send_to_user, user_id, options, lang)
|
||||
except MailError as exc:
|
||||
return _mail_error_response(exc, lang)
|
||||
except Exception as exc:
|
||||
logger.exception("Mail: send to user failed")
|
||||
return JSONResponse({'error': str(exc)}, status_code=502)
|
||||
|
||||
|
||||
@app.get('/settings', tags=["System Templates"])
|
||||
def settings_page(request: Request):
|
||||
user = _check_admin(request)
|
||||
@@ -5691,6 +5824,7 @@ def save_settings(request: Request, payload: SaveSettingsRequest):
|
||||
settings['captcha'] = payload.captcha.dict()
|
||||
settings['telegram'] = payload.telegram.dict()
|
||||
settings['ssl'] = payload.ssl.dict()
|
||||
settings['mail'] = merge_mail_settings(settings.get('mail'), payload.mail.dict())
|
||||
|
||||
old_auto_backup = settings.get('auto_backup', {}) or {}
|
||||
interval_hours = max(1, min(24, int(payload.auto_backup.interval_hours or 24)))
|
||||
|
||||
+700
@@ -0,0 +1,700 @@
|
||||
"""Email delivery of client configurations and proxy links.
|
||||
|
||||
Everything the panel already hands a user by hand -- the `.conf` file of a
|
||||
WireGuard-style peer, the `vpn://` key built from it, the `vless://` / `tg://`
|
||||
link of a proxy -- this module puts into an email instead: to a single user
|
||||
from their card, or to every user that has an address in one background run.
|
||||
|
||||
Nothing about a protocol is re-derived here. The config text comes from the
|
||||
same manager call the share page and the Telegram bot use, and the link from
|
||||
the same `config_payloads()`, so a protocol that renders correctly in the UI
|
||||
renders correctly in an email. The only protocol-shaped decision made locally
|
||||
is whether a connection travels as an attachment or as a link: Xray and Telemt
|
||||
*are* links (their "config" is the URL itself), everything else is a file plus
|
||||
the `vpn://` key generated from it.
|
||||
|
||||
Every message goes out as text plus an HTML alternative. The HTML half wears
|
||||
the panel's own design tokens -- the light-theme colours, radius and type scale
|
||||
from `static/css/style.css`, and the appearance title/logo the admin set -- and
|
||||
nothing from anywhere else. The text half is what mail providers score for spam
|
||||
and what a `vpn://` key survives intact, so it is never dropped.
|
||||
"""
|
||||
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
import smtplib
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr, formatdate, make_msgid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEFAULT_MAIL_SETTINGS = {
|
||||
'enabled': False,
|
||||
'host': '',
|
||||
'port': 587,
|
||||
'security': 'starttls', # none | starttls | ssl
|
||||
'username': '',
|
||||
'password': '',
|
||||
'from_email': '',
|
||||
'from_name': '',
|
||||
'reply_to': '',
|
||||
'timeout_seconds': 30,
|
||||
}
|
||||
|
||||
SECURITY_CHOICES = ('none', 'starttls', 'ssl')
|
||||
|
||||
# Protocols whose "config" is already a link: nothing to attach, and wrapping
|
||||
# them in a .conf file would only give the user a file no client can import.
|
||||
LINK_ONLY_PROTOCOLS = ('xray', 'telemt')
|
||||
|
||||
# Enough for a user with a peer on every server; a bulk run that tried to
|
||||
# attach more than this hit something other than a normal account.
|
||||
MAX_ATTACHMENTS_PER_MESSAGE = 50
|
||||
|
||||
# The panel's light theme (static/css/style.css), inlined: mail clients strip
|
||||
# <style> blocks and know nothing about CSS variables, so the tokens have to
|
||||
# travel as literal values on each element.
|
||||
PANEL_STYLE = {
|
||||
'font': "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
'page_bg': '#f5f5f9',
|
||||
'card_bg': '#ffffff',
|
||||
'text': '#1a1a2e',
|
||||
'muted': '#6b6b83',
|
||||
'accent': '#7c3aed',
|
||||
'border': 'rgba(0, 0, 0, 0.08)',
|
||||
'radius': '12px',
|
||||
}
|
||||
|
||||
# Deliberately loose: we reject the obviously unusable (no @, spaces, no dot in
|
||||
# the domain) and leave the rest to the SMTP server, which is the only party
|
||||
# that actually knows whether an address exists.
|
||||
EMAIL_RE = re.compile(r'^[^@\s,;<>]+@[^@\s,;<>]+\.[^@\s,;<>]+$')
|
||||
|
||||
|
||||
class MailError(Exception):
|
||||
"""Something the caller can fix: no SMTP settings, no recipient, no configs."""
|
||||
|
||||
def __init__(self, message, status_code=400):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
# ===================== settings =====================
|
||||
|
||||
def mail_settings(data):
|
||||
"""The mail block of data.json, with every default filled in."""
|
||||
stored = (data.get('settings', {}) or {}).get('mail', {}) or {}
|
||||
merged = dict(DEFAULT_MAIL_SETTINGS)
|
||||
for key in DEFAULT_MAIL_SETTINGS:
|
||||
if key in stored:
|
||||
merged[key] = stored[key]
|
||||
return merged
|
||||
|
||||
|
||||
def _clamp_int(value, fallback, low, high):
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
return max(low, min(high, number))
|
||||
|
||||
|
||||
def merge_mail_settings(current, incoming):
|
||||
"""Fold a settings-form payload onto the stored block.
|
||||
|
||||
The password is the one field the settings page never echoes back (it
|
||||
would put the SMTP password in the page source of every admin session),
|
||||
so an empty password in the payload means "keep the stored one". Clearing
|
||||
it is an explicit act: send a single space.
|
||||
"""
|
||||
merged = dict(DEFAULT_MAIL_SETTINGS)
|
||||
for key in DEFAULT_MAIL_SETTINGS:
|
||||
if key in (current or {}):
|
||||
merged[key] = (current or {})[key]
|
||||
|
||||
incoming = incoming or {}
|
||||
for key in ('host', 'username', 'from_email', 'from_name', 'reply_to'):
|
||||
if key in incoming:
|
||||
merged[key] = str(incoming.get(key) or '').strip()
|
||||
if 'enabled' in incoming:
|
||||
merged['enabled'] = bool(incoming.get('enabled'))
|
||||
if 'port' in incoming:
|
||||
merged['port'] = _clamp_int(incoming.get('port'), merged['port'] or 587, 1, 65535)
|
||||
if 'timeout_seconds' in incoming:
|
||||
merged['timeout_seconds'] = _clamp_int(
|
||||
incoming.get('timeout_seconds'), merged['timeout_seconds'] or 30, 5, 300)
|
||||
if 'security' in incoming:
|
||||
security = str(incoming.get('security') or '').strip().lower()
|
||||
if security in SECURITY_CHOICES:
|
||||
merged['security'] = security
|
||||
if 'password' in incoming:
|
||||
password = str(incoming.get('password') or '')
|
||||
if password.strip():
|
||||
merged['password'] = password
|
||||
elif password: # a whitespace-only value is the explicit clear
|
||||
merged['password'] = ''
|
||||
return merged
|
||||
|
||||
|
||||
def is_valid_email(value):
|
||||
return bool(EMAIL_RE.match(str(value or '').strip()))
|
||||
|
||||
|
||||
def validate_mail_settings(settings):
|
||||
"""Raise unless the block can actually send. Returns the settings."""
|
||||
if not settings.get('enabled'):
|
||||
raise MailError('mail_error_disabled')
|
||||
if not str(settings.get('host') or '').strip():
|
||||
raise MailError('mail_error_no_host')
|
||||
if not is_valid_email(settings.get('from_email')):
|
||||
raise MailError('mail_error_no_sender')
|
||||
if str(settings.get('security') or '') not in SECURITY_CHOICES:
|
||||
raise MailError('mail_error_bad_security')
|
||||
return settings
|
||||
|
||||
|
||||
# ===================== SMTP transport =====================
|
||||
|
||||
def default_smtp_factory(settings):
|
||||
"""A connected smtplib client for these settings (no login yet)."""
|
||||
host = str(settings.get('host') or '').strip()
|
||||
port = int(settings.get('port') or 587)
|
||||
timeout = int(settings.get('timeout_seconds') or 30)
|
||||
if settings.get('security') == 'ssl':
|
||||
return smtplib.SMTP_SSL(host, port, timeout=timeout, context=ssl.create_default_context())
|
||||
return smtplib.SMTP(host, port, timeout=timeout)
|
||||
|
||||
|
||||
class SmtpTransport:
|
||||
"""One SMTP session, reused for a whole run.
|
||||
|
||||
A bulk send to a few hundred users over a fresh connection per message is
|
||||
both slow and the shape of traffic that gets an account rate-limited, so
|
||||
the session is opened once and handed every message. Providers still drop
|
||||
idle or long sessions, hence the single reconnect-and-retry on a dropped
|
||||
connection -- anything worse is the caller's problem to report per user.
|
||||
"""
|
||||
|
||||
def __init__(self, settings, smtp_factory=None):
|
||||
self.settings = settings
|
||||
self._factory = smtp_factory or default_smtp_factory
|
||||
self.client = None
|
||||
self.sent = 0
|
||||
|
||||
def __enter__(self):
|
||||
self.open()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
def open(self):
|
||||
if self.client is not None:
|
||||
return self.client
|
||||
client = self._factory(self.settings)
|
||||
try:
|
||||
if self.settings.get('security') == 'starttls':
|
||||
client.starttls(context=ssl.create_default_context())
|
||||
client.ehlo()
|
||||
username = str(self.settings.get('username') or '').strip()
|
||||
if username:
|
||||
client.login(username, str(self.settings.get('password') or ''))
|
||||
except Exception:
|
||||
try:
|
||||
client.quit()
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
self.client = client
|
||||
return client
|
||||
|
||||
def send(self, message):
|
||||
client = self.open()
|
||||
try:
|
||||
client.send_message(message)
|
||||
except smtplib.SMTPServerDisconnected:
|
||||
logger.info("SMTP session dropped, reconnecting for one retry")
|
||||
self.close()
|
||||
self.open().send_message(message)
|
||||
self.sent += 1
|
||||
|
||||
def close(self):
|
||||
client, self.client = self.client, None
|
||||
if client is None:
|
||||
return
|
||||
try:
|
||||
client.quit()
|
||||
except Exception:
|
||||
try:
|
||||
client.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ===================== message building =====================
|
||||
|
||||
def _safe_filename(name, protocol, used):
|
||||
base = re.sub(r'[^A-Za-z0-9_.-]+', '_', str(name or protocol or 'config')).strip('._-')
|
||||
base = (base or 'config')[:48]
|
||||
candidate = f'{base}.conf'
|
||||
index = 2
|
||||
while candidate.lower() in used:
|
||||
candidate = f'{base}_{index}.conf'
|
||||
index += 1
|
||||
used.add(candidate.lower())
|
||||
return candidate
|
||||
|
||||
|
||||
def _paragraphs(text):
|
||||
return [line for line in str(text or '').splitlines()]
|
||||
|
||||
|
||||
class MailOptions:
|
||||
"""What the admin ticked in the send dialog."""
|
||||
|
||||
def __init__(self, include_configs=True, include_links=True, subject='', message='',
|
||||
recipient='', only_enabled=True, skip_without_connections=True):
|
||||
self.include_configs = bool(include_configs)
|
||||
self.include_links = bool(include_links)
|
||||
# Header values may not carry linefeeds, and EmailMessage raises on
|
||||
# them -- fold a pasted multi-line subject instead of failing the send.
|
||||
self.subject = ' '.join(str(subject or '').split())
|
||||
self.message = str(message or '').strip()
|
||||
self.recipient = str(recipient or '').strip()
|
||||
self.only_enabled = bool(only_enabled)
|
||||
self.skip_without_connections = bool(skip_without_connections)
|
||||
if not self.include_configs and not self.include_links:
|
||||
raise MailError('mail_error_nothing_to_send')
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, raw):
|
||||
raw = raw or {}
|
||||
return cls(
|
||||
include_configs=raw.get('include_configs', True),
|
||||
include_links=raw.get('include_links', True),
|
||||
subject=raw.get('subject', ''),
|
||||
message=raw.get('message', ''),
|
||||
recipient=raw.get('recipient', ''),
|
||||
only_enabled=raw.get('only_enabled', True),
|
||||
skip_without_connections=raw.get('skip_without_connections', True),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'include_configs': self.include_configs,
|
||||
'include_links': self.include_links,
|
||||
'subject': self.subject,
|
||||
'message': self.message,
|
||||
'only_enabled': self.only_enabled,
|
||||
'skip_without_connections': self.skip_without_connections,
|
||||
}
|
||||
|
||||
|
||||
def _panel_header(appearance):
|
||||
"""The panel's own logo and title, as it shows them in its header."""
|
||||
title = str((appearance or {}).get('title') or '').strip()
|
||||
logo = str((appearance or {}).get('logo') or '').strip()
|
||||
return ' '.join(part for part in (logo, title) if part)
|
||||
|
||||
|
||||
def build_message(*, settings, recipient, username, items, options, translate, appearance=None):
|
||||
"""The email itself: plain text, an HTML alternative and the attachments."""
|
||||
msg = EmailMessage()
|
||||
header = _panel_header(appearance)
|
||||
panel_title = str((appearance or {}).get('title') or '').strip()
|
||||
from_email = str(settings.get('from_email') or '').strip()
|
||||
from_name = str(settings.get('from_name') or '').strip() or panel_title
|
||||
msg['From'] = formataddr((from_name, from_email)) if from_name else from_email
|
||||
msg['To'] = recipient
|
||||
reply_to = str(settings.get('reply_to') or '').strip()
|
||||
if reply_to:
|
||||
msg['Reply-To'] = reply_to
|
||||
subject = options.subject or translate('mail_default_subject')
|
||||
if not options.subject and panel_title:
|
||||
subject = f'{subject} \u2014 {panel_title}'
|
||||
msg['Subject'] = subject
|
||||
msg['Date'] = formatdate(localtime=True)
|
||||
domain = from_email.split('@')[-1] if '@' in from_email else None
|
||||
msg['Message-ID'] = make_msgid(domain=domain) if domain else make_msgid()
|
||||
# Auto-generated mail: keep vacation responders and other robots quiet.
|
||||
msg['Auto-Submitted'] = 'auto-generated'
|
||||
|
||||
s = PANEL_STYLE
|
||||
lines = []
|
||||
html_parts = [
|
||||
f'<div style="background:{s["page_bg"]};padding:24px 12px;font-family:{s["font"]};">',
|
||||
f'<div style="max-width:560px;margin:0 auto;background:{s["card_bg"]};'
|
||||
f'border:1px solid {s["border"]};border-radius:{s["radius"]};padding:24px;'
|
||||
f'color:{s["text"]};font-size:15px;line-height:1.55;">',
|
||||
]
|
||||
if header:
|
||||
lines.append(header)
|
||||
lines.append('')
|
||||
html_parts.append(
|
||||
f'<div style="font-size:18px;font-weight:700;color:{s["accent"]};'
|
||||
f'margin:0 0 16px 0;">{html.escape(header)}</div>'
|
||||
)
|
||||
|
||||
greeting = translate('mail_greeting').replace('{}', username)
|
||||
lines.extend([greeting, ''])
|
||||
html_parts.append(f'<p style="margin:0 0 12px 0;">{html.escape(greeting)}</p>')
|
||||
|
||||
if options.message:
|
||||
lines.extend(_paragraphs(options.message))
|
||||
lines.append('')
|
||||
body = '<br>'.join(html.escape(line) for line in _paragraphs(options.message))
|
||||
html_parts.append(f'<p style="margin:0 0 12px 0;">{body}</p>')
|
||||
|
||||
if items:
|
||||
intro = translate('mail_intro_connections')
|
||||
lines.extend([intro, ''])
|
||||
html_parts.append(f'<p style="margin:0 0 16px 0;">{html.escape(intro)}</p>')
|
||||
|
||||
attachments = []
|
||||
used_names = set()
|
||||
for item in items:
|
||||
heading = f"{item['name']} - {item['protocol_name']}"
|
||||
if item.get('server_name'):
|
||||
heading += f" ({item['server_name']})"
|
||||
lines.append(heading)
|
||||
html_parts.append(
|
||||
f'<div style="margin:0 0 12px 0;padding:12px 14px;border:1px solid {s["border"]};'
|
||||
f'border-radius:{s["radius"]};">'
|
||||
f'<div style="font-weight:600;">{html.escape(heading)}</div>'
|
||||
)
|
||||
if options.include_links and item.get('link'):
|
||||
label = translate('mail_link_label')
|
||||
lines.append(f" {label}: {item['link']}")
|
||||
html_parts.append(
|
||||
f'<div style="margin-top:8px;color:{s["muted"]};font-size:13px;">'
|
||||
f'{html.escape(label)}</div>'
|
||||
f'<div style="word-break:break-all;font-family:ui-monospace,SFMono-Regular,Consolas,monospace;'
|
||||
f'font-size:13px;color:{s["accent"]};">{html.escape(item["link"])}</div>'
|
||||
)
|
||||
if options.include_configs and item.get('config') and len(attachments) < MAX_ATTACHMENTS_PER_MESSAGE:
|
||||
filename = _safe_filename(item.get('name'), item.get('protocol'), used_names)
|
||||
attachments.append((filename, item['config']))
|
||||
label = translate('mail_file_label')
|
||||
lines.append(f" {label}: {filename}")
|
||||
html_parts.append(
|
||||
f'<div style="margin-top:8px;color:{s["muted"]};font-size:13px;">'
|
||||
f'{html.escape(label)}: {html.escape(filename)}</div>'
|
||||
)
|
||||
lines.append('')
|
||||
html_parts.append('</div>')
|
||||
|
||||
if not items:
|
||||
note = translate('mail_no_connections_note')
|
||||
lines.extend([note, ''])
|
||||
html_parts.append(f'<p style="margin:0 0 12px 0;">{html.escape(note)}</p>')
|
||||
|
||||
footer = translate('mail_footer')
|
||||
lines.append(footer)
|
||||
html_parts.append(
|
||||
f'<p style="margin:20px 0 0 0;color:{s["muted"]};font-size:13px;">{html.escape(footer)}</p>'
|
||||
'</div></div>'
|
||||
)
|
||||
|
||||
msg.set_content('\n'.join(lines))
|
||||
msg.add_alternative(''.join(html_parts), subtype='html')
|
||||
for filename, config in attachments:
|
||||
# text/plain, not application/octet-stream: every mail client then shows
|
||||
# a preview instead of a file it warns the user about.
|
||||
msg.add_attachment(config.encode('utf-8'), maintype='text', subtype='plain', filename=filename)
|
||||
return msg, len(attachments)
|
||||
|
||||
|
||||
# ===================== the service =====================
|
||||
|
||||
class BulkJob:
|
||||
"""State of one mass send, polled by the UI."""
|
||||
|
||||
def __init__(self, total, options, started_by=''):
|
||||
self._lock = threading.Lock()
|
||||
self._state = {
|
||||
'running': True,
|
||||
'total': total,
|
||||
'sent': 0,
|
||||
'failed': 0,
|
||||
'skipped': 0,
|
||||
'started_at': datetime.now().isoformat(),
|
||||
'finished_at': None,
|
||||
'started_by': started_by,
|
||||
'error': '',
|
||||
'options': options.to_dict(),
|
||||
'results': [],
|
||||
}
|
||||
|
||||
def record(self, entry):
|
||||
with self._lock:
|
||||
status = entry.get('status')
|
||||
if status in ('sent', 'failed', 'skipped'):
|
||||
self._state[status] += 1
|
||||
self._state['results'].append(entry)
|
||||
|
||||
def finish(self, error=''):
|
||||
with self._lock:
|
||||
self._state['running'] = False
|
||||
self._state['finished_at'] = datetime.now().isoformat()
|
||||
if error:
|
||||
self._state['error'] = str(error)
|
||||
|
||||
def snapshot(self):
|
||||
with self._lock:
|
||||
state = dict(self._state)
|
||||
state['results'] = list(self._state['results'])
|
||||
return state
|
||||
|
||||
|
||||
class MailService:
|
||||
"""Sends configs and links by email, to one user or to all of them.
|
||||
|
||||
Every panel-specific operation is injected, which keeps this module free of
|
||||
FastAPI and of the SSH layer: the tests drive it with plain fakes.
|
||||
"""
|
||||
|
||||
def __init__(self, load_data, get_ssh, get_protocol_manager, manager_call,
|
||||
config_payloads, protocol_display_name, protocol_base,
|
||||
translate=None, smtp_factory=None):
|
||||
self._load_data = load_data
|
||||
self._get_ssh = get_ssh
|
||||
self._get_protocol_manager = get_protocol_manager
|
||||
self._manager_call = manager_call
|
||||
self._config_payloads = config_payloads
|
||||
self._protocol_display_name = protocol_display_name
|
||||
self._protocol_base = protocol_base
|
||||
self._translate = translate or (lambda key, lang='en': key)
|
||||
self._smtp_factory = smtp_factory
|
||||
self._job_lock = threading.Lock()
|
||||
self._job = None
|
||||
|
||||
# ---------- helpers ----------
|
||||
|
||||
def _t(self, lang):
|
||||
return lambda key: self._translate(key, lang)
|
||||
|
||||
def settings(self, data=None):
|
||||
return mail_settings(data if data is not None else self._load_data())
|
||||
|
||||
def _appearance(self, data):
|
||||
return (data.get('settings', {}) or {}).get('appearance', {}) or {}
|
||||
|
||||
def _find_user(self, data, user_id):
|
||||
user = next((u for u in data.get('users', []) if u.get('id') == user_id), None)
|
||||
if not user:
|
||||
raise MailError('mail_error_user_not_found', status_code=404)
|
||||
return user
|
||||
|
||||
def collect_items(self, data, user):
|
||||
"""Config text and link for every connection the user owns.
|
||||
|
||||
Unreachable servers are reported, not raised: an admin sending to 200
|
||||
users should not lose the run because one node is down, and the user
|
||||
whose peer lives there is told which connection is missing.
|
||||
"""
|
||||
items, errors = [], []
|
||||
servers = data.get('servers', [])
|
||||
conns = [c for c in data.get('user_connections', []) if c.get('user_id') == user.get('id')]
|
||||
for conn in conns:
|
||||
sid = conn.get('server_id', 0)
|
||||
protocol = conn.get('protocol', 'awg')
|
||||
name = conn.get('name') or self._protocol_display_name(protocol)
|
||||
if sid >= len(servers):
|
||||
errors.append(f"{name}: server {sid} not found")
|
||||
continue
|
||||
server = servers[sid]
|
||||
try:
|
||||
proto_info = (server.get('protocols', {}) or {}).get(protocol, {}) or {}
|
||||
port = proto_info.get('port', '55424')
|
||||
ssh = self._get_ssh(server)
|
||||
ssh.connect()
|
||||
try:
|
||||
manager = self._get_protocol_manager(ssh, protocol)
|
||||
config = self._manager_call(
|
||||
manager, 'get_client_config', protocol,
|
||||
conn.get('client_id'), server.get('host'), port)
|
||||
finally:
|
||||
ssh.disconnect()
|
||||
except Exception as exc:
|
||||
logger.warning(f"Mail: cannot read config for {name} ({protocol}): {exc}")
|
||||
errors.append(f"{name}: {exc}")
|
||||
continue
|
||||
if not str(config or '').strip():
|
||||
errors.append(f"{name}: empty config")
|
||||
continue
|
||||
|
||||
item = {
|
||||
'connection_id': conn.get('id', ''),
|
||||
'name': name,
|
||||
'protocol': protocol,
|
||||
'protocol_name': self._protocol_display_name(protocol),
|
||||
'server_name': server.get('name') or server.get('host', ''),
|
||||
'config': '',
|
||||
'link': '',
|
||||
}
|
||||
if self._protocol_base(protocol) in LINK_ONLY_PROTOCOLS:
|
||||
item['link'] = str(config).strip()
|
||||
else:
|
||||
payloads = self._config_payloads(config, server, protocol)
|
||||
item['config'] = payloads.get('config') or config
|
||||
item['link'] = payloads.get('vpn_link') or ''
|
||||
items.append(item)
|
||||
return items, errors
|
||||
|
||||
# ---------- single user ----------
|
||||
|
||||
def send_to_user(self, user_id, options, lang='en', transport=None):
|
||||
data = self._load_data()
|
||||
settings = validate_mail_settings(self.settings(data))
|
||||
user = self._find_user(data, user_id)
|
||||
recipient = options.recipient or str(user.get('email') or '').strip()
|
||||
if not is_valid_email(recipient):
|
||||
raise MailError('mail_error_no_recipient')
|
||||
|
||||
items, errors = self.collect_items(data, user)
|
||||
if not items and options.skip_without_connections:
|
||||
raise MailError('mail_error_no_connections')
|
||||
|
||||
message, attached = build_message(
|
||||
settings=settings,
|
||||
recipient=recipient,
|
||||
username=user.get('username', ''),
|
||||
items=items,
|
||||
options=options,
|
||||
translate=self._t(lang),
|
||||
appearance=self._appearance(data),
|
||||
)
|
||||
if transport is not None:
|
||||
transport.send(message)
|
||||
else:
|
||||
with SmtpTransport(settings, self._smtp_factory) as smtp:
|
||||
smtp.send(message)
|
||||
logger.info(f"Mail: sent {attached} attachment(s) to {recipient}")
|
||||
return {
|
||||
'status': 'sent',
|
||||
'user_id': user.get('id'),
|
||||
'username': user.get('username', ''),
|
||||
'email': recipient,
|
||||
'attachments': attached,
|
||||
'links': sum(1 for i in items if i.get('link')) if options.include_links else 0,
|
||||
'connections': len(items),
|
||||
'warnings': errors,
|
||||
}
|
||||
|
||||
# ---------- everyone ----------
|
||||
|
||||
def _bulk_recipients(self, data, options, user_ids=None):
|
||||
"""(user, reason) pairs: reason empty means "send"."""
|
||||
wanted = set(user_ids or [])
|
||||
out = []
|
||||
for user in data.get('users', []):
|
||||
if wanted and user.get('id') not in wanted:
|
||||
continue
|
||||
email = str(user.get('email') or '').strip()
|
||||
if not email:
|
||||
out.append((user, 'mail_skip_no_email'))
|
||||
elif not is_valid_email(email):
|
||||
out.append((user, 'mail_skip_bad_email'))
|
||||
elif options.only_enabled and user.get('enabled') is False:
|
||||
out.append((user, 'mail_skip_disabled'))
|
||||
else:
|
||||
out.append((user, ''))
|
||||
return out
|
||||
|
||||
def bulk_status(self):
|
||||
with self._job_lock:
|
||||
job = self._job
|
||||
return job.snapshot() if job else {'running': False, 'total': 0, 'sent': 0,
|
||||
'failed': 0, 'skipped': 0, 'results': []}
|
||||
|
||||
def start_bulk(self, options, lang='en', user_ids=None, started_by='', background=True):
|
||||
"""Kick off a mass send. One run at a time, progress polled from the UI."""
|
||||
data = self._load_data()
|
||||
validate_mail_settings(self.settings(data))
|
||||
targets = self._bulk_recipients(data, options, user_ids)
|
||||
if not targets:
|
||||
raise MailError('mail_error_no_recipients')
|
||||
|
||||
with self._job_lock:
|
||||
if self._job is not None and self._job.snapshot().get('running'):
|
||||
raise MailError('mail_error_bulk_running', status_code=409)
|
||||
job = BulkJob(total=len(targets), options=options, started_by=started_by)
|
||||
self._job = job
|
||||
|
||||
if background:
|
||||
threading.Thread(target=self._run_bulk, args=(job, targets, options, lang),
|
||||
name='mail-bulk', daemon=True).start()
|
||||
else:
|
||||
self._run_bulk(job, targets, options, lang)
|
||||
return job.snapshot()
|
||||
|
||||
def _run_bulk(self, job, targets, options, lang):
|
||||
# Everything lives inside the try: a settings block that went bad
|
||||
# between the click and this thread must finish the job with an error,
|
||||
# not leave it "running" forever and block every later run with a 409.
|
||||
transport = None
|
||||
try:
|
||||
settings = validate_mail_settings(self.settings())
|
||||
transport = SmtpTransport(settings, self._smtp_factory)
|
||||
for user, skip_reason in targets:
|
||||
entry = {'user_id': user.get('id'), 'username': user.get('username', ''),
|
||||
'email': str(user.get('email') or '').strip()}
|
||||
if skip_reason:
|
||||
job.record({**entry, 'status': 'skipped', 'reason': skip_reason})
|
||||
continue
|
||||
try:
|
||||
result = self.send_to_user(user.get('id'), options, lang=lang, transport=transport)
|
||||
job.record({**entry, 'status': 'sent',
|
||||
'attachments': result['attachments'],
|
||||
'connections': result['connections'],
|
||||
'warnings': result['warnings']})
|
||||
except MailError as exc:
|
||||
# "no connections yet" is a property of the account, not a
|
||||
# failure of the run: it lands in skipped so the counters
|
||||
# keep meaning what they say.
|
||||
status = 'skipped' if str(exc) == 'mail_error_no_connections' else 'failed'
|
||||
job.record({**entry, 'status': status, 'reason': str(exc)})
|
||||
except Exception as exc:
|
||||
logger.exception(f"Mail: bulk send failed for {entry['email']}")
|
||||
job.record({**entry, 'status': 'failed', 'reason': str(exc)})
|
||||
except Exception as exc:
|
||||
logger.exception("Mail: bulk run aborted")
|
||||
job.finish(error=str(exc))
|
||||
return
|
||||
finally:
|
||||
if transport is not None:
|
||||
transport.close()
|
||||
job.finish()
|
||||
|
||||
# ---------- smoke test from the settings page ----------
|
||||
|
||||
def send_test(self, recipient, lang='en'):
|
||||
data = self._load_data()
|
||||
settings = validate_mail_settings(self.settings(data))
|
||||
recipient = str(recipient or '').strip()
|
||||
if not is_valid_email(recipient):
|
||||
raise MailError('mail_error_no_recipient')
|
||||
translate = self._t(lang)
|
||||
appearance = self._appearance(data)
|
||||
panel_title = str(appearance.get('title') or '').strip()
|
||||
msg = EmailMessage()
|
||||
from_email = str(settings.get('from_email') or '').strip()
|
||||
from_name = str(settings.get('from_name') or '').strip() or panel_title
|
||||
msg['From'] = formataddr((from_name, from_email)) if from_name else from_email
|
||||
msg['To'] = recipient
|
||||
subject = translate('mail_test_subject')
|
||||
msg['Subject'] = f'{subject} \u2014 {panel_title}' if panel_title else subject
|
||||
msg['Date'] = formatdate(localtime=True)
|
||||
msg['Auto-Submitted'] = 'auto-generated'
|
||||
msg.set_content(translate('mail_test_body'))
|
||||
started = time.time()
|
||||
with SmtpTransport(settings, self._smtp_factory) as smtp:
|
||||
smtp.send(msg)
|
||||
return {'status': 'sent', 'email': recipient, 'took_ms': int((time.time() - started) * 1000)}
|
||||
@@ -187,6 +187,7 @@
|
||||
window.ICON_UPLOAD = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>';
|
||||
window.ICON_DOCS = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/></svg>';
|
||||
window.ICON_CHECK = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||
window.ICON_MAIL = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 7 10 6 10-6"/></svg>';
|
||||
window.ICON_X = '<svg class="btn-icon-svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>';
|
||||
|
||||
/* ===== Theme Toggle ===== */
|
||||
|
||||
+124
-1
@@ -15,6 +15,7 @@
|
||||
<a href="#settings-appearance">{{ _('appearance') }}</a>
|
||||
<a href="#settings-captcha">{{ _('captcha_title') }}</a>
|
||||
<a href="#settings-telegram">{{ _('telegram_bot_title') }}</a>
|
||||
<a href="#settings-mail">{{ _('mail_title') }}</a>
|
||||
<a href="#settings-tunnels">{{ _('tunnels_title') }}</a>
|
||||
<a href="#settings-ssl">{{ _('ssl_title') }}</a>
|
||||
<a href="#settings-import">{{ _('import_users_title') }}</a>
|
||||
@@ -91,6 +92,90 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- BLOCK: Email (SMTP) -->
|
||||
<div class="card" id="settings-mail">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px; vertical-align: -2px;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 7 10 6 10-6"/></svg>{{ _('mail_title') }}</h3>
|
||||
<form id="mailForm">
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center; gap: var(--space-sm); cursor: pointer;">
|
||||
<input type="checkbox" id="mail_enabled" {% if settings.mail.enabled %}checked{% endif %}>
|
||||
{{ _('mail_enabled_label') }}
|
||||
</label>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_host_label') }}</label>
|
||||
<input type="text" class="form-input" id="mail_host" value="{{ settings.mail.host or '' }}"
|
||||
placeholder="smtp.example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_port_label') }}</label>
|
||||
<input type="number" class="form-input" id="mail_port" min="1" max="65535"
|
||||
value="{{ settings.mail.port | default(587) }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_security_label') }}</label>
|
||||
<select class="form-select" id="mail_security">
|
||||
<option value="none" {% if settings.mail.security == 'none' %}selected{% endif %}>{{ _('mail_security_none') }}</option>
|
||||
<option value="starttls" {% if settings.mail.security == 'starttls' %}selected{% endif %}>{{ _('mail_security_starttls') }}</option>
|
||||
<option value="ssl" {% if settings.mail.security == 'ssl' %}selected{% endif %}>{{ _('mail_security_ssl') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_timeout_label') }}</label>
|
||||
<input type="number" class="form-input" id="mail_timeout" min="5" max="300"
|
||||
value="{{ settings.mail.timeout_seconds | default(30) }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_username_label') }}</label>
|
||||
<input type="text" class="form-input" id="mail_username" value="{{ settings.mail.username or '' }}"
|
||||
placeholder="{{ _('optional') }}" autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_password_label') }}</label>
|
||||
<input type="password" class="form-input" id="mail_password" value="" placeholder="********"
|
||||
autocomplete="new-password">
|
||||
<div class="form-hint">{{ _('mail_password_hint') }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_from_label') }}</label>
|
||||
<input type="email" class="form-input" id="mail_from" value="{{ settings.mail.from_email or '' }}"
|
||||
placeholder="vpn@example.com">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_from_name_label') }}</label>
|
||||
<input type="text" class="form-input" id="mail_from_name" value="{{ settings.mail.from_name or '' }}"
|
||||
placeholder="{{ _('optional') }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_reply_to_label') }}</label>
|
||||
<input type="email" class="form-input" id="mail_reply_to" value="{{ settings.mail.reply_to or '' }}"
|
||||
placeholder="{{ _('optional') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_test_recipient_label') }}</label>
|
||||
<div style="display: flex; gap: var(--space-sm); flex-wrap: wrap;">
|
||||
<input type="email" class="form-input" id="mail_test_recipient" style="flex: 1; min-width: 180px;"
|
||||
value="{{ current_user.email or '' }}" placeholder="admin@example.com">
|
||||
<button type="button" class="btn btn-secondary" onclick="sendTestMail()" id="mailTestBtn">
|
||||
<span id="mailTestBtnText">{{ _('mail_test_btn') }}</span>
|
||||
<div class="spinner hidden" id="mailTestSpinner" style="width:14px;height:14px;"></div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="form-hint">{{ _('mail_test_hint') }}</div>
|
||||
</div>
|
||||
<p class="form-hint">{{ _('mail_hint') }}</p>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- BLOCK 4: API Docs -->
|
||||
<div class="card">
|
||||
<h3 class="card-title" style="margin-bottom: var(--space-lg);">{{ _('api_docs_title') }}</h3>
|
||||
@@ -948,11 +1033,26 @@
|
||||
|
||||
const exit_nodes = { default_exit_uid: document.getElementById('exitDefaultNode').value || '' };
|
||||
|
||||
// The password field is intentionally never pre-filled; an empty value
|
||||
// tells the server to keep the stored one.
|
||||
const mail = {
|
||||
enabled: document.getElementById('mail_enabled').checked,
|
||||
host: document.getElementById('mail_host').value.trim(),
|
||||
port: parseInt(document.getElementById('mail_port').value || '587'),
|
||||
security: document.getElementById('mail_security').value,
|
||||
username: document.getElementById('mail_username').value.trim(),
|
||||
password: document.getElementById('mail_password').value,
|
||||
from_email: document.getElementById('mail_from').value.trim(),
|
||||
from_name: document.getElementById('mail_from_name').value.trim(),
|
||||
reply_to: document.getElementById('mail_reply_to').value.trim(),
|
||||
timeout_seconds: parseInt(document.getElementById('mail_timeout').value || '30')
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/settings/save', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, auto_backup, self_service, exit_nodes })
|
||||
body: JSON.stringify({ appearance, sync, captcha, telegram, ssl, mail, auto_backup, self_service, exit_nodes })
|
||||
});
|
||||
if (!res.ok) throw new Error(_('error'));
|
||||
const saved = await res.json();
|
||||
@@ -967,6 +1067,29 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTestMail() {
|
||||
const btn = document.getElementById('mailTestBtn');
|
||||
const spinner = document.getElementById('mailTestSpinner');
|
||||
const recipient = document.getElementById('mail_test_recipient').value.trim();
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
const res = await fetch('/api/settings/mail/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ recipient })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || _('error'));
|
||||
showToast(_('mail_test_sent').replace('{}', data.email), 'success');
|
||||
} catch (err) {
|
||||
showToast(_('error') + ': ' + err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
spinner.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleBot() {
|
||||
const btn = document.getElementById('toggleBotBtn');
|
||||
const text = document.getElementById('toggleBotBtnText');
|
||||
|
||||
@@ -19,6 +19,10 @@
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<div class="page-header__actions">
|
||||
<button class="btn btn-secondary" onclick="openBulkMailModal()" id="bulkMailBtn">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="margin-right: 6px;"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m2 7 10 6 10-6"/></svg>
|
||||
{{ _('mail_send_all_btn') }}
|
||||
</button>
|
||||
<button class="btn btn-primary" onclick="openModal('addUserModal')">
|
||||
<span>+</span> {{ _('add_user') }}
|
||||
</button>
|
||||
@@ -490,6 +494,153 @@
|
||||
}[ch]));
|
||||
}
|
||||
|
||||
|
||||
/* ===== Send configs and links by email ===== */
|
||||
let mailMode = 'single';
|
||||
let mailUserId = null;
|
||||
let mailPollTimer = null;
|
||||
|
||||
function mailOptionsFromForm() {
|
||||
return {
|
||||
include_configs: document.getElementById('mailIncludeConfigs').checked,
|
||||
include_links: document.getElementById('mailIncludeLinks').checked,
|
||||
subject: document.getElementById('mailSubject').value.trim(),
|
||||
message: document.getElementById('mailMessage').value
|
||||
};
|
||||
}
|
||||
|
||||
function resetMailModal() {
|
||||
document.getElementById('mailSubject').value = '';
|
||||
document.getElementById('mailMessage').value = '';
|
||||
document.getElementById('mailIncludeConfigs').checked = true;
|
||||
document.getElementById('mailIncludeLinks').checked = true;
|
||||
document.getElementById('mailProgress').classList.add('hidden');
|
||||
document.getElementById('mailSendBtn').disabled = false;
|
||||
}
|
||||
|
||||
function openMailModal(dataset) {
|
||||
mailMode = 'single';
|
||||
mailUserId = dataset.id;
|
||||
stopMailPolling();
|
||||
resetMailModal();
|
||||
document.getElementById('mailModalTitle').textContent = `${_('mail_send_title')} — ${dataset.username}`;
|
||||
document.getElementById('mailRecipientGroup').style.display = '';
|
||||
document.getElementById('mailRecipient').value = dataset.email || '';
|
||||
document.getElementById('mailBulkTargets').style.display = 'none';
|
||||
document.getElementById('mailBulkOptions').style.display = 'none';
|
||||
openModal('mailModal');
|
||||
}
|
||||
|
||||
function openBulkMailModal() {
|
||||
mailMode = 'bulk';
|
||||
mailUserId = null;
|
||||
resetMailModal();
|
||||
document.getElementById('mailModalTitle').textContent = _('mail_send_all_title');
|
||||
document.getElementById('mailRecipientGroup').style.display = 'none';
|
||||
document.getElementById('mailBulkTargets').style.display = '';
|
||||
document.getElementById('mailBulkOptions').style.display = '';
|
||||
openModal('mailModal');
|
||||
// A run started earlier (or from another browser tab) keeps going on the
|
||||
// server: show where it is and, if it is still going, follow it.
|
||||
refreshBulkMailStatus().then(status => {
|
||||
if (status && status.running) startMailPolling();
|
||||
});
|
||||
}
|
||||
|
||||
async function submitMail() {
|
||||
const btn = document.getElementById('mailSendBtn');
|
||||
const spinner = document.getElementById('mailSendSpinner');
|
||||
const options = mailOptionsFromForm();
|
||||
if (!options.include_configs && !options.include_links) {
|
||||
showToast(_('mail_error_nothing_to_send'), 'error');
|
||||
return;
|
||||
}
|
||||
btn.disabled = true;
|
||||
spinner.classList.remove('hidden');
|
||||
try {
|
||||
if (mailMode === 'single') {
|
||||
options.recipient = document.getElementById('mailRecipient').value.trim();
|
||||
const res = await apiCall(`/api/users/${mailUserId}/mail/send`, 'POST', options);
|
||||
showToast(_('mail_sent_ok').replace('{}', res.email), 'success');
|
||||
closeModal('mailModal');
|
||||
} else {
|
||||
options.only_enabled = document.getElementById('mailOnlyEnabled').checked;
|
||||
options.skip_without_connections = document.getElementById('mailSkipEmpty').checked;
|
||||
await apiCall('/api/users/mail/bulk', 'POST', options);
|
||||
showToast(_('mail_bulk_started'), 'success');
|
||||
startMailPolling();
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(`${_('error')}: ` + err.message, 'error');
|
||||
} finally {
|
||||
spinner.classList.add('hidden');
|
||||
// The status poll disables it again while a run is in flight; this
|
||||
// keeps a refused start (409, bad settings) from freezing the button.
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startMailPolling() {
|
||||
stopMailPolling();
|
||||
mailPollTimer = setInterval(() => refreshBulkMailStatus(true), 2000);
|
||||
refreshBulkMailStatus(true);
|
||||
}
|
||||
|
||||
function stopMailPolling() {
|
||||
if (mailPollTimer) {
|
||||
clearInterval(mailPollTimer);
|
||||
mailPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// `announce` is on only while we are following a run we started: opening
|
||||
// the dialog must show the last run's numbers without toasting them again.
|
||||
async function refreshBulkMailStatus(announce = false) {
|
||||
let status;
|
||||
try {
|
||||
status = await apiCall('/api/users/mail/bulk/status');
|
||||
} catch (err) {
|
||||
stopMailPolling();
|
||||
return null;
|
||||
}
|
||||
const done = (status.sent || 0) + (status.skipped || 0) + (status.failed || 0);
|
||||
const total = status.total || 0;
|
||||
if (!total) return status;
|
||||
|
||||
const box = document.getElementById('mailProgress');
|
||||
box.classList.remove('hidden');
|
||||
document.getElementById('mailProgressText').textContent =
|
||||
_('mail_bulk_progress').replace('{}', done).replace('{}', total);
|
||||
document.getElementById('mailProgressCounts').textContent =
|
||||
_('mail_bulk_summary').replace('{}', status.sent || 0)
|
||||
.replace('{}', status.skipped || 0).replace('{}', status.failed || 0);
|
||||
document.getElementById('mailProgressBar').style.width = `${total ? (done / total) * 100 : 0}%`;
|
||||
document.getElementById('mailSendBtn').disabled = !!status.running;
|
||||
|
||||
// Counters say how many; this says who, and why they were left out.
|
||||
const leftOut = (status.results || []).filter(row => row.status !== 'sent');
|
||||
document.getElementById('mailResults').innerHTML = leftOut.map(row => `
|
||||
<div><span class="badge badge-${row.status === 'failed' ? 'danger' : 'secondary'}"
|
||||
style="font-size:0.65rem;">${_('mail_status_' + row.status)}</span>
|
||||
${escapeHtml(row.username || row.email || '')} — ${escapeHtml(_(row.reason || ''))}</div>
|
||||
`).join('');
|
||||
|
||||
if (!status.running) {
|
||||
stopMailPolling();
|
||||
if (!announce) {
|
||||
return status;
|
||||
}
|
||||
if (status.error) {
|
||||
showToast(`${_('error')}: ${status.error}`, 'error');
|
||||
} else if (done) {
|
||||
const summary = _('mail_bulk_summary').replace('{}', status.sent || 0)
|
||||
.replace('{}', status.skipped || 0).replace('{}', status.failed || 0);
|
||||
showToast(summary, (status.failed || 0) ? 'warning' : 'success');
|
||||
}
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
function escapeJs(value) {
|
||||
return String(value ?? '').replace(/[\\'"\n\r<>]/g, ch => ({
|
||||
'\\': '\\\\', "'": "\\'", '"': '\\"', '\n': '\\n', '\r': '\\r', '<': '\\x3c', '>': '\\x3e'
|
||||
@@ -587,6 +738,10 @@
|
||||
data-enabled="${u.share_enabled ? 'true' : 'false'}"
|
||||
data-haspassword="${u.has_share_password ? 'true' : 'false'}"
|
||||
title="${_('share_access')}">📤</button>
|
||||
<button class="btn btn-secondary btn-sm btn-icon" onclick="openMailModal(this.dataset)"
|
||||
data-id="${u.id}" data-username="${u.username}" data-email="${u.email || ''}"
|
||||
${u.email ? '' : 'disabled'}
|
||||
title="${u.email ? _('mail_send_btn') : _('mail_no_email_hint')}">${ICON_MAIL}</button>
|
||||
${u.id !== "{{ current_user.id }}" ? `
|
||||
<button class="btn btn-secondary btn-sm btn-icon peer-toggle${u.enabled ? '' : ' peer-toggle-off'}" onclick="toggleUser(this.dataset, this)"
|
||||
data-id="${u.id}" data-enable="${u.enabled ? 'false' : 'true'}"
|
||||
@@ -1249,6 +1404,74 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Send by email ===== -->
|
||||
<div class="modal-backdrop" id="mailModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title" id="mailModalTitle">{{ _('mail_send_title') }}</h2>
|
||||
<button class="modal-close" onclick="closeModal('mailModal')">×</button>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="mailRecipientGroup">
|
||||
<label class="form-label">{{ _('mail_recipient_label') }}</label>
|
||||
<input class="form-input" type="email" id="mailRecipient" placeholder="user@example.com">
|
||||
</div>
|
||||
|
||||
<p class="form-hint" id="mailBulkTargets" style="display:none;">{{ _('mail_bulk_targets') }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
||||
<input type="checkbox" id="mailIncludeConfigs" checked> {{ _('mail_include_configs') }}
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer; margin-top:var(--space-sm);">
|
||||
<input type="checkbox" id="mailIncludeLinks" checked> {{ _('mail_include_links') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group" id="mailBulkOptions" style="display:none;">
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer;">
|
||||
<input type="checkbox" id="mailOnlyEnabled" checked> {{ _('mail_only_enabled') }}
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:var(--space-sm); cursor:pointer; margin-top:var(--space-sm);">
|
||||
<input type="checkbox" id="mailSkipEmpty" checked> {{ _('mail_skip_empty') }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_subject_label') }}</label>
|
||||
<input class="form-input" type="text" id="mailSubject" placeholder="{{ _('mail_subject_placeholder') }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label">{{ _('mail_message_label') }}</label>
|
||||
<textarea class="form-input" id="mailMessage" rows="3"
|
||||
placeholder="{{ _('mail_message_placeholder') }}"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="mailProgress" class="hidden" style="margin-bottom: var(--space-md);">
|
||||
<div style="display:flex; justify-content:space-between; font-size:0.8rem; color:var(--text-muted);">
|
||||
<span id="mailProgressText"></span>
|
||||
<span id="mailProgressCounts"></span>
|
||||
</div>
|
||||
<div style="width:100%; height:6px; background:var(--bg-tertiary); border-radius:3px; overflow:hidden; margin-top:4px;">
|
||||
<div id="mailProgressBar" style="height:100%; width:0%; background:var(--accent); transition:width 0.3s ease;"></div>
|
||||
</div>
|
||||
<p class="form-hint" style="margin-top:var(--space-sm);">{{ _('mail_bulk_running_note') }}</p>
|
||||
<div id="mailResults" style="max-height:140px; overflow:auto; margin-top:var(--space-sm);
|
||||
font-size:0.78rem; color:var(--text-muted);"></div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<div style="display: flex; gap: var(--space-md); justify-content: flex-end;">
|
||||
<button class="btn btn-secondary" onclick="closeModal('mailModal')">{{ _('cancel') }}</button>
|
||||
<button class="btn btn-primary" onclick="submitMail()" id="mailSendBtn">
|
||||
<span id="mailSendBtnText">{{ _('mail_send_action') }}</span>
|
||||
<div class="spinner hidden" id="mailSendSpinner"></div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentSharedUser = null;
|
||||
|
||||
|
||||
@@ -0,0 +1,655 @@
|
||||
"""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()
|
||||
+67
-1
@@ -606,5 +606,71 @@
|
||||
"stop_peer_title": "Stop",
|
||||
"start_peer_title": "Enable",
|
||||
"ssh_cooldown_title": "SSH cooldown (sec)",
|
||||
"ssh_cooldown_hint": "Initial delay before reconnecting to an unreachable server. Do not change if the server works fine."
|
||||
"ssh_cooldown_hint": "Initial delay before reconnecting to an unreachable server. Do not change if the server works fine.",
|
||||
"mail_title": "Email delivery (SMTP)",
|
||||
"mail_hint": "Configs and proxy links are sent from this mailbox. The password is stored in data.json, so use a dedicated account or an app password.",
|
||||
"mail_enabled_label": "Enable email delivery",
|
||||
"mail_host_label": "SMTP server",
|
||||
"mail_port_label": "Port",
|
||||
"mail_security_label": "Encryption",
|
||||
"mail_security_none": "None",
|
||||
"mail_security_starttls": "STARTTLS",
|
||||
"mail_security_ssl": "SSL/TLS",
|
||||
"mail_username_label": "Username",
|
||||
"mail_password_label": "Password",
|
||||
"mail_password_hint": "Leave empty to keep the stored password; type a space to clear it.",
|
||||
"mail_from_label": "Sender address",
|
||||
"mail_from_name_label": "Sender name",
|
||||
"mail_reply_to_label": "Reply-To (opt.)",
|
||||
"mail_timeout_label": "Timeout (sec)",
|
||||
"mail_test_btn": "Send test message",
|
||||
"mail_test_recipient_label": "Test recipient",
|
||||
"mail_test_sent": "Test message sent to {}",
|
||||
"mail_send_btn": "Send by email",
|
||||
"mail_send_title": "Send configurations by email",
|
||||
"mail_send_all_btn": "Email all users",
|
||||
"mail_send_all_title": "Email every user",
|
||||
"mail_recipient_label": "Recipient",
|
||||
"mail_include_configs": "Attach configuration files",
|
||||
"mail_include_links": "Include connection links",
|
||||
"mail_subject_label": "Subject",
|
||||
"mail_subject_placeholder": "Leave empty for the default subject",
|
||||
"mail_message_label": "Message (optional)",
|
||||
"mail_message_placeholder": "A few words shown above the connections",
|
||||
"mail_only_enabled": "Skip disabled users",
|
||||
"mail_skip_empty": "Skip users without connections",
|
||||
"mail_send_action": "Send",
|
||||
"mail_sent_ok": "Sent to {}",
|
||||
"mail_bulk_started": "Mass send started",
|
||||
"mail_bulk_targets": "Recipients: every user with an email address",
|
||||
"mail_bulk_progress": "{} of {} processed",
|
||||
"mail_bulk_summary": "Sent: {}, skipped: {}, errors: {}",
|
||||
"mail_bulk_running_note": "You can close this window — the send keeps running.",
|
||||
"mail_no_email_hint": "This user has no email address",
|
||||
"mail_status_sent": "sent",
|
||||
"mail_status_skipped": "skipped",
|
||||
"mail_status_failed": "error",
|
||||
"mail_greeting": "Hello, {}!",
|
||||
"mail_default_subject": "Your VPN access",
|
||||
"mail_intro_connections": "Below are your connections. Import the attached file into the VPN client, or open the link on your device.",
|
||||
"mail_link_label": "Link",
|
||||
"mail_file_label": "Attached file",
|
||||
"mail_no_connections_note": "There are no connections on your account yet — please contact your administrator.",
|
||||
"mail_footer": "Sent automatically by the VPN panel. Please do not reply to this message.",
|
||||
"mail_test_subject": "Test message from the VPN panel",
|
||||
"mail_test_body": "The SMTP settings work: this test message was sent by the VPN panel.",
|
||||
"mail_error_disabled": "Email delivery is disabled in settings",
|
||||
"mail_error_no_host": "SMTP server is not set",
|
||||
"mail_error_no_sender": "Sender address is missing or invalid",
|
||||
"mail_error_bad_security": "Unknown encryption mode",
|
||||
"mail_error_no_recipient": "No valid recipient address",
|
||||
"mail_error_no_recipients": "No users with an email address",
|
||||
"mail_error_no_connections": "This user has no connections to send",
|
||||
"mail_error_user_not_found": "User not found",
|
||||
"mail_error_nothing_to_send": "Choose files, links, or both",
|
||||
"mail_error_bulk_running": "A mass send is already running",
|
||||
"mail_skip_no_email": "no email address",
|
||||
"mail_skip_bad_email": "invalid email address",
|
||||
"mail_skip_disabled": "user disabled",
|
||||
"mail_test_hint": "The test uses the saved settings — press Save first."
|
||||
}
|
||||
+67
-1
@@ -606,5 +606,71 @@
|
||||
"stop_peer_title": "Остановить",
|
||||
"start_peer_title": "Включить",
|
||||
"ssh_cooldown_title": "SSH cooldown (сек)",
|
||||
"ssh_cooldown_hint": "Начальная пауза перед повторным подключением к недоступному серверу. Не меняйте, если сервер работает корректно."
|
||||
"ssh_cooldown_hint": "Начальная пауза перед повторным подключением к недоступному серверу. Не меняйте, если сервер работает корректно.",
|
||||
"mail_title": "Отправка почты (SMTP)",
|
||||
"mail_hint": "С этого ящика уходят конфигурации и ссылки. Пароль хранится в data.json — заведите отдельный ящик или пароль приложения.",
|
||||
"mail_enabled_label": "Включить отправку почты",
|
||||
"mail_host_label": "SMTP-сервер",
|
||||
"mail_port_label": "Порт",
|
||||
"mail_security_label": "Шифрование",
|
||||
"mail_security_none": "Нет",
|
||||
"mail_security_starttls": "STARTTLS",
|
||||
"mail_security_ssl": "SSL/TLS",
|
||||
"mail_username_label": "Логин",
|
||||
"mail_password_label": "Пароль",
|
||||
"mail_password_hint": "Пустое поле оставит сохранённый пароль; чтобы стереть его, введите пробел.",
|
||||
"mail_from_label": "Адрес отправителя",
|
||||
"mail_from_name_label": "Имя отправителя",
|
||||
"mail_reply_to_label": "Адрес для ответа (необяз.)",
|
||||
"mail_timeout_label": "Таймаут (сек)",
|
||||
"mail_test_btn": "Отправить проверочное письмо",
|
||||
"mail_test_recipient_label": "Кому отправить проверку",
|
||||
"mail_test_sent": "Проверочное письмо отправлено на {}",
|
||||
"mail_send_btn": "Отправить письмом",
|
||||
"mail_send_title": "Отправка конфигураций письмом",
|
||||
"mail_send_all_btn": "Письмо всем",
|
||||
"mail_send_all_title": "Письмо всем пользователям",
|
||||
"mail_recipient_label": "Получатель",
|
||||
"mail_include_configs": "Приложить файлы конфигураций",
|
||||
"mail_include_links": "Добавить ссылки на подключения",
|
||||
"mail_subject_label": "Тема",
|
||||
"mail_subject_placeholder": "Пусто — будет тема по умолчанию",
|
||||
"mail_message_label": "Сообщение (необязательно)",
|
||||
"mail_message_placeholder": "Несколько слов перед списком подключений",
|
||||
"mail_only_enabled": "Пропускать отключённых пользователей",
|
||||
"mail_skip_empty": "Пропускать пользователей без подключений",
|
||||
"mail_send_action": "Отправить",
|
||||
"mail_sent_ok": "Отправлено на {}",
|
||||
"mail_bulk_started": "Массовая отправка запущена",
|
||||
"mail_bulk_targets": "Получатели: все пользователи с адресом почты",
|
||||
"mail_bulk_progress": "Обработано {} из {}",
|
||||
"mail_bulk_summary": "Отправлено: {}, пропущено: {}, ошибок: {}",
|
||||
"mail_bulk_running_note": "Окно можно закрыть — отправка продолжится.",
|
||||
"mail_no_email_hint": "У пользователя не указан адрес почты",
|
||||
"mail_status_sent": "отправлено",
|
||||
"mail_status_skipped": "пропущено",
|
||||
"mail_status_failed": "ошибка",
|
||||
"mail_greeting": "Здравствуйте, {}!",
|
||||
"mail_default_subject": "Ваш доступ к VPN",
|
||||
"mail_intro_connections": "Ниже ваши подключения. Приложенный файл импортируйте в клиент VPN, ссылку откройте на устройстве.",
|
||||
"mail_link_label": "Ссылка",
|
||||
"mail_file_label": "Файл во вложении",
|
||||
"mail_no_connections_note": "На вашей учётной записи пока нет подключений — напишите администратору.",
|
||||
"mail_footer": "Письмо отправлено автоматически из панели VPN. Отвечать на него не нужно.",
|
||||
"mail_test_subject": "Проверочное письмо от панели VPN",
|
||||
"mail_test_body": "Настройки SMTP работают: это проверочное письмо отправила панель VPN.",
|
||||
"mail_error_disabled": "Отправка почты выключена в настройках",
|
||||
"mail_error_no_host": "Не указан SMTP-сервер",
|
||||
"mail_error_no_sender": "Адрес отправителя не указан или неверен",
|
||||
"mail_error_bad_security": "Неизвестный режим шифрования",
|
||||
"mail_error_no_recipient": "Нет пригодного адреса получателя",
|
||||
"mail_error_no_recipients": "Нет пользователей с адресом почты",
|
||||
"mail_error_no_connections": "У пользователя нет подключений для отправки",
|
||||
"mail_error_user_not_found": "Пользователь не найден",
|
||||
"mail_error_nothing_to_send": "Выберите файлы, ссылки или и то и другое",
|
||||
"mail_error_bulk_running": "Массовая отправка уже идёт",
|
||||
"mail_skip_no_email": "нет адреса почты",
|
||||
"mail_skip_bad_email": "неверный адрес почты",
|
||||
"mail_skip_disabled": "пользователь отключён",
|
||||
"mail_test_hint": "Проверка идёт по сохранённым настройкам — сначала нажмите «Сохранить»."
|
||||
}
|
||||
Reference in new issue
Block a user