329 lines
13 KiB
Python
329 lines
13 KiB
Python
"""
|
|
app/notify.py
|
|
=============
|
|
Email notification helper for compression job completion.
|
|
|
|
Delivery uses SMTP settings stored in SQLite (via app.db).
|
|
If no SMTP settings have been configured, the send call returns an
|
|
informative error rather than silently failing.
|
|
|
|
Public API
|
|
----------
|
|
get_smtp_config() -> dict with all SMTP fields (safe for the UI)
|
|
send_completion_email(to, results, cancelled) -> (ok: bool, error: str)
|
|
|
|
SMTP settings keys (stored in the 'settings' table)
|
|
----------------------------------------------------
|
|
smtp_host — hostname or IP of the SMTP server
|
|
smtp_port — port number (str)
|
|
smtp_security — 'tls' (STARTTLS) | 'ssl' (SMTPS) | 'none'
|
|
smtp_user — login username (optional)
|
|
smtp_password — login password (optional, stored as-is)
|
|
smtp_from — From: address used in sent mail
|
|
"""
|
|
|
|
import smtplib
|
|
import socket
|
|
import ssl
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from email.utils import formatdate, make_msgid
|
|
|
|
from .db import get_setting
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# SMTP config helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def get_smtp_config() -> dict:
|
|
"""
|
|
Read SMTP settings from the database and return them as a dict.
|
|
The password field is replaced with a placeholder so this dict is
|
|
safe to serialise and send to the browser.
|
|
|
|
Returns
|
|
-------
|
|
{
|
|
host, port, security, user, from_addr,
|
|
password_set: bool (True if a password is stored)
|
|
}
|
|
"""
|
|
return {
|
|
'host': get_setting('smtp_host') or '',
|
|
'port': get_setting('smtp_port') or '587',
|
|
'security': get_setting('smtp_security') or 'tls',
|
|
'user': get_setting('smtp_user') or '',
|
|
'from_addr': get_setting('smtp_from') or '',
|
|
'password_set': bool(get_setting('smtp_password')),
|
|
}
|
|
|
|
|
|
def _load_smtp_config() -> dict:
|
|
"""Load full config including the raw password (server-side only)."""
|
|
return {
|
|
'host': get_setting('smtp_host') or '',
|
|
'port': int(get_setting('smtp_port') or 587),
|
|
'security': get_setting('smtp_security') or 'tls',
|
|
'user': get_setting('smtp_user') or '',
|
|
'password': get_setting('smtp_password') or '',
|
|
'from_addr': get_setting('smtp_from') or '',
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Send helper
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def send_completion_email(
|
|
to_address: str,
|
|
results: list[dict],
|
|
cancelled: bool,
|
|
) -> tuple[bool, str]:
|
|
"""
|
|
Send a job-completion notification to *to_address* using the SMTP
|
|
settings stored in SQLite.
|
|
|
|
Returns (success, error_message).
|
|
"""
|
|
if not to_address or '@' not in to_address:
|
|
return False, 'Invalid recipient email address'
|
|
|
|
cfg = _load_smtp_config()
|
|
|
|
if not cfg['host']:
|
|
return False, (
|
|
'No SMTP server configured. '
|
|
'Please add your SMTP settings in the ⚙ Settings panel.'
|
|
)
|
|
if not cfg['from_addr']:
|
|
return False, (
|
|
'No From address configured. '
|
|
'Please add your SMTP settings in the ⚙ Settings panel.'
|
|
)
|
|
|
|
# ── Build message ─────────────────────────────────────────────────────
|
|
done_files = [r for r in results if r.get('status') == 'done']
|
|
error_files = [r for r in results if r.get('status') == 'error']
|
|
total = len(results)
|
|
hostname = socket.getfqdn()
|
|
|
|
if cancelled:
|
|
subject = (f'VideoPress: compression cancelled '
|
|
f'({len(done_files)}/{total} completed) on {hostname}')
|
|
elif error_files:
|
|
subject = (f'VideoPress: compression complete with '
|
|
f'{len(error_files)} error(s) on {hostname}')
|
|
else:
|
|
subject = (f'VideoPress: compression complete — '
|
|
f'{total} file(s) processed on {hostname}')
|
|
|
|
msg = MIMEMultipart('alternative')
|
|
msg['Subject'] = subject
|
|
msg['From'] = cfg['from_addr']
|
|
msg['To'] = to_address
|
|
msg['Date'] = formatdate(localtime=True)
|
|
msg['Message-ID'] = make_msgid(domain=hostname)
|
|
msg.attach(MIMEText(
|
|
_build_plain(hostname, cancelled, done_files, error_files, total),
|
|
'plain', 'utf-8',
|
|
))
|
|
msg.attach(MIMEText(
|
|
_build_html(hostname, subject, cancelled, done_files, error_files, total),
|
|
'html', 'utf-8',
|
|
))
|
|
|
|
# ── Connect and send ──────────────────────────────────────────────────
|
|
try:
|
|
security = cfg['security'].lower()
|
|
host = cfg['host']
|
|
port = cfg['port']
|
|
|
|
if security == 'ssl':
|
|
# SMTPS — wrap in SSL from the start (port 465 typically)
|
|
context = ssl.create_default_context()
|
|
server = smtplib.SMTP_SSL(host, port, context=context, timeout=15)
|
|
else:
|
|
# Plain or STARTTLS (port 587 typically)
|
|
server = smtplib.SMTP(host, port, timeout=15)
|
|
server.ehlo()
|
|
if security == 'tls':
|
|
context = ssl.create_default_context()
|
|
server.starttls(context=context)
|
|
server.ehlo()
|
|
|
|
with server:
|
|
if cfg['user'] and cfg['password']:
|
|
server.login(cfg['user'], cfg['password'])
|
|
server.sendmail(cfg['from_addr'], [to_address], msg.as_bytes())
|
|
|
|
return True, ''
|
|
|
|
except smtplib.SMTPAuthenticationError:
|
|
return False, (
|
|
'Authentication failed — check your username and password. '
|
|
'For Gmail/Google Workspace, use an App Password rather than '
|
|
'your account password.'
|
|
)
|
|
except smtplib.SMTPConnectError as exc:
|
|
return False, (
|
|
f'Could not connect to {host}:{port}. '
|
|
f'Check the host, port, and security setting. ({exc})'
|
|
)
|
|
except smtplib.SMTPRecipientsRefused as exc:
|
|
refused = ', '.join(exc.recipients.keys())
|
|
return False, f'Recipient address rejected by server: {refused}'
|
|
except smtplib.SMTPSenderRefused as exc:
|
|
return False, (
|
|
f'From address "{cfg["from_addr"]}" was rejected by the server. '
|
|
f'Ensure it matches your authenticated account. ({exc.smtp_error.decode(errors="replace")})'
|
|
)
|
|
except smtplib.SMTPException as exc:
|
|
return False, f'SMTP error: {exc}'
|
|
except ssl.SSLError as exc:
|
|
return False, (
|
|
f'SSL/TLS error connecting to {host}:{port} — '
|
|
f'try changing the Security setting. ({exc})'
|
|
)
|
|
except TimeoutError:
|
|
return False, (
|
|
f'Connection to {host}:{port} timed out. '
|
|
f'Check the host and port, and that the server is reachable.'
|
|
)
|
|
except OSError as exc:
|
|
return False, (
|
|
f'Network error connecting to {host}:{port} — {exc}. '
|
|
f'Check the hostname and that the server is reachable.'
|
|
)
|
|
except Exception as exc:
|
|
return False, f'Unexpected error: {exc}'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Email body builders
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _build_plain(hostname, cancelled, done_files, error_files, total) -> str:
|
|
lines = [
|
|
'VideoPress Compression Report',
|
|
f'Host : {hostname}',
|
|
f'Status : {"Cancelled" if cancelled else "Complete"}',
|
|
f'Files : {len(done_files)} succeeded, {len(error_files)} failed, {total} total',
|
|
'',
|
|
]
|
|
if done_files:
|
|
lines.append('Completed files:')
|
|
for r in done_files:
|
|
lines.append(
|
|
f" ✓ {r.get('filename','?')} "
|
|
f"({r.get('output_size_gb','?')} GB, "
|
|
f"-{r.get('reduction_pct','?')}%)"
|
|
)
|
|
lines.append('')
|
|
if error_files:
|
|
lines.append('Failed files:')
|
|
for r in error_files:
|
|
lines.append(
|
|
f" ✗ {r.get('filename','?')} "
|
|
f"— {r.get('message','unknown error')}"
|
|
)
|
|
lines.append('')
|
|
lines += ['—', 'Sent by VideoPress FFmpeg Compressor']
|
|
return '\n'.join(lines)
|
|
|
|
|
|
def _build_html(hostname, subject, cancelled, done_files, error_files, total) -> str:
|
|
status_colour = (
|
|
'#166534' if not cancelled and not error_files
|
|
else '#92400e' if cancelled
|
|
else '#991b1b'
|
|
)
|
|
status_label = (
|
|
'Cancelled' if cancelled
|
|
else 'Complete ✓' if not error_files
|
|
else 'Complete with errors'
|
|
)
|
|
|
|
def file_rows(files, icon, bg):
|
|
rows = ''
|
|
for r in files:
|
|
detail = (
|
|
f"{r.get('output_size_gb','?')} GB · "
|
|
f"-{r.get('reduction_pct','?')}%"
|
|
if r.get('status') == 'done'
|
|
else r.get('message', 'unknown error')
|
|
)
|
|
rows += (
|
|
f'<tr style="background:{bg}">'
|
|
f'<td style="padding:6px 10px;font-size:1.1em">{icon}</td>'
|
|
f'<td style="padding:6px 10px;font-family:monospace;font-size:.9em">'
|
|
f'{r.get("filename","?")}</td>'
|
|
f'<td style="padding:6px 10px;color:#555;font-size:.85em">{detail}</td>'
|
|
f'</tr>'
|
|
)
|
|
return rows
|
|
|
|
done_rows = file_rows(done_files, '✅', '#f0fdf4')
|
|
error_rows = file_rows(error_files, '❌', '#fef2f2')
|
|
|
|
error_cell = (
|
|
f'<div><div style="font-size:.7em;text-transform:uppercase;'
|
|
f'letter-spacing:.06em;color:#6b7280;font-weight:700">Failed</div>'
|
|
f'<div style="font-size:1.3em;font-weight:700;color:#991b1b">'
|
|
f'{len(error_files)}</div></div>'
|
|
) if error_files else ''
|
|
|
|
done_section = (
|
|
f'<h2 style="font-size:1em;color:#166534;margin:0 0 8px">Completed</h2>'
|
|
f'<table style="width:100%;border-collapse:collapse;margin-bottom:20px">'
|
|
f'{done_rows}</table>'
|
|
) if done_files else ''
|
|
|
|
error_section = (
|
|
f'<h2 style="font-size:1em;color:#991b1b;margin:0 0 8px">Errors</h2>'
|
|
f'<table style="width:100%;border-collapse:collapse;margin-bottom:20px">'
|
|
f'{error_rows}</table>'
|
|
) if error_files else ''
|
|
|
|
return f"""<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head><meta charset="UTF-8"><title>{subject}</title></head>
|
|
<body style="font-family:system-ui,sans-serif;background:#f9fafb;margin:0;padding:24px">
|
|
<div style="max-width:640px;margin:0 auto;background:#fff;border-radius:10px;
|
|
box-shadow:0 2px 8px rgba(0,0,0,.08);overflow:hidden">
|
|
<div style="background:#1a1a18;padding:20px 28px">
|
|
<span style="color:#f97316;font-size:1.4em">▶</span>
|
|
<span style="color:#f5f5f2;font-size:1.15em;font-weight:700;
|
|
letter-spacing:.03em;margin-left:10px">
|
|
Video<strong style="color:#f97316">Press</strong>
|
|
</span>
|
|
</div>
|
|
<div style="padding:28px">
|
|
<h1 style="margin:0 0 4px;font-size:1.2em;color:#111">Compression Run Report</h1>
|
|
<p style="margin:0 0 20px;color:#6b7280;font-size:.9em">Host: <code>{hostname}</code></p>
|
|
<div style="background:#f3f4f6;border-radius:8px;padding:16px 20px;
|
|
margin-bottom:24px;display:flex;gap:32px;flex-wrap:wrap">
|
|
<div>
|
|
<div style="font-size:.7em;text-transform:uppercase;letter-spacing:.06em;
|
|
color:#6b7280;font-weight:700">Status</div>
|
|
<div style="font-size:1.3em;font-weight:700;color:{status_colour}">{status_label}</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:.7em;text-transform:uppercase;letter-spacing:.06em;
|
|
color:#6b7280;font-weight:700">Total</div>
|
|
<div style="font-size:1.3em;font-weight:700;color:#111">{total}</div>
|
|
</div>
|
|
<div>
|
|
<div style="font-size:.7em;text-transform:uppercase;letter-spacing:.06em;
|
|
color:#6b7280;font-weight:700">Succeeded</div>
|
|
<div style="font-size:1.3em;font-weight:700;color:#166534">{len(done_files)}</div>
|
|
</div>
|
|
{error_cell}
|
|
</div>
|
|
{done_section}
|
|
{error_section}
|
|
<hr style="border:none;border-top:1px solid #e5e7eb;margin:24px 0 16px">
|
|
<p style="color:#9ca3af;font-size:.78em;margin:0">Sent by VideoPress FFmpeg Compressor</p>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
</html>"""
|