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."
+16 -23
View File
@@ -74,18 +74,17 @@ For database operations, use the [Database Backup and Restore](backups.md) guide
| `MAX_UPLOAD_SIZE` | Maximum file upload size (MB) | `10` | `20`, `50` |
| `SESSION_LIFETIME` | Session lifetime in minutes | `1440` (24h) | `60`, `720` |
### Alerting Configuration
### Notification Configuration
| Variable | Description | Default | Example |
|----------|-------------|---------|---------|
| `ALERTS_ENABLED` | Enable alerts | `false` | `true`, `false` |
| `ALERT_EMAIL` | Email to send alerts to | - | `admin@example.com` |
| `SMTP_SERVER` | SMTP server for sending alerts | - | `smtp.gmail.com` |
| `SMTP_PORT` | SMTP port | `587` | `587`, `465` |
| `SMTP_USERNAME` | SMTP username | - | `alerts@example.com` |
| `SMTP_PASSWORD` | SMTP password | - | `smtp_password` |
| `SMTP_USE_TLS` | Use TLS for SMTP | `true` | `true`, `false` |
| `ALERT_THRESHOLD` | Compliance threshold for alerts | `90` | `80`, `95` |
DMARQ stores notification targets in the web settings table. Configure them under
**Settings** > **Notifications** and use newline-separated Apprise URLs, such as
email, Slack, Teams, Discord, or webhook targets. Saved target URLs are redacted
from API responses.
| Setting | Description | Default | Example |
|---------|-------------|---------|---------|
| `notifications.apprise_enabled` | Enable Apprise notification delivery | `false` | `true` |
| `notifications.apprise_urls` | Newline-separated Apprise target URLs | - | `mailto://user:pass@example.com` |
### Cloudflare Integration
@@ -143,18 +142,12 @@ IMAP_POLLING_INTERVAL=30
IMAP_MARK_AS_READ=true
```
### With Email Alerting
### With Apprise Notifications
```
ALERTS_ENABLED=true
ALERT_EMAIL=admin@example.com
SMTP_SERVER=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=alerts@example.com
SMTP_PASSWORD=smtp_password
SMTP_USE_TLS=true
ALERT_THRESHOLD=95
```
1. Open **Settings** > **Notifications**.
2. Enable notifications.
3. Add one Apprise target URL per line.
4. Save and use **Send Test** to verify delivery.
## Configuration Hierarchy
@@ -192,6 +185,6 @@ DMARQ validates your configuration on startup. If there are issues, they will be
- Database connection parameters
- Secret key presence and strength
- IMAP credentials (if IMAP is enabled)
- SMTP credentials (if alerting is enabled)
- Notification targets can be tested from the settings page
Check the application logs if you encounter startup issues related to configuration.
+1 -1
View File
@@ -83,7 +83,7 @@ Follow-up:
## Later Milestones
- Notifications and alert rules with Apprise.
- Notifications and alert rules. Apprise delivery and test notifications are in place; alert rules, summaries, and alert history remain.
- DNS health and Cloudflare read-only inspection.
- Guided setup and operator health screens.
- Forensic/RUF report support.
+6 -3
View File
@@ -107,15 +107,18 @@ Exit criteria:
## Milestone 7: Notifications and Alert Rules
Status: Planned
Status: In progress
Goal: notify administrators when action is needed.
Delivered:
- Apprise notification integration for newline-separated notification target URLs.
- Notification settings UI can save Apprise targets, keeps target URLs redacted after save, and can send a test notification.
Planned:
- Apprise notification integration.
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
- Daily/weekly summary notifications.
- Alert history and test-notification UI.
- Alert history.
Exit criteria:
- A user can receive meaningful alerts without opening the dashboard daily.
+4 -1
View File
@@ -164,7 +164,10 @@ Status: Complete for the delivered reporting milestone. Alert-specific dashboard
- [x] Add startup checks for production-critical configuration
- [x] Add backup/restore guidance for database deployments
- [x] Add release checklist covering migrations, tests, and smoke checks
- [ ] Apprise notifications and alert rules
- [x] Add Apprise notification delivery and test notification support
- [ ] Add alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports
- [ ] Add daily and weekly summary notifications
- [ ] Add alert history
- [ ] DNS health guidance and Cloudflare read-only inspection
- [ ] Guided setup and operator health pages
- [ ] Forensic/RUF report support
+14 -13
View File
@@ -29,15 +29,18 @@ System-wide settings are available to administrators:
## Notification Settings
### Email Notifications
### Apprise Notifications
Configure how you receive email notifications:
Configure where DMARQ sends notifications:
1. Navigate to **Settings** > **Notifications** > **Email**
2. Configure the following:
- **Email Address**: Where notifications will be sent
- **Notification Frequency**: Immediate, daily digest, or weekly summary
- **Notification Types**: Select which events trigger notifications
1. Navigate to **Settings**.
2. Open **Notifications**.
3. Enable notifications.
4. Add one Apprise target URL per line.
5. Save and use **Send Test** to verify delivery.
Target URLs are redacted after saving so credentials are not exposed through the
settings API or page reloads.
### Alert Thresholds
@@ -52,11 +55,9 @@ Set thresholds for when alerts are triggered:
### Integration Notifications
If you've enabled additional notification channels through Apprise:
1. Navigate to **Settings** > **Notifications** > **Integrations**
2. Configure each integration separately (Slack, Teams, Discord, etc.)
3. Set which notification types go to each channel
Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other
targets through the same notification field. Add each destination on a separate
line.
## API Access
@@ -139,4 +140,4 @@ Advanced configuration options (administrators only):
- **Database Connection**: Change database settings
- **Worker Configuration**: Configure background processing settings
- **Caching**: Adjust cache settings for performance
- **Debug Mode**: Enable additional logging for troubleshooting
- **Debug Mode**: Enable additional logging for troubleshooting