This commit is contained in:
@@ -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'''<!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:
|
||||
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'<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,
|
||||
'LinkLog SMTP test',
|
||||
'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:
|
||||
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'<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 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</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):
|
||||
@@ -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 <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():
|
||||
|
||||
Reference in New Issue
Block a user