From 3bbd1eb19975c3d15176c24e5d5d73c9cd9cae39 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:14:41 +0000 Subject: [PATCH] Fix email template formatting - convert newlines to br tags and increase header padding Co-authored-by: cadtoolbox <12723486+cadtoolbox@users.noreply.github.com> --- backend/app/services/email_service.py | 44 ++--- .../tests/unit/services/test_email_service.py | 162 ++++++++++++++++++ 2 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 backend/tests/unit/services/test_email_service.py diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py index 937adfdd6..c308c4e39 100644 --- a/backend/app/services/email_service.py +++ b/backend/app/services/email_service.py @@ -67,9 +67,7 @@ async def get_notification_template(db: AsyncSession, event_type: str) -> Notifi Returns: NotificationTemplate object or None if not found """ - result = await db.execute( - select(NotificationTemplate).where(NotificationTemplate.event_type == event_type) - ) + result = await db.execute(select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)) return result.scalar_one_or_none() @@ -103,17 +101,19 @@ async def get_smtp_settings(db: AsyncSession) -> SMTPSettings | None: # Fetch all SMTP-related settings result = await db.execute( select(Settings).where( - Settings.key.in_([ - "smtp_host", - "smtp_port", - "smtp_username", - "smtp_password", - "smtp_use_tls", - "smtp_security", - "smtp_auth_enabled", - "smtp_from_email", - "smtp_from_name", - ]) + Settings.key.in_( + [ + "smtp_host", + "smtp_port", + "smtp_username", + "smtp_password", + "smtp_use_tls", + "smtp_security", + "smtp_auth_enabled", + "smtp_from_email", + "smtp_from_name", + ] + ) ) ) settings_dict = {s.key: s.value for s in result.scalars().all()} @@ -417,8 +417,8 @@ async def create_welcome_email_from_template( text_body = render_template(template.body_template, variables) # Create HTML version with embedded login button - # Escape text_body to prevent XSS vulnerabilities - escaped_text_body = html.escape(text_body) + # Escape text_body to prevent XSS vulnerabilities and convert newlines to
tags + escaped_text_body = html.escape(text_body).replace("\n", "
\n") html_body = f""" @@ -426,11 +426,11 @@ async def create_welcome_email_from_template( -
+

{html.escape(subject)}

-
{escaped_text_body}
+
{escaped_text_body}
Login Now @@ -479,8 +479,8 @@ async def create_password_reset_email_from_template( text_body = render_template(template.body_template, variables) # Create HTML version with embedded login button - # Escape text_body to prevent XSS vulnerabilities - escaped_text_body = html.escape(text_body) + # Escape text_body to prevent XSS vulnerabilities and convert newlines to
tags + escaped_text_body = html.escape(text_body).replace("\n", "
\n") html_body = f""" @@ -488,11 +488,11 @@ async def create_password_reset_email_from_template( -
+

{html.escape(subject)}

-
{escaped_text_body}
+
{escaped_text_body}
Login Now diff --git a/backend/tests/unit/services/test_email_service.py b/backend/tests/unit/services/test_email_service.py new file mode 100644 index 000000000..50222377d --- /dev/null +++ b/backend/tests/unit/services/test_email_service.py @@ -0,0 +1,162 @@ +"""Unit tests for email service. + +These tests verify email template rendering and HTML formatting. +""" + +from unittest.mock import AsyncMock, patch + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from backend.app.models.notification_template import NotificationTemplate +from backend.app.services.email_service import ( + create_password_reset_email_from_template, + create_welcome_email_from_template, +) + + +class TestEmailTemplateFormatting: + """Tests for email template formatting.""" + + @pytest.mark.asyncio + async def test_welcome_email_newlines_converted_to_br(self): + """Verify that newlines in welcome email body are converted to
tags.""" + # Mock database session + db = AsyncMock(spec=AsyncSession) + + # Mock template with newlines + template = NotificationTemplate( + event_type="user_created", + name="Welcome Email", + title_template="Welcome to {app_name}", + body_template="Hello {username}!\n\nYour password is: {password}\n\nPlease login at: {login_url}", + is_default=True, + ) + + # Patch get_notification_template to return our template + with patch("backend.app.services.email_service.get_notification_template", return_value=template): + # Generate email + subject, text_body, html_body = await create_welcome_email_from_template( + db=db, + username="testuser", + password="testpass123", + login_url="http://example.com/login", + app_name="TestApp", + ) + + # Verify subject + assert subject == "Welcome to TestApp" + + # Verify text body has newlines + assert "\n\n" in text_body + assert "Hello testuser!" in text_body + assert "Your password is: testpass123" in text_body + + # Verify HTML body has
tags instead of relying on CSS + assert "
" in html_body + # Should not use white-space: pre-wrap + assert "white-space: pre-wrap" not in html_body + # Should have proper structure + assert "" in html_body + assert '
' in html_body + + # Verify that escaped content is present (XSS protection) + assert "Hello testuser!
" in html_body + assert "Your password is: testpass123
" in html_body + + @pytest.mark.asyncio + async def test_password_reset_email_newlines_converted_to_br(self): + """Verify that newlines in password reset email body are converted to
tags.""" + # Mock database session + db = AsyncMock(spec=AsyncSession) + + # Mock template with newlines + template = NotificationTemplate( + event_type="password_reset", + name="Password Reset", + title_template="{app_name} - Password Reset", + body_template="Hello {username},\n\nYour password has been reset.\nNew password: {password}\n\nLogin at: {login_url}", + is_default=True, + ) + + # Patch get_notification_template to return our template + with patch("backend.app.services.email_service.get_notification_template", return_value=template): + # Generate email + subject, text_body, html_body = await create_password_reset_email_from_template( + db=db, + username="testuser", + password="newpass456", + login_url="http://example.com/login", + app_name="TestApp", + ) + + # Verify subject + assert subject == "TestApp - Password Reset" + + # Verify text body has newlines + assert "\n\n" in text_body + assert "Hello testuser," in text_body + + # Verify HTML body has
tags + assert "
" in html_body + # Should not use white-space: pre-wrap + assert "white-space: pre-wrap" not in html_body + # Should have security alert + assert "Security Alert" in html_body + + @pytest.mark.asyncio + async def test_email_header_padding(self): + """Verify that email header has proper padding to prevent cutoff.""" + # Mock database session + db = AsyncMock(spec=AsyncSession) + + # Mock template + template = NotificationTemplate( + event_type="user_created", + name="Welcome Email", + title_template="Welcome", + body_template="Test body", + is_default=True, + ) + + # Patch get_notification_template to return our template + with patch("backend.app.services.email_service.get_notification_template", return_value=template): + # Generate email + subject, text_body, html_body = await create_welcome_email_from_template( + db=db, + username="testuser", + password="testpass123", + login_url="http://example.com/login", + ) + + # Verify header has 30px padding (not 20px which was cutting off) + assert "padding: 30px; border-radius: 8px 8px 0 0;" in html_body + + @pytest.mark.asyncio + async def test_email_xss_protection(self): + """Verify that HTML escaping is applied to prevent XSS attacks.""" + # Mock database session + db = AsyncMock(spec=AsyncSession) + + # Mock template with potential XSS content + template = NotificationTemplate( + event_type="user_created", + name="Welcome Email", + title_template="Welcome ", + body_template="Hello \nTest", + is_default=True, + ) + + # Patch get_notification_template to return our template + with patch("backend.app.services.email_service.get_notification_template", return_value=template): + # Generate email + subject, text_body, html_body = await create_welcome_email_from_template( + db=db, + username="testuser", + password="testpass123", + login_url="http://example.com/login", + ) + + # Verify that script tags are escaped + assert "<script>" in html_body + assert "