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:
Алексей ЦыганковandClaude Opus 5 committed 2026-09-18 15:08:01 +07:00
1 parent 02c1182284
commit 71aea08c39
9 files changed
+1980 -3

No files matched your search

+9
View File
@@ -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)
+134
View File
@@ -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
View File
@@ -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)}
+1
View File
@@ -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
View File
@@ -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');
+223
View File
@@ -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;
+655
View File
@@ -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
View File
@@ -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
View File
@@ -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": "Проверка идёт по сохранённым настройкам — сначала нажмите «Сохранить»."
}