feat: add apprise notification delivery

This commit is contained in:
Christian Krakau-Louis
2026-05-22 22:42:29 +02:00
parent 701a3603b5
commit dd4b90c9de
9 changed files with 320 additions and 162 deletions
+38 -46
View File
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.security import require_admin_auth
from app.models.setting import Setting
from app.services.notifications import send_notification
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -113,66 +114,25 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
},
# ── Notifications ─────────────────────────────────────────────────────────
{
"key": "notifications.email_enabled",
"key": "notifications.apprise_enabled",
"value": "false",
"description": "Send email notifications when new DMARC failures are detected",
"description": "Send notifications through configured Apprise target URLs",
"value_type": "boolean",
"category": "notifications",
},
{
"key": "notifications.email_from",
"key": "notifications.apprise_urls",
"value": "",
"description": "From address used for notification emails",
"description": "Newline-separated Apprise notification target URLs",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.email_to",
"value": "",
"description": "Comma-separated list of recipient addresses for notifications",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.smtp_host",
"value": "",
"description": "SMTP server hostname for sending notification emails",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.smtp_port",
"value": "587",
"description": "SMTP server port",
"value_type": "integer",
"category": "notifications",
},
{
"key": "notifications.smtp_username",
"value": "",
"description": "SMTP authentication username",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.smtp_password",
"value": "",
"description": "SMTP authentication password",
"value_type": "string",
"category": "notifications",
},
{
"key": "notifications.smtp_use_tls",
"value": "true",
"description": "Use TLS when connecting to the SMTP server",
"value_type": "boolean",
"category": "notifications",
},
]
# Keys whose values should be redacted in GET responses (treated as secrets)
_SECRET_KEYS = {
"cloudflare.api_token",
"notifications.apprise_urls",
"notifications.smtp_password",
}
@@ -245,6 +205,17 @@ class SettingResponse(BaseModel):
updated_at: Optional[str]
class NotificationTestResponse(BaseModel):
"""Sanitized response from a test notification send."""
success: bool
message: str
configured_targets: int = 0
invalid_targets: int = 0
skipped: bool = False
error: Optional[str] = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -269,6 +240,27 @@ async def list_settings(
return [_row_to_dict(row) for row in rows]
@router.post("/notifications/test", response_model=NotificationTestResponse)
async def test_notification_settings(
db: Session = Depends(get_db),
_auth: dict = Depends(require_admin_auth),
) -> NotificationTestResponse:
"""Send a test notification using the configured Apprise targets."""
_seed_defaults(db)
result = send_notification(
db,
title="DMARQ test notification",
body="This confirms that DMARQ can reach the configured notification target.",
force=True,
)
if not result.success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=result.to_dict(),
)
return result.to_dict()
@router.get("/{key:path}", response_model=SettingResponse)
async def get_setting(
key: str,
+127
View File
@@ -0,0 +1,127 @@
"""Notification delivery helpers backed by Apprise."""
from __future__ import annotations
import logging
from dataclasses import asdict, dataclass
from typing import Dict, List, Optional, Tuple
import apprise
from sqlalchemy.orm import Session
from app.models.setting import Setting
logger = logging.getLogger(__name__)
@dataclass
class NotificationResult:
"""Sanitized result for a notification send attempt."""
success: bool
message: str
configured_targets: int = 0
invalid_targets: int = 0
skipped: bool = False
error: Optional[str] = None
def to_dict(self) -> Dict[str, object]:
return asdict(self)
def _truthy(value: Optional[str]) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _split_apprise_urls(value: Optional[str]) -> List[str]:
if not value:
return []
return [
line.strip()
for line in value.splitlines()
if line.strip() and not line.strip().startswith("#")
]
def _notification_settings(db: Session) -> Dict[str, Optional[str]]:
rows = db.query(Setting).filter(Setting.category == "notifications").all()
return {row.key: row.value for row in rows}
def _add_apprise_targets(notifier: apprise.Apprise, urls: List[str]) -> Tuple[int, int]:
configured_targets = 0
invalid_targets = 0
for url in urls:
try:
if notifier.add(url):
configured_targets += 1
else:
invalid_targets += 1
except Exception: # pylint: disable=broad-exception-caught
invalid_targets += 1
logger.warning("Invalid Apprise notification target was ignored.")
return configured_targets, invalid_targets
def send_notification(
db: Session,
*,
title: str,
body: str,
force: bool = False,
) -> NotificationResult:
"""Send a notification through configured Apprise target URLs."""
settings = _notification_settings(db)
enabled = _truthy(settings.get("notifications.apprise_enabled"))
if not enabled and not force:
return NotificationResult(
success=False,
skipped=True,
message="Notifications are disabled.",
)
urls = _split_apprise_urls(settings.get("notifications.apprise_urls"))
if not urls:
return NotificationResult(
success=False,
message="No notification targets are configured.",
)
notifier = apprise.Apprise()
configured_targets, invalid_targets = _add_apprise_targets(notifier, urls)
if configured_targets == 0:
return NotificationResult(
success=False,
message="No valid notification targets are configured.",
invalid_targets=invalid_targets,
)
try:
success = bool(notifier.notify(title=title, body=body))
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Apprise notification delivery failed.")
return NotificationResult(
success=False,
message="Notification delivery failed.",
configured_targets=configured_targets,
invalid_targets=invalid_targets,
error="delivery_failed",
)
if not success:
return NotificationResult(
success=False,
message="Notification delivery was not accepted by any configured target.",
configured_targets=configured_targets,
invalid_targets=invalid_targets,
error="not_delivered",
)
return NotificationResult(
success=True,
message="Notification sent.",
configured_targets=configured_targets,
invalid_targets=invalid_targets,
)
+49 -75
View File
@@ -204,97 +204,49 @@
{% endcall %}
{% endcall %}
<!-- ── Email Notifications ─────────────────────────────────────────────── -->
<!-- ── Notifications ──────────────────────────────────────────────────── -->
{% call card() %}
{% call card_header() %}
{% call card_title() %}Email Notifications{% endcall %}
{% call card_description() %}Send alerts when DMARC failures are detected{% endcall %}
{% call card_title() %}Notifications{% endcall %}
{% call card_description() %}Send alerts through Apprise-compatible targets{% endcall %}
{% endcall %}
{% call card_content() %}
<form @submit.prevent="saveCategory('notifications')" class="space-y-4">
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.email_enabled'] === 'true'"
@change="s['notifications.email_enabled'] = $event.target.checked ? 'true' : 'false'"
:checked="s['notifications.apprise_enabled'] === 'true'"
@change="s['notifications.apprise_enabled'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Enable email notifications</span>
<span class="label-text font-medium">Enable notifications</span>
</label>
</div>
<template x-if="s['notifications.email_enabled'] === 'true'">
<template x-if="s['notifications.apprise_enabled'] === 'true'">
<div class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">From Address</span></label>
<input type="email" x-model="s['notifications.email_from']"
class="input input-bordered w-full"
placeholder="noreply@example.com" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Recipient(s)</span></label>
<input type="text" x-model="s['notifications.email_to']"
class="input input-bordered w-full"
placeholder="admin@example.com, security@example.com" />
<label class="label"><span class="label-text-alt text-muted-foreground">Comma-separated email addresses</span></label>
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">Apprise Target URLs</span></label>
<textarea x-model="s['notifications.apprise_urls']"
class="textarea textarea-bordered w-full min-h-32 font-mono text-sm"
placeholder="mailto://user:password@example.com&#10;slack://token-a/token-b/token-c/channel"></textarea>
<label class="label"><span class="label-text-alt text-muted-foreground">One target per line. Values are redacted after saving.</span></label>
</div>
<div class="divider text-sm">SMTP Configuration</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SMTP Host</span></label>
<input type="text" x-model="s['notifications.smtp_host']"
class="input input-bordered w-full"
placeholder="smtp.example.com" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SMTP Port</span></label>
<input type="number" x-model.number="s['notifications.smtp_port']"
class="input input-bordered w-full"
placeholder="587" min="1" max="65535" />
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SMTP Username</span></label>
<input type="text" x-model="s['notifications.smtp_username']"
class="input input-bordered w-full"
placeholder="smtpuser@example.com" />
</div>
<div class="form-control w-full">
<label class="label"><span class="label-text font-medium">SMTP Password</span></label>
<div class="relative">
<input :type="showSmtpPw ? 'text' : 'password'"
x-model="s['notifications.smtp_password']"
class="input input-bordered w-full pr-10"
placeholder="••••••••" />
<button type="button"
class="absolute right-2 top-3 text-muted-foreground hover:text-foreground"
@click="showSmtpPw = !showSmtpPw">
<svg x-show="!showSmtpPw" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>
<svg x-show="showSmtpPw" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path><line x1="1" y1="1" x2="23" y2="23"></line></svg>
</button>
</div>
<label class="label"><span class="label-text-alt text-muted-foreground">Leave as-is to keep existing password</span></label>
</div>
</div>
<div class="form-control">
<label class="label cursor-pointer justify-start gap-3">
<input type="checkbox"
:checked="s['notifications.smtp_use_tls'] === 'true'"
@change="s['notifications.smtp_use_tls'] = $event.target.checked ? 'true' : 'false'"
class="checkbox checkbox-primary" />
<span class="label-text font-medium">Use TLS (STARTTLS)</span>
</label>
</div>
</div>
</template>
<div class="flex justify-end">
<div class="flex flex-col sm:flex-row justify-end gap-2">
<button type="button" class="btn btn-outline btn-md" :disabled="saving || testingNotification" @click="sendTestNotification()">
<template x-if="!testingNotification">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2">
<path d="m22 2-7 20-4-9-9-4Z"></path>
<path d="M22 2 11 13"></path>
</svg>
</template>
<template x-if="testingNotification"><span class="loading loading-spinner loading-xs mr-2"></span></template>
Send Test
</button>
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
<template x-if="!saving">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="mr-2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path><polyline points="17 21 17 13 7 13 7 21"></polyline><polyline points="7 3 7 8 15 8"></polyline></svg>
@@ -339,8 +291,8 @@ function settingsApp() {
saving: false,
flashMsg: '',
flashOk: true,
testingNotification: false,
showCfToken: false,
showSmtpPw: false,
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
// No manual auth header needed for API calls from the UI.
@@ -395,6 +347,28 @@ function settingsApp() {
}
},
async sendTestNotification() {
this.testingNotification = true;
try {
await this.saveCategory('notifications');
const res = await fetch('/api/v1/settings/notifications/test', {
method: 'POST',
headers: this.apiHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const detail = data.detail || {};
this.showFlash('Test failed: ' + (detail.message || data.detail || res.statusText), false);
} else {
this.showFlash('Test notification sent.', true);
}
} catch (err) {
this.showFlash('Error sending test notification: ' + err.message, false);
} finally {
this.testingNotification = false;
}
},
showFlash(msg, ok) {
this.flashMsg = msg;
this.flashOk = ok;
@@ -403,4 +377,4 @@ function settingsApp() {
};
}
</script>
{% endblock %}
{% endblock %}
+65
View File
@@ -49,6 +49,8 @@ class TestSettingsAPI:
assert "general.app_name" in keys
assert "dmarc.default_policy" in keys
assert "cloudflare.api_token" in keys
assert "notifications.apprise_enabled" in keys
assert "notifications.apprise_urls" in keys
def test_list_settings_filter_by_category(self, authed_client: TestClient):
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
@@ -125,6 +127,17 @@ class TestSettingsAPI:
assert res.status_code == 200
assert res.json()["value"] == "**redacted**"
def test_apprise_urls_are_redacted_in_response(self, authed_client: TestClient):
"""Apprise target URLs are treated as notification secrets."""
authed_client.get("/api/v1/settings")
authed_client.put(
"/api/v1/settings/notifications.apprise_urls",
json={"value": "mailto://user:password@example.com"},
)
res = authed_client.get("/api/v1/settings/notifications.apprise_urls")
assert res.status_code == 200
assert res.json()["value"] == "**redacted**"
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
"""Sending **redacted** back to PUT should not overwrite the stored value."""
authed_client.get("/api/v1/settings")
@@ -148,3 +161,55 @@ class TestSettingsAPI:
"""Unauthenticated requests to settings endpoints return 403."""
res = client.get("/api/v1/settings")
assert res.status_code in (401, 403)
def test_test_notification_sends_via_apprise(self, authed_client: TestClient, monkeypatch):
"""POST /settings/notifications/test sends a sanitized Apprise test notification."""
class FakeApprise:
instances = []
def __init__(self):
self.urls = []
self.messages = []
FakeApprise.instances.append(self)
def add(self, url):
self.urls.append(url)
return True
def notify(self, *, title, body):
self.messages.append({"title": title, "body": body})
return True
monkeypatch.setattr("app.services.notifications.apprise.Apprise", FakeApprise)
authed_client.get("/api/v1/settings")
authed_client.post(
"/api/v1/settings/bulk",
json={
"settings": {
"notifications.apprise_enabled": "true",
"notifications.apprise_urls": "mailto://user:password@example.com",
}
},
)
res = authed_client.post("/api/v1/settings/notifications/test")
assert res.status_code == 200
data = res.json()
assert data["success"] is True
assert data["configured_targets"] == 1
assert "password" not in str(data)
assert FakeApprise.instances[0].messages[0]["title"] == "DMARQ test notification"
def test_test_notification_without_targets_returns_400(self, authed_client: TestClient):
"""Test notification returns a useful error when no target is configured."""
authed_client.get("/api/v1/settings")
res = authed_client.post("/api/v1/settings/notifications/test")
assert res.status_code == 400
detail = res.json()["detail"]
assert detail["success"] is False
assert detail["message"] == "No notification targets are configured."