Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion cityinfra/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
EMAIL_USE_TLS=(bool, True),
EMAIL_HOST_USER=(str, ""),
EMAIL_HOST_PASSWORD=(str, ""),
DEFAULT_FROM_EMAIL=(str, "cityinfra@hel.fi"),
SENTRY_DSN=(str, ""),
SENTRY_DEBUG=(bool, False),
VERSION=(str, ""),
Expand Down Expand Up @@ -135,7 +136,7 @@
EMAIL_USE_TLS = env("EMAIL_USE_TLS")
EMAIL_HOST_USER = env("EMAIL_HOST_USER")
EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD")
DEFAULT_FROM_EMAIL = "cityinfra@hel.fi"
DEFAULT_FROM_EMAIL = env("DEFAULT_FROM_EMAIL")

# Determine which email backend to use based on environment
TESTING = "test" in sys.argv or "pytest" in sys.argv[0] if sys.argv else False
Expand Down
44 changes: 12 additions & 32 deletions traffic_control/management/commands/send_test_email.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
import smtplib
from typing import Any

from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError, CommandParser
from django.core.validators import validate_email

from traffic_control.services.email import send_email


class Command(BaseCommand):
Expand All @@ -31,62 +31,42 @@ def handle(self, *args: Any, **options: Any) -> None:
"""Execute the command to send test email.

Security measures implemented:
- Email addresses are validated using Django's validate_email()
- Maximum 10 recipients to prevent abuse
- Email validation is handled by the send_email service function
- Email body, subject, and sender are hardcoded (no user input)
- Log output is sanitized to prevent log injection
- Django's send_mail() automatically sanitizes email headers

Args:
*args: Positional arguments.
**options: Command options including 'recipient'.

Raises:
CommandError: If email sending fails due to SMTP or configuration errors.
CommandError: If email sending fails due to SMTP, configuration, or validation errors.
"""
recipient_input = options["recipient"]
recipients = [email.strip() for email in recipient_input.split(",") if email.strip()]

# Security: Validate all recipient email addresses to prevent injection attacks
# This prevents malformed addresses and email header injection attempts
for recipient in recipients:
try:
validate_email(recipient)
except ValidationError:
# Sanitize error message to prevent log injection
safe_recipient = recipient.replace("\n", "").replace("\r", "")[:100]
raise CommandError(f"Invalid email address: {safe_recipient}")

if not recipients:
raise CommandError("No valid recipient email addresses provided")

# Security: Limit number of recipients to prevent abuse and spam
max_recipients = 10
if len(recipients) > max_recipients:
raise CommandError(f"Too many recipients. Maximum allowed: {max_recipients}")

# Security: All email content is hardcoded - no user input in email body, subject, or sender
# This prevents email content injection attacks
sender = "cityinfra@hel.fi"
subject = "Cityinfra email test"
message = "Test message"

# Note: recipients are already validated above, but we sanitize for logging
# to prevent log injection (remove newlines/carriage returns)
safe_recipients_for_log = [r.replace("\n", "").replace("\r", "") for r in recipients]
# Sanitize recipient list for logging to prevent log injection (remove newlines/carriage returns)
safe_recipients_for_log = [r.replace("\n", "").replace("\r", "")[:100] for r in recipients]
self.stdout.write(f"Sending test email to: {', '.join(safe_recipients_for_log)}")
self.stdout.write(f"From: {sender}")
self.stdout.write(f"From: {settings.DEFAULT_FROM_EMAIL}")
self.stdout.write(f"Subject: {subject}")

try:
send_mail(
send_email(
subject=subject,
message=message,
from_email=sender,
recipient_list=recipients,
fail_silently=False,
)
self.stdout.write(self.style.SUCCESS(f"Successfully sent test email to {len(recipients)} recipient(s)"))
except ValueError as e:
# Validation errors from send_email service
raise CommandError(str(e))
except smtplib.SMTPException as e:
raise CommandError(f"SMTP error occurred. Please check your email server configuration. Error: {str(e)}")
except Exception as e:
Expand Down
62 changes: 62 additions & 0 deletions traffic_control/services/email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from django.core.exceptions import ValidationError
from django.core.mail import send_mail
from django.core.validators import validate_email


def send_email(
subject: str,
message: str,
recipient_list: list[str],
fail_silently: bool = False,
html_message: str | None = None,
max_recipients: int = 10,
) -> int:
"""
Sends an email using Django's send_mail with from_email set to None.
This will use the DEFAULT_FROM_EMAIL from Django settings.

Security measures implemented:
- Email addresses are validated using Django's validate_email()
- Maximum recipients limit to prevent abuse
- Django's send_mail() automatically sanitizes email headers

Args:
subject (str): The subject line of the email.
message (str): The plain text message body.
recipient_list (list[str]): List of recipient email addresses.
fail_silently (bool): If True, exceptions during send are suppressed. Defaults to False.
html_message (str | None): Optional HTML version of the message. Defaults to None.
max_recipients (int): Maximum number of recipients allowed. Defaults to 10.

Returns:
int: Number of successfully sent emails.

Raises:
ValueError: If any email address is invalid, recipient list is empty, or too many recipients.
"""
# Validate recipient list is not empty
if not recipient_list:
raise ValueError("No recipient email addresses provided")

# Validate all recipient email addresses to prevent injection attacks
# This prevents malformed addresses and email header injection attempts
for recipient in recipient_list:
try:
validate_email(recipient)
except ValidationError:
# Sanitize error message to prevent log injection
safe_recipient = recipient.replace("\n", "").replace("\r", "")[:100]
raise ValueError(f"Invalid email address: {safe_recipient}")

# Limit number of recipients to prevent abuse and spam
if len(recipient_list) > max_recipients:
raise ValueError(f"Too many recipients. Maximum allowed: {max_recipients}")

return send_mail(
subject=subject,
message=message,
from_email=None,
recipient_list=recipient_list,
fail_silently=fail_silently,
html_message=html_message,
)
Original file line number Diff line number Diff line change
@@ -1,32 +1,52 @@
"""Tests for send_test_email management command."""

import pytest
from django.core import mail
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import override_settings


@pytest.mark.parametrize(
"from_email",
[
"cityinfra@hel.fi",
"custom@example.com",
"another@test.org",
],
)
@pytest.mark.django_db
def test_send_test_email_single_recipient() -> None:
def test_send_test_email_single_recipient(from_email: str) -> None:
"""Test sending email to a single recipient.

Verifies that the command successfully sends an email with correct
sender, subject, and recipient information.

Args:
from_email: The email address to use as the sender.
"""

recipient = "test@example.com"

call_command("send_test_email", "--recipient", recipient)
with override_settings(DEFAULT_FROM_EMAIL=from_email):
call_command("send_test_email", "--recipient", recipient)

assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.subject == "Cityinfra email test"
assert email.body == "Test message"
assert email.from_email == "cityinfra@hel.fi"
assert email.from_email == from_email
assert email.to == [recipient]


@pytest.mark.parametrize(
"from_email",
[
"cityinfra@hel.fi",
"custom@example.com",
"another@test.org",
],
)
@pytest.mark.django_db
def test_send_test_email_multiple_recipients() -> None:
def test_send_test_email_multiple_recipients(from_email: str) -> None:
"""Test sending email to multiple comma-separated recipients.

Verifies that the command correctly parses comma-separated email
Expand All @@ -35,12 +55,13 @@ def test_send_test_email_multiple_recipients() -> None:
recipients = "test1@example.com,test2@example.com,test3@example.com"
expected_recipients = ["test1@example.com", "test2@example.com", "test3@example.com"]

call_command("send_test_email", "--recipient", recipients)
with override_settings(DEFAULT_FROM_EMAIL=from_email):
call_command("send_test_email", "--recipient", recipients)

assert len(mail.outbox) == 1
email = mail.outbox[0]
assert email.subject == "Cityinfra email test"
assert email.from_email == "cityinfra@hel.fi"
assert email.from_email == from_email
assert email.to == expected_recipients


Expand Down Expand Up @@ -111,5 +132,5 @@ def test_send_test_email_empty_recipients() -> None:

Verifies that the command rejects empty or whitespace-only recipient strings.
"""
with pytest.raises(CommandError, match="No valid recipient email addresses"):
with pytest.raises(CommandError, match="No recipient email addresses provided"):
call_command("send_test_email", "--recipient", " , , ")
Loading
Loading