feat: add apprise notification delivery
This commit is contained in:
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
from app.core.security import require_admin_auth
|
from app.core.security import require_admin_auth
|
||||||
from app.models.setting import Setting
|
from app.models.setting import Setting
|
||||||
|
from app.services.notifications import send_notification
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -113,66 +114,25 @@ SETTING_DEFAULTS: List[Dict[str, Any]] = [
|
|||||||
},
|
},
|
||||||
# ── Notifications ─────────────────────────────────────────────────────────
|
# ── Notifications ─────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
"key": "notifications.email_enabled",
|
"key": "notifications.apprise_enabled",
|
||||||
"value": "false",
|
"value": "false",
|
||||||
"description": "Send email notifications when new DMARC failures are detected",
|
"description": "Send notifications through configured Apprise target URLs",
|
||||||
"value_type": "boolean",
|
"value_type": "boolean",
|
||||||
"category": "notifications",
|
"category": "notifications",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"key": "notifications.email_from",
|
"key": "notifications.apprise_urls",
|
||||||
"value": "",
|
"value": "",
|
||||||
"description": "From address used for notification emails",
|
"description": "Newline-separated Apprise notification target URLs",
|
||||||
"value_type": "string",
|
"value_type": "string",
|
||||||
"category": "notifications",
|
"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)
|
# Keys whose values should be redacted in GET responses (treated as secrets)
|
||||||
_SECRET_KEYS = {
|
_SECRET_KEYS = {
|
||||||
"cloudflare.api_token",
|
"cloudflare.api_token",
|
||||||
|
"notifications.apprise_urls",
|
||||||
"notifications.smtp_password",
|
"notifications.smtp_password",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +205,17 @@ class SettingResponse(BaseModel):
|
|||||||
updated_at: Optional[str]
|
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
|
# Endpoints
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -269,6 +240,27 @@ async def list_settings(
|
|||||||
return [_row_to_dict(row) for row in rows]
|
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)
|
@router.get("/{key:path}", response_model=SettingResponse)
|
||||||
async def get_setting(
|
async def get_setting(
|
||||||
key: str,
|
key: str,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -204,97 +204,49 @@
|
|||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|
||||||
<!-- ── Email Notifications ─────────────────────────────────────────────── -->
|
<!-- ── Notifications ──────────────────────────────────────────────────── -->
|
||||||
{% call card() %}
|
{% call card() %}
|
||||||
{% call card_header() %}
|
{% call card_header() %}
|
||||||
{% call card_title() %}Email Notifications{% endcall %}
|
{% call card_title() %}Notifications{% endcall %}
|
||||||
{% call card_description() %}Send alerts when DMARC failures are detected{% endcall %}
|
{% call card_description() %}Send alerts through Apprise-compatible targets{% endcall %}
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
{% call card_content() %}
|
{% call card_content() %}
|
||||||
<form @submit.prevent="saveCategory('notifications')" class="space-y-4">
|
<form @submit.prevent="saveCategory('notifications')" class="space-y-4">
|
||||||
<div class="form-control">
|
<div class="form-control">
|
||||||
<label class="label cursor-pointer justify-start gap-3">
|
<label class="label cursor-pointer justify-start gap-3">
|
||||||
<input type="checkbox"
|
<input type="checkbox"
|
||||||
:checked="s['notifications.email_enabled'] === 'true'"
|
:checked="s['notifications.apprise_enabled'] === 'true'"
|
||||||
@change="s['notifications.email_enabled'] = $event.target.checked ? 'true' : 'false'"
|
@change="s['notifications.apprise_enabled'] = $event.target.checked ? 'true' : 'false'"
|
||||||
class="checkbox checkbox-primary" />
|
class="checkbox checkbox-primary" />
|
||||||
<span class="label-text font-medium">Enable email notifications</span>
|
<span class="label-text font-medium">Enable notifications</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-if="s['notifications.email_enabled'] === 'true'">
|
<template x-if="s['notifications.apprise_enabled'] === 'true'">
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="form-control w-full">
|
||||||
<div class="form-control w-full">
|
<label class="label"><span class="label-text font-medium">Apprise Target URLs</span></label>
|
||||||
<label class="label"><span class="label-text font-medium">From Address</span></label>
|
<textarea x-model="s['notifications.apprise_urls']"
|
||||||
<input type="email" x-model="s['notifications.email_from']"
|
class="textarea textarea-bordered w-full min-h-32 font-mono text-sm"
|
||||||
class="input input-bordered w-full"
|
placeholder="mailto://user:password@example.com slack://token-a/token-b/token-c/channel"></textarea>
|
||||||
placeholder="noreply@example.com" />
|
<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="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>
|
</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>
|
</div>
|
||||||
</template>
|
</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">
|
<button type="submit" class="btn btn-default btn-md" :disabled="saving">
|
||||||
<template x-if="!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>
|
<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,
|
saving: false,
|
||||||
flashMsg: '',
|
flashMsg: '',
|
||||||
flashOk: true,
|
flashOk: true,
|
||||||
|
testingNotification: false,
|
||||||
showCfToken: false,
|
showCfToken: false,
|
||||||
showSmtpPw: false,
|
|
||||||
|
|
||||||
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
|
// Session cookie is sent automatically by the browser (httpOnly, same-origin).
|
||||||
// No manual auth header needed for API calls from the UI.
|
// 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) {
|
showFlash(msg, ok) {
|
||||||
this.flashMsg = msg;
|
this.flashMsg = msg;
|
||||||
this.flashOk = ok;
|
this.flashOk = ok;
|
||||||
@@ -403,4 +377,4 @@ function settingsApp() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ class TestSettingsAPI:
|
|||||||
assert "general.app_name" in keys
|
assert "general.app_name" in keys
|
||||||
assert "dmarc.default_policy" in keys
|
assert "dmarc.default_policy" in keys
|
||||||
assert "cloudflare.api_token" 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):
|
def test_list_settings_filter_by_category(self, authed_client: TestClient):
|
||||||
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
"""GET /api/v1/settings?category=dmarc returns only dmarc settings."""
|
||||||
@@ -125,6 +127,17 @@ class TestSettingsAPI:
|
|||||||
assert res.status_code == 200
|
assert res.status_code == 200
|
||||||
assert res.json()["value"] == "**redacted**"
|
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):
|
def test_redacted_placeholder_does_not_overwrite(self, authed_client: TestClient):
|
||||||
"""Sending **redacted** back to PUT should not overwrite the stored value."""
|
"""Sending **redacted** back to PUT should not overwrite the stored value."""
|
||||||
authed_client.get("/api/v1/settings")
|
authed_client.get("/api/v1/settings")
|
||||||
@@ -148,3 +161,55 @@ class TestSettingsAPI:
|
|||||||
"""Unauthenticated requests to settings endpoints return 403."""
|
"""Unauthenticated requests to settings endpoints return 403."""
|
||||||
res = client.get("/api/v1/settings")
|
res = client.get("/api/v1/settings")
|
||||||
assert res.status_code in (401, 403)
|
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."
|
||||||
|
|||||||
@@ -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` |
|
| `MAX_UPLOAD_SIZE` | Maximum file upload size (MB) | `10` | `20`, `50` |
|
||||||
| `SESSION_LIFETIME` | Session lifetime in minutes | `1440` (24h) | `60`, `720` |
|
| `SESSION_LIFETIME` | Session lifetime in minutes | `1440` (24h) | `60`, `720` |
|
||||||
|
|
||||||
### Alerting Configuration
|
### Notification Configuration
|
||||||
|
|
||||||
| Variable | Description | Default | Example |
|
DMARQ stores notification targets in the web settings table. Configure them under
|
||||||
|----------|-------------|---------|---------|
|
**Settings** > **Notifications** and use newline-separated Apprise URLs, such as
|
||||||
| `ALERTS_ENABLED` | Enable alerts | `false` | `true`, `false` |
|
email, Slack, Teams, Discord, or webhook targets. Saved target URLs are redacted
|
||||||
| `ALERT_EMAIL` | Email to send alerts to | - | `admin@example.com` |
|
from API responses.
|
||||||
| `SMTP_SERVER` | SMTP server for sending alerts | - | `smtp.gmail.com` |
|
|
||||||
| `SMTP_PORT` | SMTP port | `587` | `587`, `465` |
|
| Setting | Description | Default | Example |
|
||||||
| `SMTP_USERNAME` | SMTP username | - | `alerts@example.com` |
|
|---------|-------------|---------|---------|
|
||||||
| `SMTP_PASSWORD` | SMTP password | - | `smtp_password` |
|
| `notifications.apprise_enabled` | Enable Apprise notification delivery | `false` | `true` |
|
||||||
| `SMTP_USE_TLS` | Use TLS for SMTP | `true` | `true`, `false` |
|
| `notifications.apprise_urls` | Newline-separated Apprise target URLs | - | `mailto://user:pass@example.com` |
|
||||||
| `ALERT_THRESHOLD` | Compliance threshold for alerts | `90` | `80`, `95` |
|
|
||||||
|
|
||||||
### Cloudflare Integration
|
### Cloudflare Integration
|
||||||
|
|
||||||
@@ -143,18 +142,12 @@ IMAP_POLLING_INTERVAL=30
|
|||||||
IMAP_MARK_AS_READ=true
|
IMAP_MARK_AS_READ=true
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Email Alerting
|
### With Apprise Notifications
|
||||||
|
|
||||||
```
|
1. Open **Settings** > **Notifications**.
|
||||||
ALERTS_ENABLED=true
|
2. Enable notifications.
|
||||||
ALERT_EMAIL=admin@example.com
|
3. Add one Apprise target URL per line.
|
||||||
SMTP_SERVER=smtp.gmail.com
|
4. Save and use **Send Test** to verify delivery.
|
||||||
SMTP_PORT=587
|
|
||||||
SMTP_USERNAME=alerts@example.com
|
|
||||||
SMTP_PASSWORD=smtp_password
|
|
||||||
SMTP_USE_TLS=true
|
|
||||||
ALERT_THRESHOLD=95
|
|
||||||
```
|
|
||||||
|
|
||||||
## Configuration Hierarchy
|
## Configuration Hierarchy
|
||||||
|
|
||||||
@@ -192,6 +185,6 @@ DMARQ validates your configuration on startup. If there are issues, they will be
|
|||||||
- Database connection parameters
|
- Database connection parameters
|
||||||
- Secret key presence and strength
|
- Secret key presence and strength
|
||||||
- IMAP credentials (if IMAP is enabled)
|
- 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.
|
Check the application logs if you encounter startup issues related to configuration.
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ Follow-up:
|
|||||||
|
|
||||||
## Later Milestones
|
## 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.
|
- DNS health and Cloudflare read-only inspection.
|
||||||
- Guided setup and operator health screens.
|
- Guided setup and operator health screens.
|
||||||
- Forensic/RUF report support.
|
- Forensic/RUF report support.
|
||||||
|
|||||||
+6
-3
@@ -107,15 +107,18 @@ Exit criteria:
|
|||||||
|
|
||||||
## Milestone 7: Notifications and Alert Rules
|
## Milestone 7: Notifications and Alert Rules
|
||||||
|
|
||||||
Status: Planned
|
Status: In progress
|
||||||
|
|
||||||
Goal: notify administrators when action is needed.
|
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:
|
Planned:
|
||||||
- Apprise notification integration.
|
|
||||||
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
|
- Alert rules for new sender source, compliance drop, DMARC failures above threshold, and missing reports.
|
||||||
- Daily/weekly summary notifications.
|
- Daily/weekly summary notifications.
|
||||||
- Alert history and test-notification UI.
|
- Alert history.
|
||||||
|
|
||||||
Exit criteria:
|
Exit criteria:
|
||||||
- A user can receive meaningful alerts without opening the dashboard daily.
|
- A user can receive meaningful alerts without opening the dashboard daily.
|
||||||
|
|||||||
+4
-1
@@ -164,7 +164,10 @@ Status: Complete for the delivered reporting milestone. Alert-specific dashboard
|
|||||||
- [x] Add startup checks for production-critical configuration
|
- [x] Add startup checks for production-critical configuration
|
||||||
- [x] Add backup/restore guidance for database deployments
|
- [x] Add backup/restore guidance for database deployments
|
||||||
- [x] Add release checklist covering migrations, tests, and smoke checks
|
- [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
|
- [ ] DNS health guidance and Cloudflare read-only inspection
|
||||||
- [ ] Guided setup and operator health pages
|
- [ ] Guided setup and operator health pages
|
||||||
- [ ] Forensic/RUF report support
|
- [ ] Forensic/RUF report support
|
||||||
|
|||||||
+14
-13
@@ -29,15 +29,18 @@ System-wide settings are available to administrators:
|
|||||||
|
|
||||||
## Notification Settings
|
## Notification Settings
|
||||||
|
|
||||||
### Email Notifications
|
### Apprise Notifications
|
||||||
|
|
||||||
Configure how you receive email notifications:
|
Configure where DMARQ sends notifications:
|
||||||
|
|
||||||
1. Navigate to **Settings** > **Notifications** > **Email**
|
1. Navigate to **Settings**.
|
||||||
2. Configure the following:
|
2. Open **Notifications**.
|
||||||
- **Email Address**: Where notifications will be sent
|
3. Enable notifications.
|
||||||
- **Notification Frequency**: Immediate, daily digest, or weekly summary
|
4. Add one Apprise target URL per line.
|
||||||
- **Notification Types**: Select which events trigger notifications
|
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
|
### Alert Thresholds
|
||||||
|
|
||||||
@@ -52,11 +55,9 @@ Set thresholds for when alerts are triggered:
|
|||||||
|
|
||||||
### Integration Notifications
|
### Integration Notifications
|
||||||
|
|
||||||
If you've enabled additional notification channels through Apprise:
|
Apprise supports email, Slack, Teams, Discord, generic webhooks, and many other
|
||||||
|
targets through the same notification field. Add each destination on a separate
|
||||||
1. Navigate to **Settings** > **Notifications** > **Integrations**
|
line.
|
||||||
2. Configure each integration separately (Slack, Teams, Discord, etc.)
|
|
||||||
3. Set which notification types go to each channel
|
|
||||||
|
|
||||||
## API Access
|
## API Access
|
||||||
|
|
||||||
@@ -139,4 +140,4 @@ Advanced configuration options (administrators only):
|
|||||||
- **Database Connection**: Change database settings
|
- **Database Connection**: Change database settings
|
||||||
- **Worker Configuration**: Configure background processing settings
|
- **Worker Configuration**: Configure background processing settings
|
||||||
- **Caching**: Adjust cache settings for performance
|
- **Caching**: Adjust cache settings for performance
|
||||||
- **Debug Mode**: Enable additional logging for troubleshooting
|
- **Debug Mode**: Enable additional logging for troubleshooting
|
||||||
|
|||||||
Reference in New Issue
Block a user