From 7950bc9f6422aff0b4feca8842f6690366fc2b27 Mon Sep 17 00:00:00 2001 From: Alex Blank Date: Mon, 4 May 2026 17:24:41 +0200 Subject: [PATCH] redesign --- .../services/mailer.py | 157 +++++++++++++----- backend/tests/test_mailer.py | 31 ++-- docs/REQUIREMENTS.md | 2 + frontend/src/App.tsx | 92 +++++++--- frontend/src/components/MetricCard.tsx | 16 +- .../src/components/SessionActivityPanel.tsx | 8 +- frontend/src/index.css | 5 + frontend/src/pages/FileBrowser.tsx | 11 +- frontend/src/pages/Media.tsx | 21 ++- frontend/src/pages/Users.tsx | 8 +- 10 files changed, 255 insertions(+), 96 deletions(-) diff --git a/backend/src/media_library_viewer_api/services/mailer.py b/backend/src/media_library_viewer_api/services/mailer.py index eee3d77..45dd6a3 100644 --- a/backend/src/media_library_viewer_api/services/mailer.py +++ b/backend/src/media_library_viewer_api/services/mailer.py @@ -328,6 +328,7 @@ def send_email_message( attempts: list[dict[str, Any]] = [] last_error = "" + fallback_from_address = smtp_username if smtp_username and smtp_username != from_address else None for mode in _smtp_mode_candidates(settings): meta = _smtp_attempt_metadata(mode) logger.info( @@ -341,40 +342,6 @@ def send_email_message( ) try: _send_email_via_mode(mode, message, recipients, from_address) - attempts.append( - { - "label": meta["label"], - "smtp_host": meta["smtp_host"], - "smtp_port": meta["smtp_port"], - "use_tls": meta["use_tls"], - "use_ssl": meta["use_ssl"], - "status": "ok", - } - ) - logger.info( - "SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s", - meta["label"], - meta["smtp_host"], - meta["smtp_port"], - meta["transport"], - meta["auth_user"], - from_address, - ) - return { - "from_address": from_address, - "recipient_count": len(recipients), - "attachment_count": len(attachment_list), - "subject": subject, - "authenticated_as": smtp_username or None, - "selected_mode": { - "label": meta["label"], - "smtp_host": meta["smtp_host"], - "smtp_port": meta["smtp_port"], - "use_tls": meta["use_tls"], - "use_ssl": meta["use_ssl"], - }, - "attempts": attempts, - } except Exception as exc: last_error = describe_smtp_error(exc) attempts.append( @@ -388,9 +355,9 @@ def send_email_message( "error": last_error, } ) - if _smtp_sender_not_authorized(exc): + if _smtp_sender_not_authorized(exc) and fallback_from_address: logger.warning( - "SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s", + "SMTP send sender rejected label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s; retrying with smtp_username", meta["label"], meta["smtp_host"], meta["smtp_port"], @@ -399,16 +366,124 @@ def send_email_message( from_address, last_error, ) - else: - logger.warning( - "SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s", + fallback_message, fallback_from = build_email_message( + settings, + recipients, + subject, + html_body, + text_body, + attachment_list, + sender_address=fallback_from_address, + reply_to_address=from_address, + ) + try: + _send_email_via_mode(mode, fallback_message, recipients, fallback_from) + except Exception as fallback_exc: + last_error = describe_smtp_error(fallback_exc) + attempts.append( + { + "label": meta["label"], + "smtp_host": meta["smtp_host"], + "smtp_port": meta["smtp_port"], + "use_tls": meta["use_tls"], + "use_ssl": meta["use_ssl"], + "status": "failed", + "error": last_error, + "sender_fallback": True, + } + ) + logger.warning( + "SMTP send fallback failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s", + meta["label"], + meta["smtp_host"], + meta["smtp_port"], + meta["transport"], + meta["auth_user"], + fallback_from, + last_error, + ) + continue + + attempts.append( + { + "label": meta["label"], + "smtp_host": meta["smtp_host"], + "smtp_port": meta["smtp_port"], + "use_tls": meta["use_tls"], + "use_ssl": meta["use_ssl"], + "status": "ok", + "sender_fallback": True, + } + ) + logger.info( + "SMTP send succeeded via smtp_username label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s", meta["label"], meta["smtp_host"], meta["smtp_port"], meta["transport"], meta["auth_user"], - from_address, - last_error, + fallback_from, ) + return { + "from_address": fallback_from, + "recipient_count": len(recipients), + "attachment_count": len(attachment_list), + "subject": subject, + "authenticated_as": smtp_username or None, + "selected_mode": { + "label": meta["label"], + "smtp_host": meta["smtp_host"], + "smtp_port": meta["smtp_port"], + "use_tls": meta["use_tls"], + "use_ssl": meta["use_ssl"], + }, + "attempts": attempts, + } + logger.warning( + "SMTP send failed label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s error=%s", + meta["label"], + meta["smtp_host"], + meta["smtp_port"], + meta["transport"], + meta["auth_user"], + from_address, + last_error, + ) + continue + + attempts.append( + { + "label": meta["label"], + "smtp_host": meta["smtp_host"], + "smtp_port": meta["smtp_port"], + "use_tls": meta["use_tls"], + "use_ssl": meta["use_ssl"], + "status": "ok", + } + ) + logger.info( + "SMTP send succeeded label=%s host=%s port=%s transport=%s auth_user=%s from_address=%s", + meta["label"], + meta["smtp_host"], + meta["smtp_port"], + meta["transport"], + meta["auth_user"], + from_address, + ) + return { + "from_address": from_address, + "recipient_count": len(recipients), + "attachment_count": len(attachment_list), + "subject": subject, + "authenticated_as": smtp_username or None, + "selected_mode": { + "label": meta["label"], + "smtp_host": meta["smtp_host"], + "smtp_port": meta["smtp_port"], + "use_tls": meta["use_tls"], + "use_ssl": meta["use_ssl"], + }, + "attempts": attempts, + } raise RuntimeError(last_error or "SMTP delivery failed") diff --git a/backend/tests/test_mailer.py b/backend/tests/test_mailer.py index 63e5ab5..ac45d3c 100644 --- a/backend/tests/test_mailer.py +++ b/backend/tests/test_mailer.py @@ -103,7 +103,7 @@ class MailerTests(unittest.TestCase): self.assertEqual(result["attempts"][1]["status"], "ok") fallback_smtp.send_message.assert_called_once() - def test_send_email_message_rejects_unauthorized_from_address(self) -> None: + def test_send_email_message_retries_with_smtp_username_when_from_is_rejected(self) -> None: settings = SimpleNamespace( smtp_host="smtp.example.com", smtp_port=587, @@ -116,25 +116,30 @@ class MailerTests(unittest.TestCase): smtp_timeout=15, ) smtp = MagicMock() - smtp.send_message.side_effect = smtplib.SMTPDataError( - 551, b"5.7.1 Not authorised to send from this header address" - ) + smtp.send_message.side_effect = [ + smtplib.SMTPDataError(551, b"5.7.1 Not authorised to send from this header address"), + {}, + ] smtp_factory = MagicMock(return_value=_SMTPContext(smtp)) with patch("media_library_viewer_api.services.mailer.smtplib.SMTP", smtp_factory), patch( "media_library_viewer_api.services.mailer.smtplib.SMTP_SSL" ) as smtp_ssl: - with self.assertRaises(RuntimeError) as ctx: - send_email_message( - settings, - recipients=["alex@example.com"], - subject="Hello", - html_body="

Hello

", - ) + result = send_email_message( + settings, + recipients=["alex@example.com"], + subject="Hello", + html_body="

Hello

", + ) smtp_ssl.assert_not_called() - self.assertIn("authorized alias", str(ctx.exception).lower()) - self.assertEqual(smtp.send_message.call_count, 1) + self.assertEqual(result["from_address"], "mailer@example.com") + self.assertEqual(smtp.send_message.call_count, 2) + first_message = smtp.send_message.call_args_list[0].args[0] + second_message = smtp.send_message.call_args_list[1].args[0] + self.assertEqual(first_message["From"], "Manage ") + self.assertEqual(second_message["From"], "Manage ") + self.assertEqual(second_message["Reply-To"], "alias@example.com") def test_describe_smtp_error_handles_timeout(self) -> None: detail = describe_smtp_error(TimeoutError("timed out")) diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 948cf6c..3464d18 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -61,6 +61,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - The shared session table should keep a compact overall status summary line above the rows that reports total sessions plus playing, paused, and idle counts. - The shared session table should keep the session identifier under the user name in a caption instead of giving it a full column, to keep the table tighter. - The Users tab may open a read-only detail drawer for a selected user, but any communication actions in that drawer should remain clearly disabled/placeholders until the workflow is implemented. +- The frontend shell and primary pages should remain responsive and mobile-safe, with compact navigation, stacked controls on narrow screens, and reduced table column density where needed. - Backend startup should log a secret-safe configuration summary and request/activity diagnostics so configuration issues can be debugged without exposing API keys. ### Remote Filesystem over SSH @@ -173,3 +174,4 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo - 2026-05-03: Updated the dashboard monitoring cards to show 10-minute averages with high/low subtext instead of only the latest sample. - 2026-05-03: Added OIDC/JWT auth support plus root-level Docker Compose deployment files for production and dev workflows. - 2026-05-04: Backend Docker Compose now mounts a host SSH directory into `/root/.ssh` so Paramiko can use a private key and strict host-key checking without baking secrets into the image. +- 2026-05-04: The frontend was adjusted to be more mobile-safe by making the app shell tabs scrollable, stacking header controls on narrow screens, and hiding low-priority table columns on smaller displays. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 86f100f..e8299d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -49,20 +49,75 @@ function Shell({ }) { const location = useLocation(); const current = location.pathname; + const isMobile = useMediaQuery("(max-width: 900px)"); return ( <> - - - Manage - + + + + Manage + + + {authLabel && ( + + )} + + {onSignOut && ( + + )} + + - + - - {authLabel && ( - - )} - - {onSignOut && ( - - )} - - + } /> } /> diff --git a/frontend/src/components/MetricCard.tsx b/frontend/src/components/MetricCard.tsx index 6aba8a2..0032bcb 100644 --- a/frontend/src/components/MetricCard.tsx +++ b/frontend/src/components/MetricCard.tsx @@ -8,8 +8,8 @@ interface Props { export function MetricCard({ label, value, subtext }: Props) { return ( - - + + {label} - + {value} {subtext && ( {subtext} diff --git a/frontend/src/components/SessionActivityPanel.tsx b/frontend/src/components/SessionActivityPanel.tsx index 3fa4388..f87ab5a 100644 --- a/frontend/src/components/SessionActivityPanel.tsx +++ b/frontend/src/components/SessionActivityPanel.tsx @@ -79,7 +79,7 @@ export function SessionActivityPanel({ @@ -198,7 +198,7 @@ export function SessionActivityPanel({ } /> - + - + {session.device || "Unknown device"} - + {session.transcoding === "yes" ? session.transcoding_type diff --git a/frontend/src/index.css b/frontend/src/index.css index 30e636a..cb6f4cb 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -4,4 +4,9 @@ body, margin: 0; width: 100%; min-height: 100%; + overflow-x: hidden; +} + +* { + box-sizing: border-box; } diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx index 4a3198a..365fd65 100644 --- a/frontend/src/pages/FileBrowser.tsx +++ b/frontend/src/pages/FileBrowser.tsx @@ -17,6 +17,7 @@ import { Stack, TextField, Typography, + useMediaQuery, } from "@mui/material"; import { useDirectoryListing, @@ -538,6 +539,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) { export function FileBrowser() { const [searchParams] = useSearchParams(); + const isMobile = useMediaQuery("(max-width: 900px)"); const initialRequestedPath = searchParams.get("path") ?? "/"; const initialSelectedPath = initialRequestedPath !== "/" && @@ -628,7 +630,7 @@ export function FileBrowser() { File Browser - + setPathInput(e.target.value)} onKeyDown={handlePathSubmit} /> - - @@ -667,6 +669,7 @@ export function FileBrowser() { columns={columns} loading={isLoading} rowSelectionModel={rowSelectionModel} + columnVisibilityModel={isMobile ? { ext: false, modified: false } : undefined} hideFooter sx={{ "& .MuiDataGrid-columnHeaders": { @@ -731,7 +734,7 @@ export function FileBrowser() { - +