feat(email): generic webhook for all notification emails, hide SMTP UI
Aegis CI / lint-and-test (push) Has been cancelled
Snyk Security Scan / Python vulnerabilities (backend) (push) Has been cancelled
Snyk Security Scan / npm vulnerabilities (frontend) (push) Has been cancelled
Snyk Security Scan / Docker image vulnerabilities (backend) (push) Has been cancelled

- 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.
This commit is contained in:
kitos
2026-07-20 10:24:15 +02:00
parent bbe7f49c86
commit 1d0d880929
11 changed files with 442 additions and 146 deletions
+1
View File
@@ -41,6 +41,7 @@ _REDACTED_KEYS = {
"smtp.password", "smtp.password",
"jira.admin_api_token", "jira.admin_api_token",
"tempo.admin_token", "tempo.admin_token",
"email_webhook.api_key",
# Older/alternate key names kept defensively in case a prior schema # Older/alternate key names kept defensively in case a prior schema
# version wrote under these instead. # version wrote under these instead.
"jira.api_token", "jira.api_token",
+56 -23
View File
@@ -344,60 +344,93 @@ def update_jira_config(
) )
class PasswordWebhookConfigOut(BaseModel): class EmailWebhookConfigOut(BaseModel):
configured: bool configured: bool
url: str url: str
api_key_set: bool api_key_set: bool
class PasswordWebhookConfigUpdate(BaseModel): class EmailWebhookConfigUpdate(BaseModel):
url: str url: str
# Optional — omit or send "" to leave the currently-stored key unchanged. # Optional — omit or send "" to leave the currently-stored key unchanged.
api_key: Optional[str] = None api_key: Optional[str] = None
@router.get("/password-webhook-config", response_model=PasswordWebhookConfigOut) class EmailWebhookTestRequest(BaseModel):
def get_password_webhook_config( to: str
@router.get("/email-webhook-config", response_model=EmailWebhookConfigOut)
def get_email_webhook_config(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), 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. The API key itself is never returned, only whether one is set.
**Requires** the ``admin`` role. **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 "" url = get_webhook_url(db) or ""
api_key_set = bool(get_password_webhook_api_key(db)) api_key_set = bool(get_webhook_api_key(db))
return PasswordWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set) return EmailWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set)
@router.patch("/password-webhook-config", response_model=PasswordWebhookConfigOut) @router.patch("/email-webhook-config", response_model=EmailWebhookConfigOut)
def update_password_webhook_config( def update_email_webhook_config(
payload: PasswordWebhookConfigUpdate, payload: EmailWebhookConfigUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_role("admin")), 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. **Requires** the ``admin`` role.
""" """
from app.services.password_setup_service import ( from app.services.webhook_email_service import (
get_password_webhook_api_key, get_webhook_api_key,
get_password_webhook_url, get_webhook_url,
set_password_webhook_api_key, set_webhook_api_key,
set_password_webhook_url, set_webhook_url,
) )
set_password_webhook_url(db, payload.url) set_webhook_url(db, payload.url)
if payload.api_key: if payload.api_key:
set_password_webhook_api_key(db, payload.api_key) set_webhook_api_key(db, payload.api_key)
db.commit() db.commit()
url = get_password_webhook_url(db) or "" url = get_webhook_url(db) or ""
api_key_set = bool(get_password_webhook_api_key(db)) api_key_set = bool(get_webhook_api_key(db))
return PasswordWebhookConfigOut(configured=bool(url), url=url, api_key_set=api_key_set) 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") @router.post("/jira-test")
@@ -755,6 +755,15 @@ def complete_campaign(db: Session, campaign_id: str) -> Campaign:
campaign.completed_at = datetime.utcnow() campaign.completed_at = datetime.utcnow()
# Flush changes to DB without committing the transaction # Flush changes to DB without committing the transaction
db.flush() 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
return campaign return campaign
@@ -391,5 +391,18 @@ def sync_mitre(db: Session) -> dict:
# Commit all pending changes to the database # Commit all pending changes to the database
db.commit() 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
return summary return summary
@@ -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( def notify_role_with_email(
db: Session, db: Session,
*, *,
@@ -387,6 +433,12 @@ def notify_test_state_change(db: Session, test, new_state: str) -> None:
# Keyword argument: entity_id # Keyword argument: entity_id
entity_id=test_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 # Alternative: new_state == "validated" and creator_id
elif 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 # Keyword argument: entity_id
entity_id=test_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.',
)
+9 -89
View File
@@ -2,88 +2,27 @@
An admin creates a user with just a name/email/role (see An admin creates a user with just a name/email/role (see
``user_service.create_user_without_password``). To let that user in, the ``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 admin clicks "Send Email": this service issues a one-time token and sends
a payload carrying it to an admin-configured webhook URL (intended for a it via the shared ``webhook_email_service`` (POSTs to an admin-configured
Power Automate flow that actually sends the email — the exact webhook Power Automate webhook). The same mechanism, and the same button, is
contract is still being finalized, hence the configurable URL rather than
a hardcoded integration). The same mechanism, and the same button, is
reused for password resets on existing users. reused for password resets on existing users.
""" """
from __future__ import annotations from __future__ import annotations
import logging
import secrets import secrets
from datetime import datetime, timedelta from datetime import datetime, timedelta
import requests
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings from app.config import settings
from app.domain.errors import BusinessRuleViolation, EntityNotFoundError from app.domain.errors import BusinessRuleViolation, EntityNotFoundError
from app.models.password_setup_token import PasswordSetupToken from app.models.password_setup_token import PasswordSetupToken
from app.models.user import User 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) _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: def create_setup_token(db: Session, user: User) -> PasswordSetupToken:
"""Issue a fresh one-time token for *user*, invalidating any unused ones. """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: 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 Does not commit; caller commits (the token must be persisted even if
the webhook call itself fails, so a fresh "Send Email" retry works). the webhook call itself fails, so a fresh "Send Email" retry works).
""" """
webhook_url = get_password_webhook_url(db) if not get_webhook_url(db):
if not webhook_url:
raise BusinessRuleViolation( 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) 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" f"{set_password_url}\n\n"
"This link expires in 24 hours and can only be used once." "This link expires in 24 hours and can only be used once."
) )
send_webhook_email(db, to=user.email, subject=subject, message=message, full_name=user.full_name)
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)
def _get_valid_token_or_raise(db: Session, token: str) -> PasswordSetupToken: def _get_valid_token_or_raise(db: Session, token: str) -> PasswordSetupToken:
@@ -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
@@ -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"
+12 -12
View File
@@ -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): def test_admin_can_configure_password_webhook(api, auth_headers):
resp = api( 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"}, json={"url": "https://example.com/power-automate-hook"},
) )
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
assert resp.json()["configured"] is True 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" assert get_resp.json()["url"] == "https://example.com/power-automate-hook"
def test_admin_can_configure_webhook_api_key(api, auth_headers): def test_admin_can_configure_webhook_api_key(api, auth_headers):
resp = api( 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"}, json={"url": "https://example.com/power-automate-hook", "api_key": "super-secret-key"},
) )
assert resp.status_code == 200, resp.text 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): def test_send_password_email_sends_api_key_header(api, db, auth_headers, new_user_id):
api( 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"}, 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) api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
call_kwargs = mock_post.call_args call_kwargs = mock_post.call_args
assert call_kwargs.kwargs["headers"] == {"x-api-key": "super-secret-key"} 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): def test_send_password_email_posts_to_webhook_and_issues_token(api, db, auth_headers, new_user_id):
api( 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"}, 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) resp = api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
assert resp.status_code == 200, resp.text assert resp.status_code == 200, resp.text
mock_post.assert_called_once() 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): def test_set_password_with_valid_token_succeeds(api, db, auth_headers, new_user_id):
api( 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"}, 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) api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
from app.models.password_setup_token import PasswordSetupToken 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): def test_set_password_token_cannot_be_reused(api, db, auth_headers, new_user_id):
api( 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"}, 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) api("post", f"/api/v1/users/{new_user_id}/send-password-email", auth_headers)
from app.models.password_setup_token import PasswordSetupToken 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): 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 assert resp.status_code == 403
+11 -6
View File
@@ -199,29 +199,34 @@ export async function testJiraConnection(): Promise<{
return data; return data;
} }
export interface PasswordWebhookConfigOut { export interface EmailWebhookConfigOut {
configured: boolean; configured: boolean;
url: string; url: string;
api_key_set: boolean; api_key_set: boolean;
} }
export async function getPasswordWebhookConfig(): Promise<PasswordWebhookConfigOut> { export async function getEmailWebhookConfig(): Promise<EmailWebhookConfigOut> {
const { data } = await client.get<PasswordWebhookConfigOut>("/system/password-webhook-config"); const { data } = await client.get<EmailWebhookConfigOut>("/system/email-webhook-config");
return data; return data;
} }
/** apiKey is optional — omit or pass "" to leave the currently-stored key unchanged. */ /** apiKey is optional — omit or pass "" to leave the currently-stored key unchanged. */
export async function updatePasswordWebhookConfig( export async function updateEmailWebhookConfig(
url: string, url: string,
apiKey?: string, apiKey?: string,
): Promise<PasswordWebhookConfigOut> { ): Promise<EmailWebhookConfigOut> {
const { data } = await client.patch<PasswordWebhookConfigOut>("/system/password-webhook-config", { const { data } = await client.patch<EmailWebhookConfigOut>("/system/email-webhook-config", {
url, url,
api_key: apiKey || undefined, api_key: apiKey || undefined,
}); });
return data; 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<{ export async function testTempoConnection(): Promise<{
status: "ok" | "error" | "disabled"; status: "ok" | "error" | "disabled";
message?: string; message?: string;
+46 -16
View File
@@ -53,8 +53,9 @@ import {
getJiraConfig, getJiraConfig,
updateJiraConfig, updateJiraConfig,
testJiraConnection, testJiraConnection,
getPasswordWebhookConfig, getEmailWebhookConfig,
updatePasswordWebhookConfig, updateEmailWebhookConfig,
testEmailWebhook,
testTempoConnection, testTempoConnection,
type EmailConfigUpdate, type EmailConfigUpdate,
type WebhookCreate, type WebhookCreate,
@@ -1685,15 +1686,16 @@ function SystemInfoSection() {
); );
} }
function PasswordWebhookSection() { function EmailWebhookSection() {
const qc = useQueryClient(); const qc = useQueryClient();
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [apiKey, setApiKey] = useState(""); const [apiKey, setApiKey] = useState("");
const [testTo, setTestTo] = useState("");
const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null); const [toast, setToast] = useState<{ msg: string; type: "success" | "error" } | null>(null);
const { data, isLoading } = useQuery({ const { data, isLoading } = useQuery({
queryKey: ["password-webhook-config"], queryKey: ["email-webhook-config"],
queryFn: getPasswordWebhookConfig, queryFn: getEmailWebhookConfig,
}); });
useEffect(() => { useEffect(() => {
@@ -1701,15 +1703,22 @@ function PasswordWebhookSection() {
}, [data]); }, [data]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: () => updatePasswordWebhookConfig(url, apiKey), mutationFn: () => updateEmailWebhookConfig(url, apiKey),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["password-webhook-config"] }); qc.invalidateQueries({ queryKey: ["email-webhook-config"] });
setApiKey(""); setApiKey("");
setToast({ msg: "Webhook configuration saved", type: "success" }); setToast({ msg: "Webhook configuration saved", type: "success" });
}, },
onError: () => setToast({ msg: "Failed to save webhook configuration", type: "error" }), 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) { if (isLoading) {
return ( return (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
@@ -1721,14 +1730,14 @@ function PasswordWebhookSection() {
return ( return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6"> <div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-2 text-lg font-semibold text-white flex items-center gap-2"> <h2 className="mb-2 text-lg font-semibold text-white flex items-center gap-2">
<Mail className="h-5 w-5 text-cyan-400" /> Password Setup Email Webhook <Mail className="h-5 w-5 text-cyan-400" /> Email Webhook
</h2> </h2>
<p className="mb-4 text-sm text-gray-500"> <p className="mb-4 text-sm text-gray-500">
When an admin sends a set-password or password-reset link from User Management, a{" "} All platform notification emails password setup/reset, test validated/rejected,
<code className="text-gray-400">{"{ to, subject, body }"}</code> payload is POSTed to campaign completed, new MITRE techniques, and more are sent by POSTing a{" "}
this URL point it at your Power Automate flow (or similar) that actually delivers the <code className="text-gray-400">{"{ to, subject, body }"}</code> payload to this URL
email. The API key, if set, is sent as an <code className="text-gray-400">x-api-key</code>{" "} point it at your Power Automate flow (or similar) that actually delivers the email. The
header. API key, if set, is sent as an <code className="text-gray-400">x-api-key</code> header.
</p> </p>
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@@ -1766,9 +1775,30 @@ function PasswordWebhookSection() {
</div> </div>
{!data?.configured && ( {!data?.configured && (
<p className="mt-2 text-xs text-amber-400/80"> <p className="mt-2 text-xs text-amber-400/80">
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.
</p> </p>
)} )}
<div className="mt-6 border-t border-gray-800 pt-4">
<p className="mb-3 text-sm font-medium text-gray-300">Send Test Email</p>
<div className="flex gap-2">
<input
type="email"
value={testTo}
onChange={(e) => 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"
/>
<button
onClick={() => testTo && testMutation.mutate()}
disabled={testMutation.isPending || !testTo}
className="flex items-center gap-2 rounded-lg bg-gray-700 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-gray-600 disabled:opacity-50"
>
{testMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Send Test
</button>
</div>
</div>
{toast && ( {toast && (
<Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} /> <Toast message={toast.msg} type={toast.type} onClose={() => setToast(null)} />
)} )}
@@ -1799,7 +1829,7 @@ export default function SettingsPage() {
icon: Webhook, icon: Webhook,
show: isAdmin, 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: "jira", label: "Jira", icon: Link2, show: isAdmin },
{ id: "sso", label: "SSO / Azure AD", icon: KeyRound, show: isAdmin }, { id: "sso", label: "SSO / Azure AD", icon: KeyRound, show: isAdmin },
{ id: "system", label: "System", icon: Server, show: isAdmin }, { id: "system", label: "System", icon: Server, show: isAdmin },
@@ -1873,7 +1903,7 @@ export default function SettingsPage() {
{activeTab === "system" && isAdmin && ( {activeTab === "system" && isAdmin && (
<div className="space-y-6"> <div className="space-y-6">
<SystemInfoSection /> <SystemInfoSection />
<PasswordWebhookSection /> <EmailWebhookSection />
<ExportImportSection /> <ExportImportSection />
</div> </div>
)} )}