This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>',
|
||||||
)
|
)
|
||||||
@@ -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 '<User>' in html
|
||||||
|
assert 'x=1&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():
|
||||||
|
|||||||
Reference in New Issue
Block a user