Updates galore. Improved folder structure, componentized, and notifications upon completion.
This commit is contained in:
parent
b48784e2ad
commit
7e0502ca40
33 changed files with 3565 additions and 728 deletions
329
app/notify.py
Normal file
329
app/notify.py
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
"""
|
||||
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>"""
|
||||
Loading…
Add table
Add a link
Reference in a new issue