From 1acc3dc8a390705adbc1f861c1af5460e23f6301 Mon Sep 17 00:00:00 2001 From: Daniel-VM Date: Thu, 27 Aug 2026 16:10:20 +0200 Subject: [PATCH 1/6] Add email test command --- CHANGELOG.md | 1 + README.md | 15 ++++ conf/docker_production_settings.txt | 2 + conf/docker_test_settings.txt | 2 + conf/template_install_settings.txt | 2 + conf/template_settings.py | 4 + core/management/commands/send_test_email.py | 82 +++++++++++++++++++++ core/tests.py | 38 ++++++++++ docker-compose.test.yml | 2 + install.sh | 4 + 10 files changed, 152 insertions(+) create mode 100644 core/management/commands/send_test_email.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 846666a..ac7d328 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 +- 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 From e96a89b56c6dcba416bcbc155ef3f844dac27ad3 Mon Sep 17 00:00:00 2001 From: Daniel-VM Date: Thu, 27 Aug 2026 16:16:46 +0200 Subject: [PATCH 2/6] update changelog in #31 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac7d328..5a29e29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +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 -- Add a `send_test_email` management command and configurable Django email backend. +- [#31](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/31/) Add a `send_test_email` management command and configurable Django email backend. ### `Fixed` From a11bcb2b4d4aad2b0070e241d643b501df460194 Mon Sep 17 00:00:00 2001 From: Daniel-VM Date: Thu, 27 Aug 2026 17:13:08 +0200 Subject: [PATCH 3/6] Improve access request email templates --- CHANGELOG.md | 1 + README.md | 5 +- core/api/services/access_requests.py | 258 +++++++++++++++++++++++++-- core/tests.py | 32 +++- 4 files changed, 272 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a29e29..e696106 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#27](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/27) Move API documentation under `/v1` - [#28](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/28) Keep API documentation public - [#30](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/30) Thread access request notification emails +- Send access request notifications as multipart plain-text and HTML emails. ### `Added` diff --git a/README.md b/README.md index 153c0a2..a4976d3 100644 --- a/README.md +++ b/README.md @@ -520,8 +520,9 @@ matching use-case admin group, for example `/use-cases/mepram/admin`. Configure `PATHOCORE_ACCESS_REQUEST_ADMIN_EMAILS` only as a fallback or general copy. Rejected and revoked notifications include the review note as the reason and the available use-case admin contact emails. -All access request workflow emails include a plain-text footer with PathoCore / -MEPRAM DataHub and the technical platform links. +Access request workflow emails are sent as multipart plain text and HTML. +The HTML version presents the requested use-case, role and web section in a +structured summary instead of exposing raw group paths in the main sentence. In the local Docker test stack these messages are captured by Mailpit: diff --git a/core/api/services/access_requests.py b/core/api/services/access_requests.py index 74ac4f0..34035cd 100644 --- a/core/api/services/access_requests.py +++ b/core/api/services/access_requests.py @@ -1,8 +1,9 @@ from email.utils import parseaddr +from html import escape from django.conf import settings from django.contrib.auth import get_user_model -from django.core.mail import EmailMessage +from django.core.mail import EmailMultiAlternatives from django.db import transaction from django.db.models import Q from django.utils import timezone @@ -25,9 +26,6 @@ Technical platforms: BIPLAT-CIBERINFEC https://github.com/BIPLAT-CIBERINFEC/ - -BU-ISCIII -https://github.com/BU-ISCIII """ @@ -328,17 +326,22 @@ def build_group_path_from_scope(scope): def notify_access_request_created(access_request): - requested_access = _requested_access_label(access_request) + access_summary = _requested_access_text(access_request) _send_access_request_email( access_request, message=_with_email_footer( f"Hello {access_request.first_name},\n\n" "We have received your PathoCore access request and it is pending " "administrator review.\n\n" - f"Requested access: {requested_access}\n" + f"Requested access:\n{access_summary}\n\n" "You will receive another notification once the request has been " "reviewed.\n" ), + title="Access request received", + intro=( + "We have received your PathoCore access request and it is pending " + "administrator review." + ), recipient_list=[access_request.email], message_key="received", ) @@ -350,9 +353,18 @@ def notify_access_request_created(access_request): access_request, message=_with_email_footer( f"User: {access_request.username} <{access_request.email}>\n" - f"Requested: {requested_access}\n" + "Requested access:\n" + f"{_requested_access_text(access_request, include_group=True)}\n" f"Message: {access_request.message or '-'}" ), + title="New access request pending review", + intro=( + f"{access_request.username} requested access to " + f"{_requested_access_label(access_request)}." + ), + note_label="User message", + note=access_request.message or "-", + include_group=True, recipient_list=recipients, message_key="admin-pending", reply_to_thread=True, @@ -383,7 +395,7 @@ def _unique_emails(emails): def notify_access_request_reviewed(access_request): - requested_access = _requested_access_label(access_request) + access_summary = _requested_access_text(access_request) section_url = _use_case_section_url(access_request) section_line = ( f"Web section: {section_url}\n\n" @@ -394,7 +406,7 @@ def notify_access_request_reviewed(access_request): if access_request.status == core.models.AccessRequest.STATUS_APPROVED: status_message = ( "Your PathoCore access request has been approved.\n\n" - f"You requested access to: {requested_access}\n" + f"Requested access:\n{access_summary}\n\n" f"{section_line}" "If this is your first access, you will receive a Keycloak email " "to verify your email and set your password." @@ -406,7 +418,7 @@ def notify_access_request_reviewed(access_request): elif access_request.status == core.models.AccessRequest.STATUS_REJECTED: status_message = ( "Your PathoCore access request has been rejected.\n\n" - f"Requested access: {requested_access}\n" + f"Requested access:\n{access_summary}\n\n" f"Reason: {access_request.review_note or '-'}\n" f"{_admin_contact_line(access_request)}" ) @@ -424,6 +436,10 @@ def notify_access_request_reviewed(access_request): _send_access_request_email( access_request, message=_with_email_footer(message), + title=_review_email_title(access_request), + intro=status_message.split("\n\n", 1)[0], + note_label="Review note", + note=access_request.review_note or "-", recipient_list=[access_request.email], message_key=access_request.status, reply_to_thread=True, @@ -431,15 +447,18 @@ def notify_access_request_reviewed(access_request): def notify_access_request_revoked(access_request): - revoked_access = _requested_access_label(access_request) _send_access_request_email( access_request, message=_with_email_footer( "Your PathoCore access has been revoked.\n\n" - f"Revoked access: {revoked_access}\n" + f"Revoked access:\n{_requested_access_text(access_request)}\n\n" f"Reason: {access_request.review_note or '-'}\n" f"{_admin_contact_line(access_request)}" ), + title="Access revoked", + intro="Your PathoCore access has been revoked.", + note_label="Reason", + note=access_request.review_note or "-", recipient_list=[access_request.email], message_key="revoked", reply_to_thread=True, @@ -450,11 +469,16 @@ def _send_access_request_email( access_request, *, message, + title, + intro, recipient_list, message_key, + note_label=None, + note=None, + include_group=False, reply_to_thread=False, ): - email = EmailMessage( + email = EmailMultiAlternatives( subject=_access_request_email_subject(access_request), body=message, from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), @@ -465,6 +489,17 @@ def _send_access_request_email( reply_to_thread=reply_to_thread, ), ) + email.attach_alternative( + _access_request_email_html( + access_request, + title=title, + intro=intro, + note_label=note_label, + note=note, + include_group=include_group, + ), + "text/html", + ) email.send(fail_silently=True) @@ -513,9 +548,41 @@ def _with_email_footer(message): def _requested_access_label(access_request): use_case_label = _use_case_label(access_request.requested_use_case) - role = access_request.requested_role + return f"{use_case_label} {_role_label(access_request.requested_role)} access" + + +def _requested_access_text(access_request, *, include_group=False): + use_case_label = _use_case_label(access_request.requested_use_case) + lines = [ + f"- Use case: {use_case_label}", + f"- Role: {_role_label(access_request.requested_role)}", + ] + section_url = _use_case_section_url(access_request) + if section_url: + lines.append(f"- Web section: {section_url}") + if include_group: + lines.append(f"- Permission group: {_permission_group_path(access_request)}") + return "\n".join(lines) + + +def _permission_group_path(access_request): group_path = access_request.approved_group or build_group_path(access_request) - return f"{use_case_label} ({role}) [{group_path}]" + return group_path + + +def _role_label(role): + return str(role or "").strip().replace("_", " ").title() + + +def _review_email_title(access_request): + titles = { + core.models.AccessRequest.STATUS_APPROVED: "Access request approved", + core.models.AccessRequest.STATUS_REJECTED: "Access request rejected", + } + return titles.get( + access_request.status, + "Access request status updated", + ) def _use_case_label(use_case_name): @@ -539,6 +606,167 @@ def _admin_contact_line(access_request): return f"If you have questions, contact: {', '.join(contacts)}\n" +def _access_request_email_html( + access_request, + *, + title, + intro, + note_label=None, + note=None, + include_group=False, +): + use_case_label = _use_case_label(access_request.requested_use_case) + role_label = _role_label(access_request.requested_role) + group_path = _permission_group_path(access_request) + section_url = _use_case_section_url(access_request) + page_style = _style( + "margin:0", + "padding:0", + "background:#f8fafc", + "color:#0f172a", + "font-family:Arial,Helvetica,sans-serif", + ) + wrapper_style = _style("background:#f8fafc", "padding:24px 0") + card_style = _style( + "max-width:640px", + "width:100%", + "background:#ffffff", + "border:1px solid #e2e8f0", + "border-radius:6px", + "overflow:hidden", + ) + header_style = _style("padding:20px 24px", "background:#0f172a", "color:#ffffff") + eyebrow_style = _style( + "font-size:13px", + "letter-spacing:.04em", + "text-transform:uppercase", + "color:#99f6e4", + ) + title_style = _style( + "margin:8px 0 0", + "font-size:22px", + "line-height:1.3", + "font-weight:700", + ) + intro_style = _style( + "margin:0 0 20px", + "font-size:15px", + "line-height:1.6", + "color:#334155", + ) + table_style = _style( + "border:1px solid #e2e8f0", + "border-radius:6px", + "border-collapse:separate", + "border-spacing:0", + "overflow:hidden", + ) + label_style = _style("padding:12px 16px", "color:#475569") + value_style = _style("padding:12px 16px", "color:#0f172a") + top_label_style = _style(label_style, "border-top:1px solid #e2e8f0") + top_value_style = _style(value_style, "border-top:1px solid #e2e8f0") + group_style = _style( + top_value_style, + "font-family:Consolas,Menlo,monospace", + "font-size:13px", + ) + button_style = _style( + "display:inline-block", + "padding:10px 16px", + "background:#0f766e", + "color:#ffffff", + "text-decoration:none", + "border-radius:4px", + "font-weight:600", + ) + footer_style = _style( + "padding:16px 24px", + "background:#f1f5f9", + "color:#475569", + "font-size:13px", + "line-height:1.5", + ) + link_style = _style("color:#0f766e", "text-decoration:none") + note_html = "" + if note_label and note is not None: + note_html = f""" + + {escape(note_label)} + {escape(str(note))} + + """ + action_html = "" + if section_url: + action_html = f""" +

+ + Open use-case section + +

+ """ + group_html = "" + if include_group: + group_html = f""" + + Permission group + {escape(group_path)} + + """ + return f""" + + + + + + +
+ + + + + + + + + + +
+
PathoCore / MEPRAM DataHub
+

{escape(title)}

+
+

{escape(intro)}

+ + + + + + + + + + {group_html} + {note_html} +
Use case + {escape(use_case_label)} +
Role{escape(role_label)}
+ {action_html} +
+ Technical platform: + BIPLAT-CIBERINFEC +
+
+ +""" + + +def _style(*rules): + return ";".join(rule.rstrip(";") for rule in rules if rule) + + def _get_use_case_config(use_case_name): normalized_name = _normalize_identifier(use_case_name) for use_case in getattr(settings, "PATHOCORE_ACCESS_REQUEST_USE_CASES", []): diff --git a/core/tests.py b/core/tests.py index fc570b9..5d88a85 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1563,8 +1563,14 @@ def test_public_user_can_create_pending_access_request(self): self.assertEqual(mail.outbox[0].to, ["new.user@example.org"]) self.assertEqual( mail.outbox[0].subject, - f"[PathoCore access #{response.data['id']}] MEPRAM (view) - new_user", - ) + f"[PathoCore access #{response.data['id']}] " + "MEPRAM View access - new_user", + ) + self.assertIn("- Use case: MEPRAM", mail.outbox[0].body) + self.assertIn("- Role: View", mail.outbox[0].body) + self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html") + self.assertIn("Access request received", mail.outbox[0].alternatives[0][0]) + self.assertNotIn("Permission group", mail.outbox[0].alternatives[0][0]) self.assertEqual( mail.outbox[0].extra_headers["Message-ID"], (f""), @@ -1666,7 +1672,12 @@ def test_access_request_notifies_keycloak_use_case_admins(self, emails_mock): mail.outbox[1].extra_headers["References"], mail.outbox[0].extra_headers["Message-ID"], ) - self.assertIn("https://github.com/BU-ISCIII", mail.outbox[1].body) + self.assertIn("- Permission group: /use-cases/mepram/view", mail.outbox[1].body) + self.assertIn( + "New access request pending review", + mail.outbox[1].alternatives[0][0], + ) + self.assertIn("Permission group", mail.outbox[1].alternatives[0][0]) @override_settings(PATHOCORE_ACCESS_REQUEST_ADMIN_EMAILS=["fallback@example.org"]) @patch("core.api.services.keycloak_admin.list_group_member_emails") @@ -1746,18 +1757,21 @@ def test_admin_can_approve_access_request(self, provision_mock): self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, - f"[PathoCore access #{access_request.pk}] MEPRAM (view) - new_user", + f"[PathoCore access #{access_request.pk}] " + "MEPRAM View access - new_user", ) self.assertEqual( mail.outbox[0].extra_headers["In-Reply-To"], (f""), ) - self.assertIn("MEPRAM (view)", mail.outbox[0].body) + self.assertIn("- Use case: MEPRAM", mail.outbox[0].body) + self.assertIn("- Role: View", mail.outbox[0].body) self.assertIn( "https://mepram-datahub.ciberisciii.es/use-cases/mepram", mail.outbox[0].body, ) self.assertIn("Technical platforms:", mail.outbox[0].body) + self.assertIn("Access request approved", mail.outbox[0].alternatives[0][0]) @patch("core.api.services.keycloak_admin.list_group_member_emails") def test_admin_can_reject_access_request(self, emails_mock): @@ -1781,7 +1795,8 @@ def test_admin_can_reject_access_request(self, emails_mock): self.assertEqual(mail.outbox[0].to, ["new.user@example.org"]) self.assertEqual( mail.outbox[0].subject, - f"[PathoCore access #{access_request.pk}] MEPRAM (view) - new_user", + f"[PathoCore access #{access_request.pk}] " + "MEPRAM View access - new_user", ) self.assertEqual( mail.outbox[0].extra_headers["In-Reply-To"], @@ -1790,6 +1805,7 @@ def test_admin_can_reject_access_request(self, emails_mock): self.assertIn("Reason: Missing project justification", mail.outbox[0].body) self.assertIn("contact: mepram.admin@example.org", mail.outbox[0].body) self.assertIn("Technical platforms:", mail.outbox[0].body) + self.assertIn("Access request rejected", mail.outbox[0].alternatives[0][0]) @patch("core.api.services.keycloak_admin.revoke_approved_user_access") @patch("core.api.services.keycloak_admin.list_group_member_emails") @@ -1822,7 +1838,8 @@ def test_admin_can_revoke_approved_access_request(self, emails_mock, revoke_mock self.assertEqual(mail.outbox[0].to, ["new.user@example.org"]) self.assertEqual( mail.outbox[0].subject, - f"[PathoCore access #{access_request.pk}] MEPRAM (view) - new_user", + f"[PathoCore access #{access_request.pk}] " + "MEPRAM View access - new_user", ) self.assertEqual( mail.outbox[0].extra_headers["In-Reply-To"], @@ -1831,6 +1848,7 @@ def test_admin_can_revoke_approved_access_request(self, emails_mock, revoke_mock self.assertIn("Reason: Access no longer required", mail.outbox[0].body) self.assertIn("contact: mepram.admin@example.org", mail.outbox[0].body) self.assertIn("Technical platforms:", mail.outbox[0].body) + self.assertIn("Access revoked", mail.outbox[0].alternatives[0][0]) @staticmethod def _request_payload(): From a209117b191900bd1136d6d5b2ebab3689ccee19 Mon Sep 17 00:00:00 2001 From: Daniel-VM Date: Mon, 31 Aug 2026 11:31:02 +0200 Subject: [PATCH 4/6] Hide access links until request approval --- CHANGELOG.md | 1 + core/api/services/access_requests.py | 9 ++++++++- core/tests.py | 4 ++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e696106..668418b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#28](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/28) Keep API documentation public - [#30](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/30) Thread access request notification emails - Send access request notifications as multipart plain-text and HTML emails. +- [#32](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/32) Show access request use-case links only after approval. ### `Added` diff --git a/core/api/services/access_requests.py b/core/api/services/access_requests.py index 34035cd..88c2888 100644 --- a/core/api/services/access_requests.py +++ b/core/api/services/access_requests.py @@ -344,6 +344,7 @@ def notify_access_request_created(access_request): ), recipient_list=[access_request.email], message_key="received", + show_action=False, ) recipients = _access_request_admin_recipients(access_request) @@ -368,6 +369,7 @@ def notify_access_request_created(access_request): recipient_list=recipients, message_key="admin-pending", reply_to_thread=True, + show_action=True, ) @@ -443,6 +445,7 @@ def notify_access_request_reviewed(access_request): recipient_list=[access_request.email], message_key=access_request.status, reply_to_thread=True, + show_action=access_request.status == core.models.AccessRequest.STATUS_APPROVED, ) @@ -462,6 +465,7 @@ def notify_access_request_revoked(access_request): recipient_list=[access_request.email], message_key="revoked", reply_to_thread=True, + show_action=False, ) @@ -477,6 +481,7 @@ def _send_access_request_email( note=None, include_group=False, reply_to_thread=False, + show_action=False, ): email = EmailMultiAlternatives( subject=_access_request_email_subject(access_request), @@ -497,6 +502,7 @@ def _send_access_request_email( note_label=note_label, note=note, include_group=include_group, + show_action=show_action, ), "text/html", ) @@ -614,6 +620,7 @@ def _access_request_email_html( note_label=None, note=None, include_group=False, + show_action=False, ): use_case_label = _use_case_label(access_request.requested_use_case) role_label = _role_label(access_request.requested_role) @@ -696,7 +703,7 @@ def _access_request_email_html( """ action_html = "" - if section_url: + if show_action and section_url: action_html = f"""

diff --git a/core/tests.py b/core/tests.py index 5d88a85..d54bcbd 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1570,6 +1570,7 @@ def test_public_user_can_create_pending_access_request(self): self.assertIn("- Role: View", mail.outbox[0].body) self.assertEqual(mail.outbox[0].alternatives[0][1], "text/html") self.assertIn("Access request received", mail.outbox[0].alternatives[0][0]) + self.assertNotIn("Open use-case section", mail.outbox[0].alternatives[0][0]) self.assertNotIn("Permission group", mail.outbox[0].alternatives[0][0]) self.assertEqual( mail.outbox[0].extra_headers["Message-ID"], @@ -1677,6 +1678,7 @@ def test_access_request_notifies_keycloak_use_case_admins(self, emails_mock): "New access request pending review", mail.outbox[1].alternatives[0][0], ) + self.assertIn("Open use-case section", mail.outbox[1].alternatives[0][0]) self.assertIn("Permission group", mail.outbox[1].alternatives[0][0]) @override_settings(PATHOCORE_ACCESS_REQUEST_ADMIN_EMAILS=["fallback@example.org"]) @@ -1772,6 +1774,7 @@ def test_admin_can_approve_access_request(self, provision_mock): ) self.assertIn("Technical platforms:", mail.outbox[0].body) self.assertIn("Access request approved", mail.outbox[0].alternatives[0][0]) + self.assertIn("Open use-case section", mail.outbox[0].alternatives[0][0]) @patch("core.api.services.keycloak_admin.list_group_member_emails") def test_admin_can_reject_access_request(self, emails_mock): @@ -1806,6 +1809,7 @@ def test_admin_can_reject_access_request(self, emails_mock): self.assertIn("contact: mepram.admin@example.org", mail.outbox[0].body) self.assertIn("Technical platforms:", mail.outbox[0].body) self.assertIn("Access request rejected", mail.outbox[0].alternatives[0][0]) + self.assertNotIn("Open use-case section", mail.outbox[0].alternatives[0][0]) @patch("core.api.services.keycloak_admin.revoke_approved_user_access") @patch("core.api.services.keycloak_admin.list_group_member_emails") From 9d76c9f0bfb989772e07fc9a053afb2fb0677133 Mon Sep 17 00:00:00 2001 From: Daniel VM <69470278+Daniel-VM@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:41:09 +0200 Subject: [PATCH 5/6] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 668418b..3a4ba49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [#27](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/27) Move API documentation under `/v1` - [#28](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/28) Keep API documentation public - [#30](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/30) Thread access request notification emails -- Send access request notifications as multipart plain-text and HTML emails. +- [#32](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/32) Send access request notifications as multipart plain-text and HTML emails. - [#32](https://github.com/BIPLAT-CIBERINFEC/pathocore-api/pull/32) Show access request use-case links only after approval. ### `Added` From fc8d260d12f47c1f30e71cb0a20b1eacd8b99dab Mon Sep 17 00:00:00 2001 From: Daniel-VM Date: Mon, 31 Aug 2026 11:38:39 +0200 Subject: [PATCH 6/6] fix linting --- core/tests.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/core/tests.py b/core/tests.py index d54bcbd..0321511 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1759,8 +1759,7 @@ def test_admin_can_approve_access_request(self, provision_mock): self.assertEqual(len(mail.outbox), 1) self.assertEqual( mail.outbox[0].subject, - f"[PathoCore access #{access_request.pk}] " - "MEPRAM View access - new_user", + f"[PathoCore access #{access_request.pk}] " "MEPRAM View access - new_user", ) self.assertEqual( mail.outbox[0].extra_headers["In-Reply-To"], @@ -1798,8 +1797,7 @@ def test_admin_can_reject_access_request(self, emails_mock): self.assertEqual(mail.outbox[0].to, ["new.user@example.org"]) self.assertEqual( mail.outbox[0].subject, - f"[PathoCore access #{access_request.pk}] " - "MEPRAM View access - new_user", + f"[PathoCore access #{access_request.pk}] " "MEPRAM View access - new_user", ) self.assertEqual( mail.outbox[0].extra_headers["In-Reply-To"], @@ -1842,8 +1840,7 @@ def test_admin_can_revoke_approved_access_request(self, emails_mock, revoke_mock self.assertEqual(mail.outbox[0].to, ["new.user@example.org"]) self.assertEqual( mail.outbox[0].subject, - f"[PathoCore access #{access_request.pk}] " - "MEPRAM View access - new_user", + f"[PathoCore access #{access_request.pk}] " "MEPRAM View access - new_user", ) self.assertEqual( mail.outbox[0].extra_headers["In-Reply-To"],