diff --git a/CHANGELOG.md b/CHANGELOG.md index 846666a..5a29e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#17](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/17) Notify use-case admins of pending access requests - [#18](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/18) Use English copy in API response payloads - [#26](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/26) Support proxy CSRF settings for admin forms behind HTTPS reverse proxies +- [#31](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/31/) Add a `send_test_email` management command and configurable Django email backend. ### `Fixed` diff --git a/README.md b/README.md index 30988a2..153c0a2 100644 --- a/README.md +++ b/README.md @@ -291,8 +291,10 @@ safe defaults and normally do not need to be changed. | `EMAIL_PORT` | `1025` or `587` | SMTP port. | | `EMAIL_HOST_USER` | SMTP username | Optional SMTP auth username. | | `EMAIL_HOST_PASSWORD` | SMTP password | Optional SMTP auth password. | +| `EMAIL_BACKEND` | `django.core.mail.backends.smtp.EmailBackend` | Django email backend. Use `django.core.mail.backends.console.EmailBackend` for CLI-only development checks. | | `EMAIL_USE_TLS` | `false` or `true` | Whether SMTP uses TLS. | | `DEFAULT_FROM_EMAIL` | `no-reply@pathocore.local` | Sender shown in PathoCore API emails. | +| `ALLOWED_EMAIL_DOMAINS` | `ciberisciii.es,externos.isciii.es` | Optional comma-separated recipient domain allow-list for the test email command. | | `PATHOCORE_ACCESS_REQUEST_ADMIN_EMAILS` | `admin@example.org` | Optional fallback/copy recipients if Keycloak use-case admins are not found. | New access requests notify admins from the Keycloak group @@ -527,6 +529,19 @@ In the local Docker test stack these messages are captured by Mailpit: http://127.0.0.1:8025 ``` +Send a test email with the active Django settings: + +```bash +python manage.py send_test_email user@example.org +``` + +For a console-only check, override the backend: + +```bash +EMAIL_BACKEND=django.core.mail.backends.console.EmailBackend \ + python manage.py send_test_email user@example.org +``` + Revocation removes the approved Keycloak group but does not disable the whole account. diff --git a/conf/docker_production_settings.txt b/conf/docker_production_settings.txt index 4a839b6..4da9b92 100644 --- a/conf/docker_production_settings.txt +++ b/conf/docker_production_settings.txt @@ -50,8 +50,10 @@ EMAIL_HOST="change_me_smtp_host" EMAIL_PORT=587 EMAIL_HOST_USER="" EMAIL_HOST_PASSWORD="" +EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" EMAIL_USE_TLS="true" DEFAULT_FROM_EMAIL="no-reply@pathocore.local" +ALLOWED_EMAIL_DOMAINS="" ### Logs settings LOG_TYPE="regular_folder" diff --git a/conf/docker_test_settings.txt b/conf/docker_test_settings.txt index 72f828e..2f9f4bc 100644 --- a/conf/docker_test_settings.txt +++ b/conf/docker_test_settings.txt @@ -50,8 +50,10 @@ EMAIL_HOST="mailpit" EMAIL_PORT=1025 EMAIL_HOST_USER="" EMAIL_HOST_PASSWORD="" +EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" EMAIL_USE_TLS="false" DEFAULT_FROM_EMAIL="no-reply@pathocore.local" +ALLOWED_EMAIL_DOMAINS="" ### Logs settings LOG_TYPE="regular_folder" diff --git a/conf/template_install_settings.txt b/conf/template_install_settings.txt index 1b835c6..66b6947 100644 --- a/conf/template_install_settings.txt +++ b/conf/template_install_settings.txt @@ -52,8 +52,10 @@ EMAIL_HOST="" EMAIL_PORT=587 EMAIL_HOST_USER="" EMAIL_HOST_PASSWORD="" +EMAIL_BACKEND="django.core.mail.backends.smtp.EmailBackend" EMAIL_USE_TLS="true" DEFAULT_FROM_EMAIL="no-reply@pathocore.local" +ALLOWED_EMAIL_DOMAINS="" ### Logs settings LOG_TYPE="symbolic_link" # can be symbolic link, or regular_folder diff --git a/conf/template_settings.py b/conf/template_settings.py index 70f32de..92bc877 100644 --- a/conf/template_settings.py +++ b/conf/template_settings.py @@ -239,6 +239,9 @@ def _csv_env(name, default=None): EMAIL_PORT = _int_env("EMAIL_PORT", "emailport") EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "emailhostuser") EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "emailhostpassword") +EMAIL_BACKEND = os.environ.get( + "EMAIL_BACKEND", "django.core.mail.backends.smtp.EmailBackend" +) EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "emailhosttls").lower() in ( "1", "true", @@ -246,6 +249,7 @@ def _csv_env(name, default=None): "on", ) DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "pathocore-api@localhost") +ALLOWED_EMAIL_DOMAINS = _csv_env("ALLOWED_EMAIL_DOMAINS") SPECTACULAR_SETTINGS = { "TITLE": "PathoCore API", diff --git a/core/management/commands/send_test_email.py b/core/management/commands/send_test_email.py new file mode 100644 index 0000000..577a13f --- /dev/null +++ b/core/management/commands/send_test_email.py @@ -0,0 +1,82 @@ +from email.utils import parseaddr + +from django.conf import settings +from django.core.mail import send_mail +from django.core.management.base import BaseCommand, CommandError + + +def _email_domain(address): + _, parsed_address = parseaddr(address or "") + if "@" not in parsed_address: + return "" + return parsed_address.rsplit("@", 1)[1].lower() + + +class Command(BaseCommand): + help = "Send a test email using the active Django email settings." + + def add_arguments(self, parser): + parser.add_argument("recipient", help="Recipient email address.") + parser.add_argument( + "--subject", + default="PathoCore email test", + help="Email subject. Default: PathoCore email test", + ) + parser.add_argument( + "--message", + default="This is a PathoCore email test.", + help="Plain-text email body.", + ) + parser.add_argument( + "--from-email", + default=None, + help="Sender address. Defaults to DEFAULT_FROM_EMAIL.", + ) + parser.add_argument( + "--ignore-domain-policy", + action="store_true", + help="Skip ALLOWED_EMAIL_DOMAINS validation for this test send.", + ) + + def handle(self, *args, **options): + recipient = options["recipient"].strip() + from_email = options["from_email"] or settings.DEFAULT_FROM_EMAIL + allowed_domains = { + domain.lower() for domain in getattr(settings, "ALLOWED_EMAIL_DOMAINS", []) + } + + if not _email_domain(recipient): + raise CommandError("recipient must be a valid email address") + + recipient_domain = _email_domain(recipient) + if ( + allowed_domains + and not options["ignore_domain_policy"] + and recipient_domain not in allowed_domains + ): + raise CommandError( + "recipient domain '%s' is not in ALLOWED_EMAIL_DOMAINS" + % recipient_domain + ) + + self.stdout.write("EMAIL_BACKEND=%s" % settings.EMAIL_BACKEND) + self.stdout.write("EMAIL_HOST=%s" % getattr(settings, "EMAIL_HOST", "")) + self.stdout.write("EMAIL_PORT=%s" % getattr(settings, "EMAIL_PORT", "")) + self.stdout.write("EMAIL_USE_TLS=%s" % getattr(settings, "EMAIL_USE_TLS", "")) + self.stdout.write("DEFAULT_FROM_EMAIL=%s" % settings.DEFAULT_FROM_EMAIL) + if allowed_domains: + self.stdout.write( + "ALLOWED_EMAIL_DOMAINS=%s" % ",".join(sorted(allowed_domains)) + ) + + sent_count = send_mail( + options["subject"], + options["message"], + from_email, + [recipient], + fail_silently=False, + ) + + self.stdout.write( + self.style.SUCCESS("Sent %s test email(s) to %s" % (sent_count, recipient)) + ) diff --git a/core/tests.py b/core/tests.py index c1658fd..fc570b9 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,5 +1,6 @@ import base64 from datetime import date +from io import StringIO from unittest.mock import patch from django.contrib.auth.models import User @@ -7,6 +8,7 @@ from django.core.cache import cache from django.core.exceptions import PermissionDenied from django.core.management import call_command +from django.core.management.base import CommandError from django.test import SimpleTestCase, TestCase from django.test import override_settings from django.utils import timezone @@ -1491,6 +1493,42 @@ def test_command_creates_or_updates_default_superuser(self): self.assertTrue(user.check_password("new_pass")) +class SendTestEmailCommandTests(SimpleTestCase): + @override_settings( + ALLOWED_EMAIL_DOMAINS=["ciberisciii.es"], + DEFAULT_FROM_EMAIL="no-reply@pathocore.local", + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + EMAIL_HOST="mailpit", + EMAIL_PORT=1025, + EMAIL_USE_TLS=False, + ) + def test_command_uses_configured_backend(self): + mail.outbox = [] + stdout = StringIO() + + call_command("send_test_email", "da.valle@ciberisciii.es", stdout=stdout) + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(mail.outbox[0].to, ["da.valle@ciberisciii.es"]) + self.assertEqual(mail.outbox[0].from_email, "no-reply@pathocore.local") + self.assertIn( + "EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend", + stdout.getvalue(), + ) + self.assertIn("Sent 1 test email(s)", stdout.getvalue()) + + @override_settings( + ALLOWED_EMAIL_DOMAINS=["ciberisciii.es"], + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + ) + def test_command_rejects_disallowed_recipient_domain(self): + with self.assertRaisesMessage( + CommandError, + "recipient domain 'example.org' is not in ALLOWED_EMAIL_DOMAINS", + ): + call_command("send_test_email", "user@example.org") + + @override_settings( ROOT_URLCONF="conf.urls", ALLOWED_HOSTS=["testserver", "localhost"], diff --git a/docker-compose.test.yml b/docker-compose.test.yml index ce3f7e4..97b20e5 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -66,8 +66,10 @@ services: EMAIL_PORT: ${EMAIL_PORT:-1025} EMAIL_HOST_USER: ${EMAIL_HOST_USER:-} EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-} + EMAIL_BACKEND: ${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend} EMAIL_USE_TLS: ${EMAIL_USE_TLS:-false} DEFAULT_FROM_EMAIL: ${DEFAULT_FROM_EMAIL:-no-reply@pathocore.local} + ALLOWED_EMAIL_DOMAINS: ${ALLOWED_EMAIL_DOMAINS:-} DATABROWSER_CACHE_SCHEDULER_ENABLED: ${DATABROWSER_CACHE_SCHEDULER_ENABLED:-true} # Python weekday: Monday=0, Friday=4. DATABROWSER_CACHE_REFRESH_WEEKDAY: ${DATABROWSER_CACHE_REFRESH_WEEKDAY:-4} diff --git a/install.sh b/install.sh index 8c5a657..ed6750e 100755 --- a/install.sh +++ b/install.sh @@ -134,10 +134,14 @@ write_runtime_env_file() { write_runtime_env_var "EMAIL_PORT" "${EMAIL_PORT:-587}" write_runtime_env_var "EMAIL_HOST_USER" "${EMAIL_HOST_USER:-}" write_runtime_env_var "EMAIL_HOST_PASSWORD" "${EMAIL_HOST_PASSWORD:-}" + write_runtime_env_var \ + "EMAIL_BACKEND" \ + "${EMAIL_BACKEND:-django.core.mail.backends.smtp.EmailBackend}" write_runtime_env_var "EMAIL_USE_TLS" "${EMAIL_USE_TLS:-true}" write_runtime_env_var \ "DEFAULT_FROM_EMAIL" \ "${DEFAULT_FROM_EMAIL:-no-reply@pathocore.local}" + write_runtime_env_var "ALLOWED_EMAIL_DOMAINS" "${ALLOWED_EMAIL_DOMAINS:-}" for env_key in $(compgen -A variable KEYCLOAK_ | sort); do write_runtime_env_var "$env_key" "${!env_key}" done