Mail looks 'improved'
Build LinkLog Development Image / development-image (push) Successful in 19s

This commit is contained in:
2026-08-26 20:27:54 +02:00
parent 314959c7bf
commit b03a241be2
4 changed files with 144 additions and 4 deletions
+42
View File
@@ -1,5 +1,47 @@
# Chat Log # 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 <public hostname>”.
### User
Instead of “Hello” print “Hello a message from <public hostname>”, with the public hostname linking to the LinkLog server.
### Assistant outcome
Updated the branded email header to show “Hello a message from <public hostname>”. 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 ### User
Fix SA-015: Some destructive and administrative operations lack audit logging. Fix SA-015: Some destructive and administrative operations lack audit logging.
+7
View File
@@ -190,6 +190,13 @@
184. Run full backend and frontend test suites. 184. Run full backend and frontend test suites.
185. Solve SA-010: Avatar validation trusts the client MIME type. 185. Solve SA-010: Avatar validation trusts the client MIME type.
186. Fix SA-015: Some destructive and administrative operations lack audit logging. 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 <public hostname>", 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 ## Future entries
+60 -2
View File
@@ -2,13 +2,57 @@
## SPDX-License-Identifier: GPL-3.0-or-later ## SPDX-License-Identifier: GPL-3.0-or-later
from email.message import EmailMessage from email.message import EmailMessage
from html import escape
from pathlib import Path
from smtplib import SMTP from smtplib import SMTP
import json import json
from urllib.parse import urlparse
from backend.app.core.config import settings from backend.app.core.config import settings
from backend.app.database import get_connection from backend.app.database import get_connection
from backend.app.services.secret_store import decrypt_secret, encrypt_secret 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'''<!doctype html>
<html lang="en">
<body style="margin:0;background:#1e1e2e;color:#cdd6f4;font-family:Arial,sans-serif;line-height:1.6;">
<div style="max-width:620px;margin:32px auto;padding:0 20px;">
<div style="background:#11111b;border:1px solid #45475a;border-radius:12px;overflow:hidden;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#181825;">
<tr>
<td style="padding:20px 24px;text-align:left;vertical-align:top;width:50px;">
<img src="cid:linklog-logo" alt="LinkLog" width="50" height="50" style="display:block;width:50px;height:50px;">
</td>
<td style="padding:20px 0 20px 12px;text-align:left;vertical-align:top;">
<span style="color:#cba6f7;font-family:'Asset',Georgia,serif;font-size:18px;line-height:50px;">Hello, a message from <a href="{safe_public_url}" style="color:#cba6f7;text-decoration:underline;">{safe_public_hostname}</a></span>
</td>
</tr>
</table>
<div style="padding:28px 32px;">{body_html}</div>
</div>
<p style="margin:18px 0;text-align:center;color:#a6adc8;font-size:12px;">LinkLog</p>
</div>
</body>
</html>'''
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='<linklog-logo>')
def get_smtp_settings() -> dict: def get_smtp_settings() -> dict:
values = { values = {
@@ -42,7 +86,8 @@ def smtp_configured(smtp_values: dict | None = None) -> bool:
return bool(smtp['smtp_host'] and smtp['smtp_from']) 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() smtp = smtp_values or get_smtp_settings()
if not smtp_configured(smtp): if not smtp_configured(smtp):
raise RuntimeError('SMTP is not configured; set LINKLOG_SMTP_HOST and LINKLOG_SMTP_FROM') 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['From'] = smtp['smtp_from']
message['To'] = email message['To'] = email
message.set_content(body) 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: with SMTP(smtp['smtp_host'], smtp['smtp_port'], timeout=10) as connection:
if smtp['smtp_use_tls']: 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: 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( send_message(
email, email,
'Verify your LinkLog email address', 'Verify your LinkLog email address',
f'Hello {username},\n\n' f'Hello {username},\n\n'
f'Verify your LinkLog email address by opening this link:\n{verification_url}\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'This link expires in {settings.email_verification_expiry_hours} hours.\n',
f'<p>Hello {safe_username},</p><p>Verify your LinkLog email address:</p>'
f'<p><a href="{safe_url}" style="display:inline-block;padding:10px 16px;background:#89b4fa;color:#11111b;text-decoration:none;border-radius:6px;">Verify email address</a></p>'
f'<p style="color:#a6adc8;font-size:14px;">This link expires in {settings.email_verification_expiry_hours} hours.</p>',
) )
@@ -76,15 +128,21 @@ def send_test_email(email: str, smtp_values: dict | None = None) -> None:
email, email,
'LinkLog SMTP test', 'LinkLog SMTP test',
'This is a test message from LinkLog. SMTP is configured correctly.\n', 'This is a test message from LinkLog. SMTP is configured correctly.\n',
smtp_values, '<p>This is a test message from LinkLog.</p><p style="color:#a6adc8;">SMTP is configured correctly.</p>',
smtp_values=smtp_values,
) )
def send_password_reset_email(email: str, username: str, reset_url: str) -> None: 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( send_message(
email, email,
'Reset your LinkLog password', 'Reset your LinkLog password',
f'Hello {username},\n\n' f'Hello {username},\n\n'
f'Reset your LinkLog password by opening this link:\n{reset_url}\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'This link expires in {settings.password_reset_expiry_hours} hours.\n',
f'<p>Hello {safe_username},</p><p>Reset your LinkLog password:</p>'
f'<p><a href="{safe_url}" style="display:inline-block;padding:10px 16px;background:#f38ba8;color:#11111b;text-decoration:none;border-radius:6px;">Reset password</a></p>'
f'<p style="color:#a6adc8;font-size:14px;">This link expires in {settings.password_reset_expiry_hours} hours.</p>',
) )
+35 -2
View File
@@ -3,7 +3,7 @@
from unittest.mock import patch 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): 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_username', 'mailer')
monkeypatch.setattr(settings, 'smtp_password', 'secret') monkeypatch.setattr(settings, 'smtp_password', 'secret')
monkeypatch.setattr(settings, 'smtp_use_tls', True) 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: with patch('backend.app.services.email_service.SMTP') as smtp_class:
smtp = smtp_class.return_value.__enter__.return_value 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') smtp.login.assert_called_once_with('mailer', 'secret')
message = smtp.send_message.call_args.args[0] message = smtp.send_message.call_args.args[0]
assert message['To'] == 'user@example.com' 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</a>' in html
assert any(
part.get_content_type() == 'image/svg+xml'
and part['Content-ID'] == '<linklog-logo>'
for part in message.walk()
)
def test_send_test_email_uses_configured_recipient(monkeypatch): 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] message = smtp.send_message.call_args.args[0]
assert message['To'] == 'admin@example.com' assert message['To'] == 'admin@example.com'
assert message['Subject'] == 'LinkLog SMTP test' 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 <no-reply@example.com>')
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', '<User>', '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 '&lt;User&gt;' in html
assert 'x=1&amp;y=2' in html
assert 'cid:linklog-logo' in html
def test_smtp_password_is_encrypted_at_rest(): def test_smtp_password_is_encrypted_at_rest():