From b03a241be26a762e1ed4a7d9bb84e85f11d2dde7 Mon Sep 17 00:00:00 2001 From: Kolkman Date: Wed, 26 Aug 2026 20:27:54 +0200 Subject: [PATCH] Mail looks 'improved' --- VIBE/CHAT_LOG.md | 42 ++++++++++++++++++ VIBE/PROMPTS.md | 7 +++ backend/app/services/email_service.py | 62 ++++++++++++++++++++++++++- backend/tests/test_email_service.py | 37 +++++++++++++++- 4 files changed, 144 insertions(+), 4 deletions(-) diff --git a/VIBE/CHAT_LOG.md b/VIBE/CHAT_LOG.md index 0d8e19a..8e4fd28 100644 --- a/VIBE/CHAT_LOG.md +++ b/VIBE/CHAT_LOG.md @@ -1,5 +1,47 @@ # Chat Log +### User +Reduce the size of the font of the greeting somewhat. + +### Assistant outcome +Reduced the email greeting from 22px to 18px while preserving the Asset font, top alignment, and placement to the right of the 50×50 logo. + +### User +Put the greeting to the right of the logo in the header, and align at the top. + +### Assistant outcome +Changed the email header to use separate table cells for the logo and greeting. The greeting cell is explicitly top-aligned and padded to the right of the fixed 50×50 logo, avoiding email-client baseline alignment differences. + +### User +Put the header text to the right of the logo and align it on the top. Also add a comma after Hello. + +### Assistant outcome +Aligned the email logo and header text at the top of the header row, kept the text to the right of the 50×50 logo, and changed the greeting to “Hello, a message from ”. + +### User +Instead of “Hello” print “Hello a message from ”, with the public hostname linking to the LinkLog server. + +### Assistant outcome +Updated the branded email header to show “Hello a message from ”. The visible hostname is extracted from the configured public URL and links to that server; the existing 50×50 top-left logo and Asset font styling remain in place. + +### User +The logo is still full width. Make it 50px by 50px, put it in the top left, and put “Hello” to the right in the Asset font. + +### Assistant outcome +Reworked the email header to use a fixed 50×50 logo at the top-left, with “Hello” immediately to its right in the Asset font with email-safe fallbacks. Added regression assertions for the dimensions, placement styling, and greeting. + +### User +The Logo in the emails should be 120px wide and centered on the top. + +### Assistant outcome +Updated the shared branded email template so the embedded LinkLog logo is explicitly 120px wide and remains centered in the top header. Added a regression assertion for the rendered width. + +### User +Mails sent should be formatted nicely in the style of the website and include the logo. + +### Assistant outcome +Added branded multipart emails with a LinkLog-styled HTML layout, an embedded logo, and preserved plain-text fallbacks for verification, password-reset, and SMTP test messages. Dynamic usernames and URLs are HTML-escaped, and email tests cover the HTML body, CID logo reference, and escaping. + ### User Fix SA-015: Some destructive and administrative operations lack audit logging. diff --git a/VIBE/PROMPTS.md b/VIBE/PROMPTS.md index dbff737..f0cfa43 100644 --- a/VIBE/PROMPTS.md +++ b/VIBE/PROMPTS.md @@ -190,6 +190,13 @@ 184. Run full backend and frontend test suites. 185. Solve SA-010: Avatar validation trusts the client MIME type. 186. Fix SA-015: Some destructive and administrative operations lack audit logging. +187. Format sent mail in the website style and include the logo. +188. Set the email logo to 120px wide and center it at the top. +189. Make the email logo 50px by 50px, place it top-left, and put "Hello" to its right in the Asset font. +190. Replace the email greeting with "Hello a message from ", linking the hostname to the LinkLog server. +191. Put the email header text to the right of the logo, align it at the top, and add a comma after Hello. +192. Put the email greeting in a separate top-aligned cell to the right of the logo. +193. Reduce the email greeting font size somewhat. ## Future entries diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index f4b0ad4..1ddc33a 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -2,13 +2,57 @@ ## SPDX-License-Identifier: GPL-3.0-or-later from email.message import EmailMessage +from html import escape +from pathlib import Path from smtplib import SMTP import json +from urllib.parse import urlparse from backend.app.core.config import settings from backend.app.database import get_connection from backend.app.services.secret_store import decrypt_secret, encrypt_secret +LOGO_PATH = Path(__file__).resolve().parents[3] / 'frontend' / 'static' / 'logo.svg' + + +def _html_email(body_html: str) -> str: + public_url = settings.public_url + parsed_url = urlparse(public_url) + public_hostname = parsed_url.hostname or public_url + safe_public_url = escape(public_url, quote=True) + safe_public_hostname = escape(public_hostname) + return f''' + + +
+
+ + + + + +
+ LinkLog + + Hello, a message from {safe_public_hostname} +
+
{body_html}
+
+

LinkLog

+
+ +''' + + +def _add_html_body(message: EmailMessage, html_body: str) -> None: + message.add_alternative(_html_email(html_body), subtype='html') + html_part = message.get_payload()[-1] + try: + logo = LOGO_PATH.read_bytes() + except OSError: + return + html_part.add_related(logo, maintype='image', subtype='svg+xml', cid='') + def get_smtp_settings() -> dict: values = { @@ -42,7 +86,8 @@ def smtp_configured(smtp_values: dict | None = None) -> bool: return bool(smtp['smtp_host'] and smtp['smtp_from']) -def send_message(email: str, subject: str, body: str, smtp_values: dict | None = None) -> None: +def send_message(email: str, subject: str, body: str, html_body: str | None = None, + smtp_values: dict | None = None) -> None: smtp = smtp_values or get_smtp_settings() if not smtp_configured(smtp): raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM') @@ -52,6 +97,8 @@ def send_message(email: str, subject: str, body: str, smtp_values: dict | None = message['From'] = smtp['smtp_from'] message['To'] = email message.set_content(body) + if html_body: + _add_html_body(message, html_body) with SMTP(smtp['smtp_host'], smtp['smtp_port'], timeout=10) as connection: if smtp['smtp_use_tls']: @@ -62,12 +109,17 @@ def send_message(email: str, subject: str, body: str, smtp_values: dict | None = def send_verification_email(email: str, username: str, verification_url: str) -> None: + safe_username = escape(username) + safe_url = escape(verification_url, quote=True) send_message( email, 'Verify your LinkLog email address', f'Hello {username},\n\n' f'Verify your LinkLog email address by opening this link:\n{verification_url}\n\n' f'This link expires in {settings.email_verification_expiry_hours} hours.\n', + f'

Hello {safe_username},

Verify your LinkLog email address:

' + f'

Verify email address

' + f'

This link expires in {settings.email_verification_expiry_hours} hours.

', ) @@ -76,15 +128,21 @@ def send_test_email(email: str, smtp_values: dict | None = None) -> None: email, 'LinkLog SMTP test', 'This is a test message from LinkLog. SMTP is configured correctly.\n', - smtp_values, + '

This is a test message from LinkLog.

SMTP is configured correctly.

', + smtp_values=smtp_values, ) def send_password_reset_email(email: str, username: str, reset_url: str) -> None: + safe_username = escape(username) + safe_url = escape(reset_url, quote=True) send_message( email, 'Reset your LinkLog password', f'Hello {username},\n\n' f'Reset your LinkLog password by opening this link:\n{reset_url}\n\n' f'This link expires in {settings.password_reset_expiry_hours} hours.\n', + f'

Hello {safe_username},

Reset your LinkLog password:

' + f'

Reset password

' + f'

This link expires in {settings.password_reset_expiry_hours} hours.

', ) \ No newline at end of file diff --git a/backend/tests/test_email_service.py b/backend/tests/test_email_service.py index b3e26e3..35f3842 100644 --- a/backend/tests/test_email_service.py +++ b/backend/tests/test_email_service.py @@ -3,7 +3,7 @@ from unittest.mock import patch -from backend.app.services.email_service import send_test_email, send_verification_email +from backend.app.services.email_service import send_password_reset_email, send_test_email, send_verification_email def test_send_verification_email_uses_smtp_settings(monkeypatch): @@ -15,6 +15,7 @@ def test_send_verification_email_uses_smtp_settings(monkeypatch): monkeypatch.setattr(settings, 'smtp_username', 'mailer') monkeypatch.setattr(settings, 'smtp_password', 'secret') monkeypatch.setattr(settings, 'smtp_use_tls', True) + monkeypatch.setattr(settings, 'public_url', 'https://linklog.example') with patch('backend.app.services.email_service.SMTP') as smtp_class: smtp = smtp_class.return_value.__enter__.return_value @@ -25,7 +26,22 @@ def test_send_verification_email_uses_smtp_settings(monkeypatch): smtp.login.assert_called_once_with('mailer', 'secret') message = smtp.send_message.call_args.args[0] assert message['To'] == 'user@example.com' - assert 'https://linklog.example/verify' in message.get_content() + assert 'https://linklog.example/verify' in message.get_body(preferencelist=('plain',)).get_content() + html = message.get_body(preferencelist=('html',)).get_content() + assert 'cid:linklog-logo' in html + assert 'width="50" height="50"' in html + assert 'font-family:\'Asset\',Georgia,serif' in html + assert 'Hello, a message from' in html + assert 'vertical-align:top' in html + assert 'padding:20px 0 20px 12px' in html + assert 'font-size:18px' in html + assert 'href="https://linklog.example"' in html + assert '>linklog.example' in html + assert any( + part.get_content_type() == 'image/svg+xml' + and part['Content-ID'] == '' + for part in message.walk() + ) def test_send_test_email_uses_configured_recipient(monkeypatch): @@ -40,6 +56,23 @@ def test_send_test_email_uses_configured_recipient(monkeypatch): message = smtp.send_message.call_args.args[0] assert message['To'] == 'admin@example.com' assert message['Subject'] == 'LinkLog SMTP test' + assert message.get_body(preferencelist=('html',)) is not None + + +def test_password_reset_email_escapes_html_and_includes_logo(monkeypatch): + from backend.app.core.config import settings + + monkeypatch.setattr(settings, 'smtp_host', 'smtp.example.com') + monkeypatch.setattr(settings, 'smtp_from', 'LinkLog ') + with patch('backend.app.services.email_service.SMTP') as smtp_class: + smtp = smtp_class.return_value.__enter__.return_value + send_password_reset_email('user@example.com', '', 'https://linklog.example/reset?x=1&y=2') + + message = smtp.send_message.call_args.args[0] + html = message.get_body(preferencelist=('html',)).get_content() + assert '<User>' in html + assert 'x=1&y=2' in html + assert 'cid:linklog-logo' in html def test_smtp_password_is_encrypted_at_rest():