Improve favicon contrast and redesign email templates with logo

favicon.svg:
- Light mode: replace near-black fill (#2d3748) with indigo brand color
  (#6366f1 fill, #4f46e5 stroke, #a5b4fc page lines) — distinctive and
  high-contrast without the dark/black appearance
- Dark mode unchanged

email.py:
- Add _EMAIL_LOGO_SVG: inline SVG with white palette for rendering on
  the indigo header (white book, lavender lines, gold sparkle)
- Add _email_html(title, body): shared template wrapper — gray outer
  background, white card with border-radius, indigo header with logo +
  app name, content area, footer

notifications.py:
- Import and use _email_html for all six email functions: security alert,
  password reset, password reset success, invitation, task reminder,
  test email
- Clean up all inline HTML to match the new card layout and spacing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-25 20:33:10 -05:00
parent b37e15d59a
commit 0984dae2e7
3 changed files with 142 additions and 113 deletions
+3 -3
View File
@@ -1,8 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<style> <style>
.book { fill: #2d3748; stroke: #4a5568; } .book { fill: #6366f1; stroke: #4f46e5; }
.spine { stroke: #4a5568; } .spine { stroke: #4f46e5; }
.lines { stroke: #a0aec0; } .lines { stroke: #a5b4fc; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.book { fill: #e2e8f0; stroke: #cbd5e0; } .book { fill: #e2e8f0; stroke: #cbd5e0; }
.spine { stroke: #cbd5e0; } .spine { stroke: #cbd5e0; }

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

+63 -11
View File
@@ -13,6 +13,65 @@ from fabledassistant.models.user import User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Inline SVG logo for email (white palette, renders on indigo header)
_EMAIL_LOGO_SVG = (
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"'
' style="display:inline-block;vertical-align:middle;margin-right:8px;">'
'<path d="M4 7 C4 7 8 5 16 6 C24 5 28 7 28 7 L28 26 C28 26 24 24 16 25 C8 24 4 26 4 26 Z"'
' fill="#ffffff" stroke="#c4b5fd" stroke-width="0.5"/>'
'<line x1="16" y1="6" x2="16" y2="25" stroke="#c4b5fd" stroke-width="0.8"/>'
'<line x1="7" y1="11" x2="14" y2="10.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="14" x2="14" y2="13.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="17" x2="14" y2="16.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="7" y1="20" x2="12" y2="19.5" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="10.5" x2="25" y2="11" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="13.5" x2="25" y2="14" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<line x1="18" y1="16.5" x2="25" y2="17" stroke="#ddd6fe" stroke-width="0.6" stroke-linecap="round"/>'
'<g transform="translate(24, 5)">'
'<path d="M0 -4 L1 -1 L4 0 L1 1 L0 4 L-1 1 L-4 0 L-1 -1 Z" fill="#f6ad55"/>'
'<path d="M0 -2.5 L0.6 -0.6 L2.5 0 L0.6 0.6 L0 2.5 L-0.6 0.6 L-2.5 0 L-0.6 -0.6 Z" fill="#fbd38d"/>'
'</g>'
'<circle cx="20" cy="3" r="0.7" fill="#f6ad55" opacity="0.7"/>'
'<circle cx="27" cy="8" r="0.5" fill="#fbd38d" opacity="0.6"/>'
'</svg>'
)
def _email_html(title: str, body: str) -> str:
"""Wrap email body content in the standard Fabled Assistant template."""
return f"""<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;">
<div style="padding:32px 16px;">
<div style="max-width:520px;margin:0 auto;">
<div style="background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;">
<!-- Header -->
<div style="background:#6366f1;padding:20px 24px;text-align:center;">
<div style="margin-bottom:6px;">
{_EMAIL_LOGO_SVG}<span style="display:inline-block;vertical-align:middle;color:#ffffff;font-size:17px;font-weight:600;letter-spacing:0.01em;">Fabled Assistant</span>
</div>
<p style="margin:0;color:#c7d2fe;font-size:13px;">{title}</p>
</div>
<!-- Body -->
<div style="padding:28px 24px;">
{body}
</div>
<!-- Footer -->
<div style="border-top:1px solid #e5e7eb;padding:14px 24px;text-align:center;background:#f9fafb;">
<p style="margin:0;color:#9ca3af;font-size:12px;">This email was sent by your Fabled Assistant instance.</p>
</div>
</div>
</div>
</div>
</body>
</html>"""
SMTP_SETTING_KEYS = [ SMTP_SETTING_KEYS = [
"smtp_host", "smtp_host",
"smtp_port", "smtp_port",
@@ -109,15 +168,8 @@ async def send_email(to: str, subject: str, html_body: str) -> None:
async def send_test_email(to: str) -> None: async def send_test_email(to: str) -> None:
"""Send a branded test email.""" """Send a branded test email."""
html = """ body = """
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;"> <p style="margin:0 0 12px;color:#111827;font-size:15px;font-weight:600;">SMTP is configured correctly</p>
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;"> <p style="margin:0;color:#6b7280;font-size:14px;">Your Fabled Assistant instance can send email notifications.</p>
<h1 style="margin: 0; font-size: 20px;">Fabled Assistant</h1>
</div>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 12px; color: #374151;">Your SMTP configuration is working correctly.</p>
<p style="margin: 0; color: #6b7280; font-size: 14px;">This is a test email sent from your Fabled Assistant instance.</p>
</div>
</div>
""" """
await send_email(to, "Fabled Assistant - Test Email", html) await send_email(to, "Fabled Assistant - Test Email", _email_html("Test Email", body))
+76 -99
View File
@@ -12,7 +12,7 @@ from fabledassistant.models.app_log import AppLog
from fabledassistant.models.note import Note from fabledassistant.models.note import Note
from fabledassistant.models.setting import Setting from fabledassistant.models.setting import Setting
from fabledassistant.models.user import User from fabledassistant.models.user import User
from fabledassistant.services.email import is_smtp_configured, send_email from fabledassistant.services.email import _email_html, is_smtp_configured, send_email
from fabledassistant.services.logging import log_audit from fabledassistant.services.logging import log_audit
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -69,100 +69,84 @@ async def notify_security_event(
for k, v in details.items(): for k, v in details.items():
if k == "ip_address": if k == "ip_address":
continue continue
detail_rows += f'<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">{k}</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{v}</td></tr>' detail_rows += (
f'<tr>'
f'<td style="padding:6px 0;color:#6b7280;font-size:13px;width:120px;">{k}</td>'
f'<td style="padding:6px 0;color:#111827;font-size:13px;">{v}</td>'
f'</tr>'
)
html = f""" body = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;"> <p style="margin:0 0 16px;color:#111827;font-size:15px;font-weight:600;">{label}</p>
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;"> <table style="width:100%;border-collapse:collapse;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;">
<h1 style="margin: 0; font-size: 20px;">Security Alert</h1> <tr>
</div> <td style="padding:8px 12px;color:#6b7280;font-size:13px;width:120px;border-bottom:1px solid #e5e7eb;">Time</td>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;"> <td style="padding:8px 12px;color:#111827;font-size:13px;border-bottom:1px solid #e5e7eb;">{timestamp}</td>
<p style="margin: 0 0 16px; color: #374151; font-size: 16px; font-weight: 600;">{label}</p> </tr>
<table style="width: 100%; border-collapse: collapse;"> <tr>
<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">Time</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{timestamp}</td></tr> <td style="padding:8px 12px;color:#6b7280;font-size:13px;width:120px;{';border-bottom:1px solid #e5e7eb' if detail_rows else ''}">IP Address</td>
<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">IP Address</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{ip}</td></tr> <td style="padding:8px 12px;color:#111827;font-size:13px;{';border-bottom:1px solid #e5e7eb' if detail_rows else ''}">{ip}</td>
{detail_rows} </tr>
</table> {detail_rows}
<p style="margin: 16px 0 0; color: #9ca3af; font-size: 12px;">If this wasn't you, change your password immediately.</p> </table>
</div> <p style="margin:16px 0 0;color:#9ca3af;font-size:12px;">If this wasn't you, change your password immediately.</p>
</div>
""" """
await send_email(email, f"Fabled Assistant - {label}", html) await send_email(email, f"Fabled Assistant - {label}", _email_html("Security Alert", body))
except Exception: except Exception:
logger.exception("Failed to send security notification for user %d", user_id) logger.exception("Failed to send security notification for user %d", user_id)
async def send_password_reset_email(email: str, reset_url: str) -> None: async def send_password_reset_email(email: str, reset_url: str) -> None:
"""Send a password reset email with a link to reset the user's password.""" """Send a password reset email with a link to reset the user's password."""
html = f""" body = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;"> <p style="margin:0 0 16px;color:#374151;font-size:15px;">
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;"> We received a request to reset your password. Click the button below to choose a new password.
<h1 style="margin: 0; font-size: 20px;">Password Reset</h1> </p>
<div style="text-align:center;margin:24px 0;">
<a href="{reset_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Reset Password
</a>
</div> </div>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;"> <p style="margin:0 0 6px;color:#6b7280;font-size:13px;">This link expires in 1 hour.</p>
<p style="margin: 0 0 16px; color: #374151; font-size: 15px;"> <p style="margin:0 0 16px;color:#6b7280;font-size:13px;">If you didn't request this, you can safely ignore this email.</p>
We received a request to reset your password. Click the button below to choose a new password: <hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;">
</p> <p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
<div style="text-align: center; margin: 24px 0;"> <p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{reset_url}</p>
<a href="{reset_url}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 15px;">
Reset Password
</a>
</div>
<p style="margin: 0 0 8px; color: #6b7280; font-size: 13px;">This link expires in 1 hour.</p>
<p style="margin: 0; color: #6b7280; font-size: 13px;">If you didn't request this, you can safely ignore this email.</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="margin: 0; color: #9ca3af; font-size: 12px;">If the button doesn't work, copy and paste this link into your browser:</p>
<p style="margin: 4px 0 0; color: #9ca3af; font-size: 12px; word-break: break-all;">{reset_url}</p>
</div>
</div>
""" """
await send_email(email, "Fabled Assistant - Password Reset", html) await send_email(email, "Fabled Assistant - Password Reset", _email_html("Password Reset", body))
async def send_password_reset_success_email(email: str) -> None: async def send_password_reset_success_email(email: str) -> None:
"""Send a notification that the password was successfully reset.""" """Send a notification that the password was successfully reset."""
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
html = f""" body = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;"> <p style="margin:0 0 12px;color:#374151;font-size:15px;">
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;"> Your password was successfully changed at <strong>{timestamp}</strong>.
<h1 style="margin: 0; font-size: 20px;">Password Changed</h1> </p>
</div> <p style="margin:0;color:#6b7280;font-size:13px;">If you didn't make this change, please contact your administrator immediately.</p>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; color: #374151; font-size: 15px;">
Your password was successfully reset at {timestamp}.
</p>
<p style="margin: 0; color: #6b7280; font-size: 13px;">If you didn't make this change, please contact your administrator immediately.</p>
</div>
</div>
""" """
await send_email(email, "Fabled Assistant - Password Changed", html) await send_email(email, "Fabled Assistant - Password Changed", _email_html("Password Changed", body))
async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None: async def send_invitation_email(email: str, invite_url: str, invited_by_username: str) -> None:
"""Send a branded invitation email with a registration link.""" """Send a branded invitation email with a registration link."""
html = f""" body = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;"> <p style="margin:0 0 16px;color:#374151;font-size:15px;">
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;"> <strong>{invited_by_username}</strong> has invited you to join Fabled Assistant.
<h1 style="margin: 0; font-size: 20px;">You're Invited!</h1> Click the button below to create your account.
</p>
<div style="text-align:center;margin:24px 0;">
<a href="{invite_url}" style="display:inline-block;background:#6366f1;color:#ffffff;padding:12px 32px;border-radius:6px;text-decoration:none;font-weight:600;font-size:15px;">
Accept Invitation
</a>
</div> </div>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;"> <p style="margin:0 0 6px;color:#6b7280;font-size:13px;">This invitation expires in 7 days.</p>
<p style="margin: 0 0 16px; color: #374151; font-size: 15px;"> <p style="margin:0 0 16px;color:#6b7280;font-size:13px;">If you weren't expecting this, you can safely ignore this email.</p>
<strong>{invited_by_username}</strong> has invited you to join Fabled Assistant. Click the button below to create your account: <hr style="border:none;border-top:1px solid #e5e7eb;margin:16px 0;">
</p> <p style="margin:0;color:#9ca3af;font-size:12px;">If the button doesn't work, copy and paste this link:</p>
<div style="text-align: center; margin: 24px 0;"> <p style="margin:4px 0 0;color:#9ca3af;font-size:12px;word-break:break-all;">{invite_url}</p>
<a href="{invite_url}" style="display: inline-block; background: #6366f1; color: #fff; padding: 12px 32px; border-radius: 6px; text-decoration: none; font-weight: 600; font-size: 15px;">
Accept Invitation
</a>
</div>
<p style="margin: 0 0 8px; color: #6b7280; font-size: 13px;">This invitation expires in 7 days.</p>
<p style="margin: 0; color: #6b7280; font-size: 13px;">If you weren't expecting this invitation, you can safely ignore this email.</p>
<hr style="border: none; border-top: 1px solid #e5e7eb; margin: 16px 0;" />
<p style="margin: 0; color: #9ca3af; font-size: 12px;">If the button doesn't work, copy and paste this link into your browser:</p>
<p style="margin: 4px 0 0; color: #9ca3af; font-size: 12px; word-break: break-all;">{invite_url}</p>
</div>
</div>
""" """
await send_email(email, "Fabled Assistant - You're Invited!", html) await send_email(email, "Fabled Assistant - You're Invited!", _email_html("You're Invited!", body))
async def check_due_tasks() -> None: async def check_due_tasks() -> None:
@@ -221,34 +205,27 @@ async def check_due_tasks() -> None:
overdue = task.due_date < today if task.due_date else False overdue = task.due_date < today if task.due_date else False
date_color = "#ef4444" if overdue else "#374151" date_color = "#ef4444" if overdue else "#374151"
date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else "" date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else ""
overdue_badge = ' <span style="color: #ef4444; font-weight: 600; font-size: 12px;">(overdue)</span>' if overdue else "" overdue_badge = ' <span style="color:#ef4444;font-weight:600;font-size:11px;">(overdue)</span>' if overdue else ""
task_rows += f""" task_rows += (
<tr> f'<tr>'
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; font-size: 14px;">{task.title}</td> f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;color:#111827;font-size:14px;">{task.title}</td>'
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; font-size: 14px;">{date_label}{overdue_badge}</td> f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;font-size:14px;">{date_label}{overdue_badge}</td>'
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">{task.status}</td> f'<td style="padding:8px 12px;border-bottom:1px solid #e5e7eb;color:#6b7280;font-size:13px;">{task.status}</td>'
</tr> f'</tr>'
""" )
html = f""" body = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px;"> <p style="margin:0 0 16px;color:#374151;font-size:15px;">You have <strong>{len(user_tasks)}</strong> task(s) due:</p>
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;"> <table style="width:100%;border-collapse:collapse;border:1px solid #e5e7eb;border-radius:6px;overflow:hidden;">
<h1 style="margin: 0; font-size: 20px;">Task Reminders</h1> <tr style="background:#f9fafb;">
</div> <th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Task</th>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;"> <th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Due</th>
<p style="margin: 0 0 16px; color: #374151;">You have {len(user_tasks)} task(s) due:</p> <th style="padding:8px 12px;text-align:left;font-size:12px;color:#6b7280;font-weight:600;text-transform:uppercase;letter-spacing:0.05em;border-bottom:1px solid #e5e7eb;">Status</th>
<table style="width: 100%; border-collapse: collapse;"> </tr>
<tr style="background: #f3f4f6;"> {task_rows}
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Task</th> </table>
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Due</th>
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Status</th>
</tr>
{task_rows}
</table>
</div>
</div>
""" """
await send_email(email, f"Fabled Assistant - {len(user_tasks)} Task(s) Due", html) await send_email(email, f"Fabled Assistant - {len(user_tasks)} Task(s) Due", _email_html("Task Reminders", body))
await log_audit( await log_audit(
"task_reminder", "task_reminder",
user_id=user_id, user_id=user_id,