From 1d0d8809296e0157ed9c36c8a4cbe27da6ffce8c Mon Sep 17 00:00:00 2001 From: kitos Date: Mon, 20 Jul 2026 10:24:15 +0200 Subject: [PATCH] feat(email): generic webhook for all notification emails, hide SMTP UI - Renamed the password-setup webhook (email_webhook.*, /system/email-webhook-config) and made it the single transport for all notification emails. - New webhook_email_service.py shared by password_setup_service and notification_service. - Wired test validated/rejected, campaign completed, and new-MITRE-technique notifications through the webhook (previously dead SMTP code, never triggered). - Added POST /system/email-webhook-test to send a real test email. - Hid the Email/SMTP settings tab from the UI (SMTP code kept intact, unused). - Redact email_webhook.api_key on config export. --- backend/app/routers/admin_config.py | 1 + backend/app/routers/system.py | 79 ++++++++---- backend/app/services/campaign_crud_service.py | 9 ++ backend/app/services/mitre_sync_service.py | 13 ++ backend/app/services/notification_service.py | 58 +++++++++ .../app/services/password_setup_service.py | 98 ++------------- backend/app/services/webhook_email_service.py | 112 +++++++++++++++++ .../tests/test_email_webhook_notifications.py | 115 ++++++++++++++++++ backend/tests/test_password_setup_flow.py | 24 ++-- frontend/src/api/settings.ts | 17 ++- frontend/src/pages/SettingsPage.tsx | 62 +++++++--- 11 files changed, 442 insertions(+), 146 deletions(-) create mode 100644 backend/app/services/webhook_email_service.py create mode 100644 backend/tests/test_email_webhook_notifications.py diff --git a/backend/app/routers/admin_config.py b/backend/app/routers/admin_config.py index 7166ac7..a623642 100644 --- a/backend/app/routers/admin_config.py +++ b/backend/app/routers/admin_config.py @@ -41,6 +41,7 @@ _REDACTED_KEYS = { "smtp.password", "jira.admin_api_token", "tempo.admin_token", + "email_webhook.api_key", # Older/alternate key names kept defensively in case a prior schema # version wrote under these instead. "jira.api_token", diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index d19ac8d..04ba9bd 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -344,60 +344,93 @@ def update_jira_config( ) -class PasswordWebhookConfigOut(BaseModel): +class EmailWebhookConfigOut(BaseModel): configured: bool url: str api_key_set: bool -class PasswordWebhookConfigUpdate(BaseModel): +class EmailWebhookConfigUpdate(BaseModel): url: str # Optional — omit or send "" to leave the currently-stored key unchanged. api_key: Optional[str] = None -@router.get("/password-webhook-config", response_model=PasswordWebhookConfigOut) -def get_password_webhook_config( +class EmailWebhookTestRequest(BaseModel): + to: str + + +@router.get("/email-webhook-config", response_model=EmailWebhookConfigOut) +def get_email_webhook_config( db: Session = Depends(get_db), current_user: User = Depends(require_role("admin")), ): - """Return the configured webhook URL used to email set-password links. + """Return the configured webhook used to send every platform notification + email (password setup/reset, test validated, campaign completed, etc.). The API key itself is never returned, only whether one is set. **Requires** the ``admin`` role. """ - from app.services.password_setup_service import get_password_webhook_api_key, get_password_webhook_url + from app.services.webhook_email_service import get_webhook_api_key, get_webhook_url - url = get_password_webhook_url(db) or "" - api_key_set = bool(get_password_webhook_api_key(db)) - return PasswordWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set) + url = get_webhook_url(db) or "" + api_key_set = bool(get_webhook_api_key(db)) + return EmailWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set) -@router.patch("/password-webhook-config", response_model=PasswordWebhookConfigOut) -def update_password_webhook_config( - payload: PasswordWebhookConfigUpdate, +@router.patch("/email-webhook-config", response_model=EmailWebhookConfigOut) +def update_email_webhook_config( + payload: EmailWebhookConfigUpdate, db: Session = Depends(get_db), current_user: User = Depends(require_role("admin")), ): - """Set the webhook URL (and optionally API key) used to email set-password links. + """Set the webhook URL (and optionally API key) used to send all + platform notification emails. **Requires** the ``admin`` role. """ - from app.services.password_setup_service import ( - get_password_webhook_api_key, - get_password_webhook_url, - set_password_webhook_api_key, - set_password_webhook_url, + from app.services.webhook_email_service import ( + get_webhook_api_key, + get_webhook_url, + set_webhook_api_key, + set_webhook_url, ) - set_password_webhook_url(db, payload.url) + set_webhook_url(db, payload.url) if payload.api_key: - set_password_webhook_api_key(db, payload.api_key) + set_webhook_api_key(db, payload.api_key) db.commit() - url = get_password_webhook_url(db) or "" - api_key_set = bool(get_password_webhook_api_key(db)) - return PasswordWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set) + url = get_webhook_url(db) or "" + api_key_set = bool(get_webhook_api_key(db)) + return EmailWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set) + + +@router.post("/email-webhook-test") +def test_email_webhook( + payload: EmailWebhookTestRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_role("admin")), +) -> dict: + """Send a real test notification through the configured email webhook. + + **Requires** the ``admin`` role. + """ + from app.services.webhook_email_service import send_webhook_email + + sent = send_webhook_email( + db, + to=payload.to, + subject="Aegis Email Webhook Test", + message=( + "This is a test notification confirming the email webhook is " + "configured and working correctly." + ), + full_name=current_user.full_name, + ) + if not sent: + raise HTTPException(status_code=502, detail="Failed to send — check the webhook URL/API key and logs.") + return {"detail": f"Test email sent to {payload.to}"} @router.post("/jira-test") diff --git a/backend/app/services/campaign_crud_service.py b/backend/app/services/campaign_crud_service.py index 1628150..6ddee82 100644 --- a/backend/app/services/campaign_crud_service.py +++ b/backend/app/services/campaign_crud_service.py @@ -755,6 +755,15 @@ def complete_campaign(db: Session, campaign_id: str) -> Campaign: campaign.completed_at = datetime.utcnow() # Flush changes to DB without committing the transaction db.flush() + + from app.services.notification_service import notify_user_by_email + notify_user_by_email( + db, campaign.created_by, + preference_key="email_on_campaign_completed", + subject=f"Campaign Completed: {campaign.name}", + message=f'Your campaign "{campaign.name}" has been completed.', + ) + # Return campaign return campaign diff --git a/backend/app/services/mitre_sync_service.py b/backend/app/services/mitre_sync_service.py index e78cf0e..7bf777d 100644 --- a/backend/app/services/mitre_sync_service.py +++ b/backend/app/services/mitre_sync_service.py @@ -391,5 +391,18 @@ def sync_mitre(db: Session) -> dict: # Commit all pending changes to the database db.commit() + if created > 0: + from app.services.notification_service import notify_all_users_by_email + + notify_all_users_by_email( + db, + preference_key="email_on_new_mitre_techniques", + subject=f"MITRE ATT&CK Updated: {created} new techniques", + message=( + f"{created} new techniques were added and {updated} were " + "updated in the MITRE ATT&CK catalog." + ), + ) + # Return summary return summary diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 0561f25..c637bbd 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -245,6 +245,52 @@ def cleanup_old_notifications(db: Session, days: int = 90) -> int: # --------------------------------------------------------------------------- +def _preference_allows(user: User | None, preference_key: str) -> bool: + """True unless the user has explicitly turned *preference_key* off. + + ``notification_preferences`` may be ``None`` (older rows predating the + column) or missing individual keys (newer preference toggles added + after the DB default was set) — both cases default to "on". + """ + if user is None or not user.email: + return False + prefs = user.notification_preferences or {} + return prefs.get(preference_key, True) is not False + + +def notify_user_by_email(db: Session, user_id, *, preference_key: str, subject: str, message: str) -> None: + """Email a single user (by id) if their preferences allow it. + + Best-effort — swallows lookup/send failures so a notification email + never breaks whatever workflow step triggered it. + """ + if not user_id: + return + try: + user = db.query(User).filter(User.id == user_id).first() + if not _preference_allows(user, preference_key): + return + from app.services.webhook_email_service import send_webhook_email + send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name) + except Exception: + pass # nosec B110 — email failures never crash the caller + + +def notify_all_users_by_email(db: Session, *, preference_key: str, subject: str, message: str) -> None: + """Email every active, opted-in user — for platform-wide events + (e.g. a MITRE ATT&CK sync) rather than a single actor's own action. + """ + users = db.query(User).filter(User.is_active == True, User.email.isnot(None)).all() # noqa: E712 + for user in users: + if not _preference_allows(user, preference_key): + continue + try: + from app.services.webhook_email_service import send_webhook_email + send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name) + except Exception: + pass # nosec B110 — one user's failure must not skip the rest + + def notify_role_with_email( db: Session, *, @@ -387,6 +433,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None: # Keyword argument: entity_id entity_id=test_id, ) + notify_user_by_email( + db, creator_id, + preference_key="email_on_test_rejected", + subject=f"Test Rejected: {test_name}", + message=f'Your test "{test_name}" has been rejected. Please review and resubmit.', + ) # Alternative: new_state == "validated" and creator_id elif new_state == "validated" and creator_id: @@ -406,3 +458,9 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None: # Keyword argument: entity_id entity_id=test_id, ) + notify_user_by_email( + db, creator_id, + preference_key="email_on_test_validated", + subject=f"Test Validated: {test_name}", + message=f'Your test "{test_name}" has been validated successfully.', + ) diff --git a/backend/app/services/password_setup_service.py b/backend/app/services/password_setup_service.py index 4b98ed3..0721f11 100644 --- a/backend/app/services/password_setup_service.py +++ b/backend/app/services/password_setup_service.py @@ -2,88 +2,27 @@ An admin creates a user with just a name/email/role (see ``user_service.create_user_without_password``). To let that user in, the -admin clicks "Send Email": this service issues a one-time token and POSTs -a payload carrying it to an admin-configured webhook URL (intended for a -Power Automate flow that actually sends the email — the exact webhook -contract is still being finalized, hence the configurable URL rather than -a hardcoded integration). The same mechanism, and the same button, is +admin clicks "Send Email": this service issues a one-time token and sends +it via the shared ``webhook_email_service`` (POSTs to an admin-configured +Power Automate webhook). The same mechanism, and the same button, is reused for password resets on existing users. """ from __future__ import annotations -import logging import secrets from datetime import datetime, timedelta -import requests from sqlalchemy.orm import Session from app.config import settings from app.domain.errors import BusinessRuleViolation, EntityNotFoundError from app.models.password_setup_token import PasswordSetupToken from app.models.user import User +from app.services.webhook_email_service import get_webhook_url, send_webhook_email -logger = logging.getLogger(__name__) - -_WEBHOOK_URL_CONFIG_KEY = "password_setup.webhook_url" -_WEBHOOK_API_KEY_CONFIG_KEY = "password_setup.webhook_api_key" _TOKEN_TTL = timedelta(hours=24) -# Standard footer appended to every Power-Automate-delivered notification -# email, matching the platform's established template. -_EMAIL_SIGNATURE = ( - "\n\nRegards,\n" - "AEGIS Security Platform\n" - "Purple Team Engineering\n" - "Owned and operated by Enterprise Corp.\n\n" - "Assume breach. Validate controls. Improve continuously.\n\n" - "This is an automated notification. Please do not reply." -) - - -def _build_email_body(full_name: str | None, message: str) -> str: - """Wrap *message* in the standard greeting + signature template.""" - greeting = full_name or "there" - return f"HI {greeting}\n\n{message}{_EMAIL_SIGNATURE}" - - -def _read_system_config(db: Session, key: str) -> str | None: - from app.models.system_config import SystemConfig # avoid circular at import time - - row = db.query(SystemConfig).filter(SystemConfig.key == key).first() - return row.value if row else None - - -def _write_system_config(db: Session, key: str, value: str) -> None: - from app.models.system_config import SystemConfig - - row = db.query(SystemConfig).filter(SystemConfig.key == key).first() - if row: - row.value = value - else: - db.add(SystemConfig(key=key, value=value)) - - -def get_password_webhook_url(db: Session) -> str | None: - """Return the configured Power-Automate-style webhook URL, or None.""" - return _read_system_config(db, _WEBHOOK_URL_CONFIG_KEY) or None - - -def set_password_webhook_url(db: Session, url: str) -> None: - """Persist the webhook URL. Does not commit; caller commits.""" - _write_system_config(db, _WEBHOOK_URL_CONFIG_KEY, url) - - -def get_password_webhook_api_key(db: Session) -> str | None: - """Return the configured webhook API key, or None.""" - return _read_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY) or None - - -def set_password_webhook_api_key(db: Session, api_key: str) -> None: - """Persist the webhook API key. Does not commit; caller commits.""" - _write_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY, api_key) - def create_setup_token(db: Session, user: User) -> PasswordSetupToken: """Issue a fresh one-time token for *user*, invalidating any unused ones. @@ -106,16 +45,15 @@ def create_setup_token(db: Session, user: User) -> PasswordSetupToken: def send_password_setup_email(db: Session, user: User) -> None: - """Issue a token and POST it to the configured webhook. + """Issue a token and email it via the configured webhook. - Raises BusinessRuleViolation if no webhook URL is configured yet. + Raises BusinessRuleViolation if no webhook is configured yet. Does not commit; caller commits (the token must be persisted even if the webhook call itself fails, so a fresh "Send Email" retry works). """ - webhook_url = get_password_webhook_url(db) - if not webhook_url: + if not get_webhook_url(db): raise BusinessRuleViolation( - "No password-setup webhook is configured yet — set one in Settings first." + "No email webhook is configured yet — set one in Settings first." ) token = create_setup_token(db, user) @@ -127,25 +65,7 @@ def send_password_setup_email(db: Session, user: User) -> None: f"{set_password_url}\n\n" "This link expires in 24 hours and can only be used once." ) - - api_key = get_password_webhook_api_key(db) - headers = {"x-api-key": api_key} if api_key else {} - - try: - requests.post( - webhook_url, - json={ - "to": user.email, - "subject": subject, - "body": _build_email_body(user.full_name, message), - }, - headers=headers, - timeout=10, - ) - except requests.RequestException: - # Best-effort: the token still exists, so the admin can retry - # "Send Email" once the webhook endpoint is reachable again. - logger.warning("Password-setup webhook call failed for user %s", user.id, exc_info=True) + send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name) def _get_valid_token_or_raise(db: Session, token: str) -> PasswordSetupToken: diff --git a/backend/app/services/webhook_email_service.py b/backend/app/services/webhook_email_service.py new file mode 100644 index 0000000..81d1086 --- /dev/null +++ b/backend/app/services/webhook_email_service.py @@ -0,0 +1,112 @@ +"""Generic outbound-email sender — POSTs to an admin-configured webhook. + +Replaces the old SMTP-based email system. Every notification email in the +platform (password setup/reset, test validated, campaign completed, new +MITRE techniques synced, etc.) goes through ``send_webhook_email`` here, +which POSTs a ``{to, subject, body}`` JSON payload to a single configured +webhook URL — intended for a Power Automate flow that actually delivers +the email. An optional API key, if configured, is sent as an +``x-api-key`` header. + +SMTP (``app.services.email_service``) is intentionally left in place but +unused and unreachable from the UI — see that module's docstring. +""" + +from __future__ import annotations + +import logging + +import requests +from sqlalchemy.orm import Session + +logger = logging.getLogger(__name__) + +_WEBHOOK_URL_CONFIG_KEY = "email_webhook.url" +_WEBHOOK_API_KEY_CONFIG_KEY = "email_webhook.api_key" + +# Standard greeting + footer wrapped around every notification email's +# actual message, matching the platform's established template. +_EMAIL_SIGNATURE = ( + "\n\nRegards,\n" + "AEGIS Security Platform\n" + "Purple Team Engineering\n" + "Owned and operated by Enterprise Corp.\n\n" + "Assume breach. Validate controls. Improve continuously.\n\n" + "This is an automated notification. Please do not reply." +) + + +def build_email_body(full_name: str | None, message: str) -> str: + """Wrap *message* in the standard greeting + signature template.""" + greeting = full_name or "there" + return f"HI {greeting}\n\n{message}{_EMAIL_SIGNATURE}" + + +def _read_system_config(db: Session, key: str) -> str | None: + from app.models.system_config import SystemConfig # avoid circular at import time + + row = db.query(SystemConfig).filter(SystemConfig.key == key).first() + return row.value if row else None + + +def _write_system_config(db: Session, key: str, value: str) -> None: + from app.models.system_config import SystemConfig + + row = db.query(SystemConfig).filter(SystemConfig.key == key).first() + if row: + row.value = value + else: + db.add(SystemConfig(key=key, value=value)) + + +def get_webhook_url(db: Session) -> str | None: + """Return the configured Power-Automate-style webhook URL, or None.""" + return _read_system_config(db, _WEBHOOK_URL_CONFIG_KEY) or None + + +def set_webhook_url(db: Session, url: str) -> None: + """Persist the webhook URL. Does not commit; caller commits.""" + _write_system_config(db, _WEBHOOK_URL_CONFIG_KEY, url) + + +def get_webhook_api_key(db: Session) -> str | None: + """Return the configured webhook API key, or None.""" + return _read_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY) or None + + +def set_webhook_api_key(db: Session, api_key: str) -> None: + """Persist the webhook API key. Does not commit; caller commits.""" + _write_system_config(db, _WEBHOOK_API_KEY_CONFIG_KEY, api_key) + + +def send_webhook_email( + db: Session, *, to: str | None, subject: str, message: str, full_name: str | None = None, +) -> bool: + """POST a notification email to the configured webhook. + + Best-effort: returns False (and logs a warning) if no webhook is + configured, *to* is empty, or the request fails — never raises, since + a webhook outage must not break whatever triggered the notification. + """ + if not to: + return False + + webhook_url = get_webhook_url(db) + if not webhook_url: + logger.warning("Email webhook is not configured — dropping email to %s", to) + return False + + api_key = get_webhook_api_key(db) + headers = {"x-api-key": api_key} if api_key else {} + + try: + requests.post( + webhook_url, + json={"to": to, "subject": subject, "body": build_email_body(full_name, message)}, + headers=headers, + timeout=10, + ) + return True + except requests.RequestException: + logger.warning("Email webhook call failed for %s", to, exc_info=True) + return False diff --git a/backend/tests/test_email_webhook_notifications.py b/backend/tests/test_email_webhook_notifications.py new file mode 100644 index 0000000..cf09169 --- /dev/null +++ b/backend/tests/test_email_webhook_notifications.py @@ -0,0 +1,115 @@ +"""Coverage for the generic email webhook: the admin test endpoint, and +the new notification-dispatch helpers that route test/campaign/MITRE +events through it (see webhook_email_service.py, notification_service.py). +""" + +import uuid +from unittest.mock import patch + +import pytest + +from app.services.notification_service import ( + _preference_allows, + notify_all_users_by_email, + notify_user_by_email, +) + + +def test_email_webhook_test_endpoint_requires_admin(api, red_lead_headers): + resp = api( + "post", "/api/v1/system/email-webhook-test", red_lead_headers, + json={"to": "someone@test.com"}, + ) + assert resp.status_code == 403 + + +def test_email_webhook_test_endpoint_fails_without_config(api, auth_headers): + resp = api( + "post", "/api/v1/system/email-webhook-test", auth_headers, + json={"to": "someone@test.com"}, + ) + assert resp.status_code == 502 + + +def test_email_webhook_test_endpoint_sends_via_configured_webhook(api, auth_headers): + api( + "patch", "/api/v1/system/email-webhook-config", auth_headers, + json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"}, + ) + with patch("app.services.webhook_email_service.requests.post") as mock_post: + resp = api( + "post", "/api/v1/system/email-webhook-test", auth_headers, + json={"to": "marcos.luna@kaseya.com"}, + ) + assert resp.status_code == 200, resp.text + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert call_kwargs.args[0] == "https://example.com/power-automate-hook" + assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"} + payload = call_kwargs.kwargs["json"] + assert payload["to"] == "marcos.luna@kaseya.com" + assert "Purple Team Engineering" in payload["body"] + + +class _FakeUser: + def __init__(self, email="user@test.com", full_name="A User", prefs=None): + self.email = email + self.full_name = full_name + self.notification_preferences = prefs + + +def test_preference_allows_defaults_to_true_when_missing(): + assert _preference_allows(_FakeUser(prefs=None), "email_on_test_validated") is True + assert _preference_allows(_FakeUser(prefs={}), "email_on_test_validated") is True + + +def test_preference_allows_respects_explicit_false(): + user = _FakeUser(prefs={"email_on_test_validated": False}) + assert _preference_allows(user, "email_on_test_validated") is False + + +def test_preference_allows_false_without_email(): + assert _preference_allows(_FakeUser(email=None), "email_on_test_validated") is False + assert _preference_allows(None, "email_on_test_validated") is False + + +def test_notify_user_by_email_sends_when_allowed(db, auth_headers, api): + resp = api( + "post", "/api/v1/users", auth_headers, + json={"full_name": "Notify Me", "email": "notifyme@test.com", "role": "viewer"}, + ) + assert resp.status_code == 201, resp.text + user_id = resp.json()["id"] + + with patch("app.services.webhook_email_service.send_webhook_email") as mock_send: + notify_user_by_email( + db, uuid.UUID(user_id), + preference_key="email_on_test_validated", + subject="Test Validated: Some Test", + message='Your test "Some Test" has been validated successfully.', + ) + mock_send.assert_called_once() + assert mock_send.call_args.kwargs["to"] == "notifyme@test.com" + + +def test_notify_all_users_by_email_skips_opted_out(db): + allowed = _FakeUser(email="allowed@test.com", prefs={"email_on_new_mitre_techniques": True}) + blocked = _FakeUser(email="blocked@test.com", prefs={"email_on_new_mitre_techniques": False}) + + class _FakeQuery: + def filter(self, *args, **kwargs): + return self + + def all(self): + return [allowed, blocked] + + with patch.object(db, "query", return_value=_FakeQuery()), \ + patch("app.services.webhook_email_service.send_webhook_email") as mock_send: + notify_all_users_by_email( + db, + preference_key="email_on_new_mitre_techniques", + subject="MITRE ATT&CK Updated: 3 new techniques", + message="3 new techniques were added and 1 were updated.", + ) + mock_send.assert_called_once() + assert mock_send.call_args.kwargs["to"] == "allowed@test.com" diff --git a/backend/tests/test_password_setup_flow.py b/backend/tests/test_password_setup_flow.py index c9bfc58..cae4b7b 100644 --- a/backend/tests/test_password_setup_flow.py +++ b/backend/tests/test_password_setup_flow.py @@ -26,19 +26,19 @@ def test_send_password_email_requires_webhook_configured(api, auth_headers, new_ def test_admin_can_configure_password_webhook(api, auth_headers): resp = api( - "patch", "/api/v1/system/password-webhook-config", auth_headers, + "patch", "/api/v1/system/email-webhook-config", auth_headers, json={"url": "https://example.com/power-automate-hook"}, ) assert resp.status_code == 200, resp.text assert resp.json()["configured"] is True - get_resp = api("get", "/api/v1/system/password-webhook-config", auth_headers) + get_resp = api("get", "/api/v1/system/email-webhook-config", auth_headers) assert get_resp.json()["url"] == "https://example.com/power-automate-hook" def test_admin_can_configure_webhook_api_key(api, auth_headers): resp = api( - "patch", "/api/v1/system/password-webhook-config", auth_headers, + "patch", "/api/v1/system/email-webhook-config", auth_headers, json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"}, ) assert resp.status_code == 200, resp.text @@ -50,10 +50,10 @@ def test_admin_can_configure_webhook_api_key(api, auth_headers): def test_send_password_email_sends_api_key_header(api, db, auth_headers, new_user_id): api( - "patch", "/api/v1/system/password-webhook-config", auth_headers, + "patch", "/api/v1/system/email-webhook-config", auth_headers, json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"}, ) - with patch("app.services.password_setup_service.requests.post") as mock_post: + with patch("app.services.webhook_email_service.requests.post") as mock_post: api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers) call_kwargs = mock_post.call_args assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"} @@ -61,11 +61,11 @@ def test_send_password_email_sends_api_key_header(api, db, auth_headers, new_use def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_headers, new_user_id): api( - "patch", "/api/v1/system/password-webhook-config", auth_headers, + "patch", "/api/v1/system/email-webhook-config", auth_headers, json={"url": "https://example.com/power-automate-hook"}, ) - with patch("app.services.password_setup_service.requests.post") as mock_post: + with patch("app.services.webhook_email_service.requests.post") as mock_post: resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers) assert resp.status_code == 200, resp.text mock_post.assert_called_once() @@ -86,10 +86,10 @@ def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_hea def test_set_password_with_valid_token_succeeds(api, db, auth_headers, new_user_id): api( - "patch", "/api/v1/system/password-webhook-config", auth_headers, + "patch", "/api/v1/system/email-webhook-config", auth_headers, json={"url": "https://example.com/power-automate-hook"}, ) - with patch("app.services.password_setup_service.requests.post"): + with patch("app.services.webhook_email_service.requests.post"): api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers) from app.models.password_setup_token import PasswordSetupToken @@ -115,10 +115,10 @@ def test_set_password_with_valid_token_succeeds(api, db, auth_headers, new_user_ def test_set_password_token_cannot_be_reused(api, db, auth_headers, new_user_id): api( - "patch", "/api/v1/system/password-webhook-config", auth_headers, + "patch", "/api/v1/system/email-webhook-config", auth_headers, json={"url": "https://example.com/power-automate-hook"}, ) - with patch("app.services.password_setup_service.requests.post"): + with patch("app.services.webhook_email_service.requests.post"): api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers) from app.models.password_setup_token import PasswordSetupToken @@ -150,7 +150,7 @@ def test_set_password_invalid_token_rejected(api): def test_password_webhook_config_requires_admin(api, red_lead_headers): - resp = api("get", "/api/v1/system/password-webhook-config", red_lead_headers) + resp = api("get", "/api/v1/system/email-webhook-config", red_lead_headers) assert resp.status_code == 403 diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index 98bbcc7..a59dea4 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -199,29 +199,34 @@ export async function testJiraConnection(): Promise<{ return data; } -export interface PasswordWebhookConfigOut { +export interface EmailWebhookConfigOut { configured: boolean; url: string; api_key_set: boolean; } -export async function getPasswordWebhookConfig(): Promise { - const { data } = await client.get("/system/password-webhook-config"); +export async function getEmailWebhookConfig(): Promise { + const { data } = await client.get("/system/email-webhook-config"); return data; } /** apiKey is optional — omit or pass "" to leave the currently-stored key unchanged. */ -export async function updatePasswordWebhookConfig( +export async function updateEmailWebhookConfig( url: string, apiKey?: string, -): Promise { - const { data } = await client.patch("/system/password-webhook-config", { +): Promise { + const { data } = await client.patch("/system/email-webhook-config", { url, api_key: apiKey || undefined, }); return data; } +export async function testEmailWebhook(to: string): Promise<{ detail: string }> { + const { data } = await client.post<{ detail: string }>("/system/email-webhook-test", { to }); + return data; +} + export async function testTempoConnection(): Promise<{ status: "ok" | "error" | "disabled"; message?: string; diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx index a4a950b..aa63d26 100644 --- a/frontend/src/pages/SettingsPage.tsx +++ b/frontend/src/pages/SettingsPage.tsx @@ -53,8 +53,9 @@ import { getJiraConfig, updateJiraConfig, testJiraConnection, - getPasswordWebhookConfig, - updatePasswordWebhookConfig, + getEmailWebhookConfig, + updateEmailWebhookConfig, + testEmailWebhook, testTempoConnection, type EmailConfigUpdate, type WebhookCreate, @@ -1685,15 +1686,16 @@ function SystemInfoSection() { ); } -function PasswordWebhookSection() { +function EmailWebhookSection() { const qc = useQueryClient(); const [url, setUrl] = useState(""); const [apiKey, setApiKey] = useState(""); + const [testTo, setTestTo] = useState(""); const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); const { data, isLoading } = useQuery({ - queryKey: ["password-webhook-config"], - queryFn: getPasswordWebhookConfig, + queryKey: ["email-webhook-config"], + queryFn: getEmailWebhookConfig, }); useEffect(() => { @@ -1701,15 +1703,22 @@ function PasswordWebhookSection() { }, [data]); const saveMutation = useMutation({ - mutationFn: () => updatePasswordWebhookConfig(url, apiKey), + mutationFn: () => updateEmailWebhookConfig(url, apiKey), onSuccess: () => { - qc.invalidateQueries({ queryKey: ["password-webhook-config"] }); + qc.invalidateQueries({ queryKey: ["email-webhook-config"] }); setApiKey(""); setToast({ msg: "Webhook configuration saved", type: "success" }); }, onError: () => setToast({ msg: "Failed to save webhook configuration", type: "error" }), }); + const testMutation = useMutation({ + mutationFn: () => testEmailWebhook(testTo), + onSuccess: () => setToast({ msg: `Test email sent to ${testTo}`, type: "success" }), + onError: () => + setToast({ msg: "Failed to send test email. Check the webhook URL/API key and logs.", type: "error" }), + }); + if (isLoading) { return (
@@ -1721,14 +1730,14 @@ function PasswordWebhookSection() { return (

- Password Setup Email Webhook + Email Webhook

- When an admin sends a set-password or password-reset link from User Management, a{" "} - {"{ to, subject, body }"} payload is POSTed to - this URL — point it at your Power Automate flow (or similar) that actually delivers the - email. The API key, if set, is sent as an x-api-key{" "} - header. + All platform notification emails — password setup/reset, test validated/rejected, + campaign completed, new MITRE techniques, and more — are sent by POSTing a{" "} + {"{ to, subject, body }"} payload to this URL — + point it at your Power Automate flow (or similar) that actually delivers the email. The + API key, if set, is sent as an x-api-key header.

@@ -1766,9 +1775,30 @@ function PasswordWebhookSection() {
{!data?.configured && (

- Not configured yet — "Send Email" in User Management will fail until a URL is saved here. + Not configured yet — notification emails will silently fail to send until a URL is + saved here.

)} +
+

Send Test Email

+
+ setTestTo(e.target.value)} + placeholder="you@company.com" + className="flex-1 rounded-lg border border-gray-700 bg-gray-800 px-3 py-2 text-sm text-gray-300 placeholder-gray-500 focus:border-cyan-500 focus:outline-none" + /> + +
+
{toast && ( setToast(null)} /> )} @@ -1799,7 +1829,7 @@ export default function SettingsPage() { icon: Webhook, show: isAdmin, }, - { id: "email", label: "Email / SMTP", icon: Mail, show: isAdmin }, + { id: "email", label: "Email / SMTP", icon: Mail, show: false }, { id: "jira", label: "Jira", icon: Link2, show: isAdmin }, { id: "sso", label: "SSO / Azure AD", icon: KeyRound, show: isAdmin }, { id: "system", label: "System", icon: Server, show: isAdmin }, @@ -1873,7 +1903,7 @@ export default function SettingsPage() { {activeTab === "system" && isAdmin && (
- +
)}