Add application logging, SMTP email notifications, and supporting changes

Phase 5.4 — Application Logging:
- AppLog model + migration 0010 for unified audit/usage/error logging
- Usage logging middleware in app.py (after_request for /api/* requests)
- Error logging in 500 handler with traceback capture
- Audit logging for auth events (register, login, login_failed, logout,
  password_change) and admin actions (backup, restore, user_delete,
  registration_toggle, smtp_config, smtp_test)
- Admin log viewer (LogsView.vue) with stats, category/search/date
  filters, paginated table with expandable detail rows
- Admin logs API endpoints in admin.py (GET /logs, GET /logs/stats)
- Configurable retention via LOG_RETENTION_DAYS with hourly cleanup

Phase 5.5 — SMTP Email Notifications:
- aiosmtplib dependency for async email sending
- Email service (services/email.py) with STARTTLS/implicit TLS support
- Notification service (services/notifications.py) for security alerts
  and task due date reminders with per-user preferences
- Admin SMTP config endpoints (GET/PUT /api/admin/smtp, POST test)
- SMTP config in Config class with env var + Docker secret support
- Settings UI: notification preferences for all users, SMTP config
  section for admin with test email

Other changes:
- stream_chat() now accepts optional options dict (for num_predict)
- Increase assist MAX_BODY_CHARS from 3000 to 8000
- get_user_by_username() added to auth service
- apiStreamPost buffer processing refactored for robustness
- AppHeader: admin Logs nav link
- Router: /admin/logs route

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 08:34:52 -05:00
parent a89d25f5d6
commit d874e0e2ae
19 changed files with 1516 additions and 31 deletions
@@ -0,0 +1,40 @@
"""Add app_logs table for audit, usage, and error logging."""
from alembic import op
revision = "0010"
down_revision = "0009"
def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS app_logs (
id SERIAL PRIMARY KEY,
category TEXT NOT NULL,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
username TEXT,
action TEXT,
endpoint TEXT,
method TEXT,
status_code INTEGER,
duration_ms REAL,
ip_address TEXT,
details TEXT,
created_at TIMESTAMPTZ DEFAULT now()
)
""")
op.execute("CREATE INDEX IF NOT EXISTS ix_app_logs_category ON app_logs (category)")
op.execute("CREATE INDEX IF NOT EXISTS ix_app_logs_user_id ON app_logs (user_id)")
op.execute("CREATE INDEX IF NOT EXISTS ix_app_logs_created_at ON app_logs (created_at)")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_app_logs_category_created_at "
"ON app_logs (category, created_at DESC)"
)
def downgrade() -> None:
op.execute("DROP INDEX IF EXISTS ix_app_logs_category_created_at")
op.execute("DROP INDEX IF EXISTS ix_app_logs_created_at")
op.execute("DROP INDEX IF EXISTS ix_app_logs_user_id")
op.execute("DROP INDEX IF EXISTS ix_app_logs_category")
op.execute("DROP TABLE IF EXISTS app_logs")
+24 -24
View File
@@ -226,22 +226,25 @@ export async function apiStreamPost(
const decoder = new TextDecoder();
let buffer = "";
function processLines(text: string) {
const lines = text.split("\n");
function processLine(line: string) {
const trimmed = line.trim();
if (trimmed.startsWith("data: ")) {
let data;
try {
data = JSON.parse(trimmed.slice(6));
} catch {
return; // Skip malformed JSON lines
}
onChunk(data);
}
}
function processBuffer() {
const lines = buffer.split("\n");
// Keep the last (possibly incomplete) line in the buffer
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith("data: ")) {
let data;
try {
data = JSON.parse(trimmed.slice(6));
} catch {
continue; // Skip malformed JSON lines
}
onChunk(data);
}
processLine(line);
}
}
@@ -250,22 +253,19 @@ export async function apiStreamPost(
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
processLines(buffer);
processBuffer();
}
} catch {
// Stream may close with a network error after all data was sent.
// Process whatever remains in the buffer before deciding to throw.
}
// Process any remaining buffer
const remaining = buffer.trim();
if (remaining.startsWith("data: ")) {
let data;
try {
data = JSON.parse(remaining.slice(6));
} catch {
return; // Skip malformed JSON
// Flush any remaining complete lines in the buffer
if (buffer.trim()) {
// The buffer may contain one or more unprocessed lines
const remaining = buffer;
buffer = "";
for (const line of remaining.split("\n")) {
processLine(line);
}
onChunk(data);
}
}
+1
View File
@@ -62,6 +62,7 @@ router.afterEach(() => {
<router-link to="/chat" class="nav-link">Chat</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/logs" class="nav-link">Logs</router-link>
<span class="status-indicator" :class="statusClass" :title="statusLabel">
<span class="status-dot"></span>
</span>
+5
View File
@@ -82,6 +82,11 @@ const router = createRouter({
name: "admin-users",
component: () => import("@/views/UserManagementView.vue"),
},
{
path: "/admin/logs",
name: "admin-logs",
component: () => import("@/views/LogsView.vue"),
},
],
});
+491
View File
@@ -0,0 +1,491 @@
<script setup lang="ts">
import { ref, onMounted, watch } from "vue";
import { apiGet } from "@/api/client";
import { useToastStore } from "@/stores/toast";
import PaginationBar from "@/components/PaginationBar.vue";
const toastStore = useToastStore();
interface LogEntry {
id: number;
category: string;
user_id: number | null;
username: string | null;
action: string | null;
endpoint: string | null;
method: string | null;
status_code: number | null;
duration_ms: number | null;
ip_address: string | null;
details: string | null;
created_at: string;
}
interface LogStats {
audit: number;
usage: number;
error: number;
total: number;
}
const logs = ref<LogEntry[]>([]);
const stats = ref<LogStats>({ audit: 0, usage: 0, error: 0, total: 0 });
const total = ref(0);
const loading = ref(true);
const expandedId = ref<number | null>(null);
// Filters
const category = ref("");
const search = ref("");
const dateFrom = ref("");
const dateTo = ref("");
const limit = 50;
const offset = ref(0);
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
onMounted(async () => {
await Promise.all([fetchLogs(), fetchStats()]);
loading.value = false;
});
watch([category, dateFrom, dateTo], () => {
offset.value = 0;
fetchLogs();
});
watch(search, () => {
if (searchTimeout) clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
offset.value = 0;
fetchLogs();
}, 300);
});
watch(offset, () => {
fetchLogs();
});
async function fetchLogs() {
try {
const params = new URLSearchParams();
if (category.value) params.set("category", category.value);
if (search.value) params.set("search", search.value);
if (dateFrom.value) params.set("date_from", dateFrom.value);
if (dateTo.value) params.set("date_to", dateTo.value);
params.set("limit", String(limit));
params.set("offset", String(offset.value));
const data = await apiGet<{ logs: LogEntry[]; total: number }>(
`/api/admin/logs?${params}`
);
logs.value = data.logs;
total.value = data.total;
} catch {
toastStore.show("Failed to load logs", "error");
}
}
async function fetchStats() {
try {
stats.value = await apiGet<LogStats>("/api/admin/logs/stats");
} catch {
// Ignore
}
}
function toggleExpand(id: number) {
expandedId.value = expandedId.value === id ? null : id;
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function formatDetails(details: string | null): string {
if (!details) return "";
try {
return JSON.stringify(JSON.parse(details), null, 2);
} catch {
return details;
}
}
function displayLabel(entry: LogEntry): string {
if (entry.category === "audit" && entry.action) return entry.action;
if (entry.endpoint) return entry.endpoint;
return "—";
}
function clearFilters() {
category.value = "";
search.value = "";
dateFrom.value = "";
dateTo.value = "";
offset.value = 0;
}
</script>
<template>
<main class="logs-page">
<h1>Application Logs</h1>
<section class="settings-section stats-section">
<div class="stats-grid">
<div class="stat-card">
<span class="stat-count">{{ stats.total.toLocaleString() }}</span>
<span class="stat-label">Total</span>
</div>
<div class="stat-card">
<span class="stat-count stat-audit">{{ stats.audit.toLocaleString() }}</span>
<span class="stat-label">Audit</span>
</div>
<div class="stat-card">
<span class="stat-count stat-usage">{{ stats.usage.toLocaleString() }}</span>
<span class="stat-label">Usage</span>
</div>
<div class="stat-card">
<span class="stat-count stat-error">{{ stats.error.toLocaleString() }}</span>
<span class="stat-label">Error</span>
</div>
</div>
</section>
<section class="settings-section">
<h2>Filters</h2>
<div class="filter-bar">
<select v-model="category" class="filter-select">
<option value="">All categories</option>
<option value="audit">Audit</option>
<option value="usage">Usage</option>
<option value="error">Error</option>
</select>
<input
v-model="search"
type="text"
placeholder="Search logs..."
class="filter-input"
/>
<input v-model="dateFrom" type="date" class="filter-date" title="From date" />
<input v-model="dateTo" type="date" class="filter-date" title="To date" />
<button
v-if="category || search || dateFrom || dateTo"
class="btn-clear"
@click="clearFilters"
>
Clear
</button>
</div>
</section>
<section class="settings-section">
<div v-if="loading" class="loading-msg">Loading logs...</div>
<div v-else-if="logs.length === 0" class="empty-msg">No log entries found.</div>
<template v-else>
<table class="users-table logs-table">
<thead>
<tr>
<th>Time</th>
<th>Category</th>
<th class="hide-mobile">User</th>
<th>Action / Endpoint</th>
<th class="hide-mobile">Status</th>
<th class="hide-mobile">Duration</th>
</tr>
</thead>
<tbody>
<template v-for="entry in logs" :key="entry.id">
<tr
class="log-row"
:class="{ 'row-expanded': expandedId === entry.id }"
@click="toggleExpand(entry.id)"
>
<td class="cell-time">{{ formatTime(entry.created_at) }}</td>
<td>
<span class="category-badge" :class="'cat-' + entry.category">
{{ entry.category }}
</span>
</td>
<td class="hide-mobile cell-user">{{ entry.username || "—" }}</td>
<td class="cell-action">
<span v-if="entry.method" class="method-tag">{{ entry.method }}</span>
{{ displayLabel(entry) }}
</td>
<td class="hide-mobile cell-status">
<span v-if="entry.status_code" :class="entry.status_code >= 400 ? 'text-error' : ''">
{{ entry.status_code }}
</span>
<span v-else></span>
</td>
<td class="hide-mobile cell-duration">
{{ entry.duration_ms != null ? entry.duration_ms + "ms" : "—" }}
</td>
</tr>
<tr v-if="expandedId === entry.id && entry.details" class="detail-row">
<td colspan="6">
<pre class="detail-json">{{ formatDetails(entry.details) }}</pre>
</td>
</tr>
</template>
</tbody>
</table>
<PaginationBar
:total="total"
:limit="limit"
:offset="offset"
@update:offset="offset = $event"
/>
</template>
</section>
</main>
</template>
<style scoped>
.logs-page {
max-width: 960px;
margin: 2rem auto;
padding: 0 1rem;
}
.logs-page h1 {
margin: 0 0 1.5rem;
}
.settings-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.25rem;
margin-bottom: 1.5rem;
}
.settings-section h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
/* Stats */
.stats-section {
padding: 1rem 1.25rem;
}
.stats-grid {
display: flex;
gap: 1rem;
}
.stat-card {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
}
.stat-count {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text);
}
.stat-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.stat-audit {
color: var(--color-primary);
}
.stat-usage {
color: var(--color-success);
}
.stat-error {
color: var(--color-danger);
}
/* Filters */
.filter-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-select,
.filter-input,
.filter-date {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.85rem;
}
.filter-select {
min-width: 140px;
}
.filter-input {
flex: 1;
min-width: 150px;
}
.filter-date {
width: 140px;
}
.btn-clear {
padding: 0.4rem 0.75rem;
background: transparent;
color: var(--color-text-muted);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.85rem;
}
.btn-clear:hover {
color: var(--color-text);
border-color: var(--color-text-muted);
}
/* Table */
.loading-msg,
.empty-msg {
text-align: center;
color: var(--color-text-muted);
font-size: 0.9rem;
padding: 1rem 0;
}
.logs-table {
width: 100%;
border-collapse: collapse;
}
.logs-table th {
text-align: left;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.logs-table td {
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--color-border);
font-size: 0.85rem;
}
.logs-table tbody tr:last-child td {
border-bottom: none;
}
.log-row {
cursor: pointer;
transition: background 0.1s;
}
.log-row:hover {
background: var(--color-bg-secondary);
}
.row-expanded {
background: var(--color-bg-secondary);
}
.cell-time {
white-space: nowrap;
color: var(--color-text-muted);
font-size: 0.8rem;
}
.cell-user {
color: var(--color-text-secondary);
}
.cell-action {
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cell-status {
font-family: monospace;
font-size: 0.85rem;
}
.cell-duration {
color: var(--color-text-muted);
font-size: 0.8rem;
white-space: nowrap;
}
.text-error {
color: var(--color-danger);
}
/* Category badges */
.category-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.35rem;
border-radius: var(--radius-sm);
}
.cat-audit {
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
}
.cat-usage {
color: var(--color-success);
background: color-mix(in srgb, var(--color-success) 15%, transparent);
}
.cat-error {
color: var(--color-danger);
background: color-mix(in srgb, var(--color-danger) 15%, transparent);
}
/* Method tag */
.method-tag {
display: inline-block;
font-size: 0.65rem;
font-weight: 700;
font-family: monospace;
padding: 0.05rem 0.25rem;
border-radius: 3px;
background: var(--color-bg-secondary);
color: var(--color-text-muted);
margin-right: 0.25rem;
}
/* Detail row */
.detail-row td {
padding: 0 0.75rem 0.75rem;
border-bottom: 1px solid var(--color-border);
}
.detail-json {
margin: 0;
padding: 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8rem;
overflow-x: auto;
white-space: pre-wrap;
word-break: break-all;
max-height: 300px;
}
@media (max-width: 768px) {
.stats-grid {
flex-wrap: wrap;
}
.stat-card {
min-width: calc(50% - 0.5rem);
}
.filter-bar {
flex-direction: column;
}
.filter-select,
.filter-input,
.filter-date {
width: 100%;
}
.cell-action {
max-width: 160px;
}
}
</style>
+233 -1
View File
@@ -3,7 +3,7 @@ import { ref, computed, onMounted } from "vue";
import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiPut } from "@/api/client";
import { apiGet, apiPost, apiPut } from "@/api/client";
import type { ModelInfo } from "@/types/settings";
const store = useSettingsStore();
@@ -23,6 +23,27 @@ const exporting = ref(false);
const restoring = ref(false);
const restoreFileInput = ref<HTMLInputElement | null>(null);
// Notification preferences
const notifyTaskReminders = ref(true);
const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
// SMTP settings (admin only)
const smtp = ref({
smtp_host: "",
smtp_port: "587",
smtp_username: "",
smtp_password: "",
smtp_from_address: "",
smtp_from_name: "Fabled Assistant",
smtp_use_tls: "true",
});
const savingSmtp = ref(false);
const smtpSaved = ref(false);
const testRecipient = ref("");
const sendingTest = ref(false);
const MODEL_CATALOG: ModelInfo[] = [
// — General Purpose —
{
@@ -193,6 +214,25 @@ onMounted(async () => {
assistantName.value = store.assistantName;
selectedModel.value = store.defaultModel;
store.fetchInstalledModels();
// Load notification preferences from user settings
const allSettings = await apiGet<Record<string, string>>("/api/settings");
if (allSettings.notify_task_reminders !== undefined) {
notifyTaskReminders.value = allSettings.notify_task_reminders !== "false";
}
if (allSettings.notify_security_alerts !== undefined) {
notifySecurityAlerts.value = allSettings.notify_security_alerts !== "false";
}
// Load SMTP config if admin
if (authStore.isAdmin) {
try {
const smtpConfig = await apiGet<Record<string, string>>("/api/admin/smtp");
smtp.value = { ...smtp.value, ...smtpConfig };
} catch {
// SMTP not configured yet
}
}
});
async function changePassword() {
@@ -319,6 +359,58 @@ function triggerRestoreUpload() {
restoreFileInput.value?.click();
}
async function saveNotifications() {
savingNotifications.value = true;
notificationsSaved.value = false;
try {
await apiPut("/api/settings", {
notify_task_reminders: notifyTaskReminders.value ? "true" : "false",
notify_security_alerts: notifySecurityAlerts.value ? "true" : "false",
});
notificationsSaved.value = true;
setTimeout(() => (notificationsSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save notification preferences", "error");
} finally {
savingNotifications.value = false;
}
}
async function saveSmtp() {
savingSmtp.value = true;
smtpSaved.value = false;
try {
await apiPut("/api/admin/smtp", smtp.value);
smtpSaved.value = true;
setTimeout(() => (smtpSaved.value = false), 2000);
} catch {
toastStore.show("Failed to save SMTP settings", "error");
} finally {
savingSmtp.value = false;
}
}
async function sendTestEmail() {
if (!testRecipient.value.trim()) {
toastStore.show("Enter a recipient email address", "error");
return;
}
sendingTest.value = true;
try {
await apiPost("/api/admin/smtp/test", { recipient: testRecipient.value.trim() });
toastStore.show("Test email sent successfully");
} catch (e: unknown) {
if (e && typeof e === "object" && "body" in e) {
const body = (e as { body?: { error?: string } }).body;
toastStore.show(body?.error || "Failed to send test email", "error");
} else {
toastStore.show("Failed to send test email", "error");
}
} finally {
sendingTest.value = false;
}
}
async function handleRestoreFile(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
@@ -425,6 +517,99 @@ async function handleRestoreFile(event: Event) {
</div>
</section>
<section class="settings-section">
<h2>Notifications</h2>
<p class="section-desc">
Choose which email notifications you'd like to receive. Requires SMTP to be configured by an admin.
</p>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="notifyTaskReminders" />
Task due date reminders
</label>
<p class="field-hint">Receive a daily email when you have tasks due or overdue.</p>
</div>
<div class="checkbox-field">
<label>
<input type="checkbox" v-model="notifySecurityAlerts" />
Security alerts
</label>
<p class="field-hint">Receive emails for login, logout, failed login attempts, and password changes.</p>
</div>
<div class="actions">
<button class="btn-save" @click="saveNotifications" :disabled="savingNotifications">
{{ savingNotifications ? "Saving..." : "Save" }}
</button>
<span v-if="notificationsSaved" class="saved-msg">Saved!</span>
</div>
</section>
<section v-if="authStore.isAdmin" class="settings-section">
<h2>Email / SMTP</h2>
<p class="section-desc">
Configure SMTP settings to enable email notifications for all users.
</p>
<div class="smtp-grid">
<div class="field">
<label for="smtp-host">SMTP Host</label>
<input id="smtp-host" v-model="smtp.smtp_host" type="text" placeholder="smtp.example.com" class="input" />
</div>
<div class="field">
<label for="smtp-port">Port</label>
<input id="smtp-port" v-model="smtp.smtp_port" type="text" placeholder="587" class="input" />
</div>
<div class="field">
<label for="smtp-username">Username</label>
<input id="smtp-username" v-model="smtp.smtp_username" type="text" class="input" />
</div>
<div class="field">
<label for="smtp-password">Password</label>
<input id="smtp-password" v-model="smtp.smtp_password" type="password" class="input" />
</div>
<div class="field">
<label for="smtp-from-address">From Address</label>
<input id="smtp-from-address" v-model="smtp.smtp_from_address" type="email" placeholder="noreply@example.com" class="input" />
</div>
<div class="field">
<label for="smtp-from-name">From Name</label>
<input id="smtp-from-name" v-model="smtp.smtp_from_name" type="text" placeholder="Fabled Assistant" class="input" />
</div>
</div>
<div class="checkbox-field">
<label>
<input type="checkbox" :checked="smtp.smtp_use_tls === 'true'" @change="smtp.smtp_use_tls = ($event.target as HTMLInputElement).checked ? 'true' : 'false'" />
Use STARTTLS
</label>
<p class="field-hint">Enable STARTTLS encryption (recommended for port 587). Implicit TLS is used automatically for port 465.</p>
</div>
<div class="actions" style="margin-bottom: 1.25rem;">
<button class="btn-save" @click="saveSmtp" :disabled="savingSmtp">
{{ savingSmtp ? "Saving..." : "Save SMTP Settings" }}
</button>
<span v-if="smtpSaved" class="saved-msg">Saved!</span>
</div>
<div class="test-email-section">
<h3 class="subsection-label">Test Email</h3>
<div class="test-email-row">
<input
v-model="testRecipient"
type="email"
placeholder="test@example.com"
class="input"
style="flex: 1;"
/>
<button class="btn-save" @click="sendTestEmail" :disabled="sendingTest || !testRecipient.trim()">
{{ sendingTest ? "Sending..." : "Send Test" }}
</button>
</div>
</div>
</section>
<section class="settings-section">
<h2>Model</h2>
<p class="section-desc">
@@ -843,4 +1028,51 @@ async function handleRestoreFile(event: Event) {
.hidden-file-input {
display: none;
}
/* Checkbox fields */
.checkbox-field {
margin-bottom: 1rem;
}
.checkbox-field label {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.95rem;
color: var(--color-text);
cursor: pointer;
}
.checkbox-field input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--color-primary);
}
/* SMTP grid */
.smtp-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0 1rem;
}
@media (max-width: 480px) {
.smtp-grid {
grid-template-columns: 1fr;
}
}
/* Test email */
.subsection-label {
margin: 0 0 0.5rem;
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text-secondary);
}
.test-email-section {
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.test-email-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
</style>
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"httpx>=0.27",
"hypercorn>=0.17",
"bcrypt>=4.0",
"aiosmtplib>=3.0",
]
[project.optional-dependencies]
+40
View File
@@ -1,5 +1,6 @@
import logging
import time
import traceback as tb_module
from pathlib import Path
from quart import Quart, g, jsonify, make_response, request, send_from_directory
@@ -59,6 +60,24 @@ def create_app() -> Quart:
response.status_code,
duration_ms,
)
# Log usage for API requests (skip logs endpoint to avoid recursion)
if request.path.startswith("/api/") and not request.path.startswith("/api/admin/logs"):
try:
from fabledassistant.services.logging import log_usage
user = getattr(g, "user", None)
await log_usage(
user_id=user.id if user else None,
username=user.username if user else None,
endpoint=request.path,
method=request.method,
status_code=response.status_code,
duration_ms=duration_ms,
)
except Exception:
logger.debug("Failed to log usage", exc_info=True)
return response
@app.before_serving
@@ -67,8 +86,12 @@ def create_app() -> Quart:
from fabledassistant.services.generation_buffer import start_cleanup_loop
from fabledassistant.services.llm import ensure_model
from fabledassistant.services.logging import start_log_retention_loop
from fabledassistant.services.notifications import start_notification_loop
start_cleanup_loop()
start_log_retention_loop()
start_notification_loop()
async def _pull_model():
try:
@@ -111,6 +134,23 @@ def create_app() -> Quart:
@app.errorhandler(500)
async def handle_500(error):
logger.exception("Internal server error on %s %s", request.method, request.path)
try:
from fabledassistant.services.logging import log_error
user = getattr(g, "user", None)
await log_error(
user_id=user.id if user else None,
username=user.username if user else None,
endpoint=request.path,
method=request.method,
error_type=type(error).__name__,
error_message=str(error),
traceback=tb_module.format_exc(),
)
except Exception:
logger.debug("Failed to log error", exc_info=True)
if request.path.startswith("/api/"):
return jsonify({"error": "Internal server error"}), 500
return "Internal Server Error", 500
+10
View File
@@ -27,3 +27,13 @@ class Config:
SECRET_KEY: str = _read_secret("SECRET_KEY", "SECRET_KEY_FILE", "dev-secret-change-me")
SECURE_COOKIES: bool = os.environ.get("SECURE_COOKIES", "").lower() in ("1", "true", "yes")
LOG_LEVEL: str = os.environ.get("LOG_LEVEL", "INFO")
LOG_RETENTION_DAYS: int = int(os.environ.get("LOG_RETENTION_DAYS", "90"))
# SMTP defaults (overridden by DB settings when configured via admin UI)
SMTP_HOST: str = os.environ.get("SMTP_HOST", "")
SMTP_PORT: int = int(os.environ.get("SMTP_PORT", "587"))
SMTP_USERNAME: str = os.environ.get("SMTP_USERNAME", "")
SMTP_PASSWORD: str = _read_secret("SMTP_PASSWORD", "SMTP_PASSWORD_FILE", "")
SMTP_FROM_ADDRESS: str = os.environ.get("SMTP_FROM_ADDRESS", "")
SMTP_FROM_NAME: str = os.environ.get("SMTP_FROM_NAME", "Fabled Assistant")
SMTP_USE_TLS: bool = os.environ.get("SMTP_USE_TLS", "true").lower() in ("1", "true", "yes")
+1
View File
@@ -15,3 +15,4 @@ from fabledassistant.models.note import Note, TaskPriority, TaskStatus # noqa:
from fabledassistant.models.conversation import Conversation, Message # noqa: E402, F401
from fabledassistant.models.setting import Setting # noqa: E402, F401
from fabledassistant.models.user import User # noqa: E402, F401
from fabledassistant.models.app_log import AppLog # noqa: E402, F401
+48
View File
@@ -0,0 +1,48 @@
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, Index, Integer, Text
from sqlalchemy.orm import Mapped, mapped_column
from fabledassistant.models import Base
class AppLog(Base):
__tablename__ = "app_logs"
id: Mapped[int] = mapped_column(primary_key=True)
category: Mapped[str] = mapped_column(Text, nullable=False)
user_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
username: Mapped[str | None] = mapped_column(Text, nullable=True)
action: Mapped[str | None] = mapped_column(Text, nullable=True)
endpoint: Mapped[str | None] = mapped_column(Text, nullable=True)
method: Mapped[str | None] = mapped_column(Text, nullable=True)
status_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
duration_ms: Mapped[float | None] = mapped_column(Float, nullable=True)
ip_address: Mapped[str | None] = mapped_column(Text, nullable=True)
details: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
__table_args__ = (
Index("ix_app_logs_category", "category"),
Index("ix_app_logs_user_id", "user_id"),
Index("ix_app_logs_created_at", "created_at"),
Index("ix_app_logs_category_created_at", "category", created_at.desc()),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"category": self.category,
"user_id": self.user_id,
"username": self.username,
"action": self.action,
"endpoint": self.endpoint,
"method": self.method,
"status_code": self.status_code,
"duration_ms": self.duration_ms,
"ip_address": self.ip_address,
"details": self.details,
"created_at": self.created_at.isoformat() if self.created_at else None,
}
+86 -2
View File
@@ -1,6 +1,6 @@
import json
from quart import Blueprint, Response, jsonify, request
from quart import Blueprint, Response, g, jsonify, request
from fabledassistant.auth import admin_required, login_required, get_current_user_id
from fabledassistant.services.auth import (
@@ -14,6 +14,9 @@ from fabledassistant.services.backup import (
export_user_backup,
restore_full_backup,
)
from fabledassistant.services.email import SMTP_SETTING_KEYS, get_smtp_config, send_test_email
from fabledassistant.services.logging import get_logs, get_log_stats, log_audit
from fabledassistant.services.settings import set_settings_batch
admin_bp = Blueprint("admin", __name__, url_prefix="/api/admin")
@@ -26,13 +29,13 @@ async def backup():
if scope == "full":
# Full backup requires admin
from quart import g
if g.user.role != "admin":
return jsonify({"error": "Admin access required for full backup"}), 403
data = await export_full_backup()
else:
data = await export_user_backup(uid)
await log_audit("backup", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"scope": scope})
return Response(
json.dumps(data, indent=2, default=str),
content_type="application/json",
@@ -50,6 +53,8 @@ async def restore():
return jsonify({"error": "No backup data provided"}), 400
stats = await restore_full_backup(data)
uid = get_current_user_id()
await log_audit("restore", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"stats": stats})
return jsonify({"status": "ok", "stats": stats})
@@ -70,6 +75,7 @@ async def remove_user(user_id: int):
deleted = await delete_user(user_id)
if not deleted:
return jsonify({"error": "User not found"}), 404
await log_audit("user_delete", user_id=current_uid, username=g.user.username, ip_address=request.remote_addr, details={"deleted_user_id": user_id})
return jsonify({"status": "ok"})
@@ -90,4 +96,82 @@ async def toggle_registration():
uid = get_current_user_id()
await set_registration_open(uid, bool(open_val))
await log_audit("registration_toggle", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"open": bool(open_val)})
return jsonify({"status": "ok", "open": bool(open_val)})
@admin_bp.route("/smtp", methods=["GET"])
@admin_required
async def get_smtp():
config = await get_smtp_config()
# Mask password
if config.get("smtp_password"):
config["smtp_password"] = "********"
return jsonify(config)
@admin_bp.route("/smtp", methods=["PUT"])
@admin_required
async def update_smtp():
data = await request.get_json()
uid = get_current_user_id()
settings_to_save = {}
for key in SMTP_SETTING_KEYS:
if key in data:
# Skip password if it's the mask placeholder
if key == "smtp_password" and data[key] == "********":
continue
settings_to_save[key] = str(data[key])
if settings_to_save:
await set_settings_batch(uid, settings_to_save)
await log_audit("smtp_config", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
return jsonify({"status": "ok"})
@admin_bp.route("/smtp/test", methods=["POST"])
@admin_required
async def test_smtp():
data = await request.get_json()
recipient = (data.get("recipient") or "").strip()
if not recipient:
return jsonify({"error": "Recipient email is required"}), 400
uid = get_current_user_id()
try:
await send_test_email(recipient)
await log_audit("smtp_test", user_id=uid, username=g.user.username, ip_address=request.remote_addr, details={"recipient": recipient})
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@admin_bp.route("/logs", methods=["GET"])
@admin_required
async def list_logs():
category = request.args.get("category")
user_id = request.args.get("user_id", type=int)
search = request.args.get("search")
date_from = request.args.get("date_from")
date_to = request.args.get("date_to")
limit = request.args.get("limit", 50, type=int)
offset = request.args.get("offset", 0, type=int)
logs, total = await get_logs(
category=category,
user_id=user_id,
search=search,
date_from=date_from,
date_to=date_to,
limit=min(limit, 200),
offset=offset,
)
return jsonify({"logs": logs, "total": total})
@admin_bp.route("/logs/stats", methods=["GET"])
@admin_required
async def log_stats():
stats = await get_log_stats()
return jsonify(stats)
+33 -1
View File
@@ -1,4 +1,6 @@
from quart import Blueprint, jsonify, request, session
import asyncio
from quart import Blueprint, g, jsonify, request, session
from fabledassistant.auth import login_required, get_current_user_id
from fabledassistant.services.auth import (
@@ -6,9 +8,12 @@ from fabledassistant.services.auth import (
change_password,
create_user,
get_user_by_id,
get_user_by_username,
get_user_count,
is_registration_open,
)
from fabledassistant.services.logging import log_audit
from fabledassistant.services.notifications import notify_security_event
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@@ -34,6 +39,7 @@ async def register():
return jsonify({"error": "Username already taken"}), 409
session["user_id"] = user.id
await log_audit("register", user_id=user.id, username=user.username, ip_address=request.remote_addr)
return jsonify(user.to_dict()), 201
@@ -48,14 +54,35 @@ async def login():
user = await authenticate(username, password)
if not user:
await log_audit("login_failed", username=username, ip_address=request.remote_addr)
# Try to notify the actual user about the failed attempt
target_user = await get_user_by_username(username)
if target_user:
asyncio.create_task(notify_security_event(
target_user.id, "login_failed",
{"ip_address": request.remote_addr, "username": username},
))
return jsonify({"error": "Invalid username or password"}), 401
session["user_id"] = user.id
await log_audit("login", user_id=user.id, username=user.username, ip_address=request.remote_addr)
asyncio.create_task(notify_security_event(
user.id, "login",
{"ip_address": request.remote_addr},
))
return jsonify(user.to_dict())
@auth_bp.route("/logout", methods=["POST"])
async def logout():
uid = session.get("user_id")
if uid:
user = await get_user_by_id(uid)
await log_audit("logout", user_id=uid, username=user.username if user else None, ip_address=request.remote_addr)
asyncio.create_task(notify_security_event(
uid, "logout",
{"ip_address": request.remote_addr},
))
session.clear()
return jsonify({"status": "ok"})
@@ -86,6 +113,11 @@ async def update_password():
if not success:
return jsonify({"error": "Current password is incorrect"}), 403
await log_audit("password_change", user_id=uid, username=g.user.username, ip_address=request.remote_addr)
asyncio.create_task(notify_security_event(
uid, "password_change",
{"ip_address": request.remote_addr},
))
return jsonify({"status": "ok"})
+1 -1
View File
@@ -1,4 +1,4 @@
MAX_BODY_CHARS = 3000
MAX_BODY_CHARS = 8000
def build_assist_messages(
+8
View File
@@ -87,6 +87,14 @@ async def get_user_by_id(user_id: int) -> User | None:
return await session.get(User, user_id)
async def get_user_by_username(username: str) -> User | None:
async with async_session() as session:
result = await session.execute(
select(User).where(User.username == username)
)
return result.scalars().first()
async def change_password(user_id: int, current_password: str, new_password: str) -> bool:
"""Change a user's password. Returns True on success, False if current password is wrong."""
async with async_session() as session:
+109
View File
@@ -0,0 +1,109 @@
"""Email service for sending notifications via SMTP."""
import logging
from email.message import EmailMessage
import aiosmtplib
from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.setting import Setting
from fabledassistant.models.user import User
logger = logging.getLogger(__name__)
SMTP_SETTING_KEYS = [
"smtp_host",
"smtp_port",
"smtp_username",
"smtp_password",
"smtp_from_address",
"smtp_from_name",
"smtp_use_tls",
]
async def get_smtp_config() -> dict[str, str]:
"""Get SMTP config from admin's settings, falling back to env vars."""
config: dict[str, str] = {
"smtp_host": Config.SMTP_HOST,
"smtp_port": str(Config.SMTP_PORT),
"smtp_username": Config.SMTP_USERNAME,
"smtp_password": Config.SMTP_PASSWORD,
"smtp_from_address": Config.SMTP_FROM_ADDRESS,
"smtp_from_name": Config.SMTP_FROM_NAME,
"smtp_use_tls": "true" if Config.SMTP_USE_TLS else "false",
}
# Override with DB settings from admin user
async with async_session() as session:
result = await session.execute(
select(Setting)
.join(User, Setting.user_id == User.id)
.where(User.role == "admin", Setting.key.in_(SMTP_SETTING_KEYS))
)
for setting in result.scalars().all():
config[setting.key] = setting.value
return config
async def is_smtp_configured() -> bool:
"""Check if SMTP is configured (has a host set)."""
config = await get_smtp_config()
return bool(config.get("smtp_host"))
async def send_email(to: str, subject: str, html_body: str) -> None:
"""Send an email via SMTP."""
config = await get_smtp_config()
host = config.get("smtp_host")
if not host:
logger.debug("SMTP not configured, skipping email to %s", to)
return
port = int(config.get("smtp_port", "587"))
username = config.get("smtp_username", "")
password = config.get("smtp_password", "")
from_address = config.get("smtp_from_address", "")
from_name = config.get("smtp_from_name", "Fabled Assistant")
use_tls = config.get("smtp_use_tls", "true") == "true"
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = f"{from_name} <{from_address}>" if from_name else from_address
msg["To"] = to
msg.set_content(subject) # plain text fallback
msg.add_alternative(html_body, subtype="html")
try:
await aiosmtplib.send(
msg,
hostname=host,
port=port,
username=username or None,
password=password or None,
start_tls=use_tls and port != 465,
use_tls=port == 465,
)
logger.info("Email sent to %s: %s", to, subject)
except Exception:
logger.exception("Failed to send email to %s: %s", to, subject)
raise
async def send_test_email(to: str) -> None:
"""Send a branded test email."""
html = """
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
<h1 style="margin: 0; font-size: 20px;">Fabled Assistant</h1>
</div>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 12px; color: #374151;">Your SMTP configuration is working correctly.</p>
<p style="margin: 0; color: #6b7280; font-size: 14px;">This is a test email sent from your Fabled Assistant instance.</p>
</div>
</div>
"""
await send_email(to, "Fabled Assistant - Test Email", html)
+7 -2
View File
@@ -72,14 +72,19 @@ async def ensure_model(model: str) -> None:
async def stream_chat(
messages: list[dict], model: str
messages: list[dict],
model: str,
options: dict | None = None,
) -> AsyncGenerator[str, None]:
"""Stream chat completion from Ollama, yielding content chunks."""
payload: dict = {"model": model, "messages": messages, "stream": True}
if options:
payload["options"] = options
async with httpx.AsyncClient(timeout=httpx.Timeout(300.0, connect=10.0)) as client:
async with client.stream(
"POST",
f"{Config.OLLAMA_URL}/api/chat",
json={"model": model, "messages": messages, "stream": True},
json=payload,
) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
+177
View File
@@ -0,0 +1,177 @@
"""Application logging service for audit, usage, and error events."""
import asyncio
import json
import logging
import traceback as tb_module
from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select, text
from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.app_log import AppLog
logger = logging.getLogger(__name__)
_retention_task: asyncio.Task | None = None
async def log_audit(
action: str,
user_id: int | None = None,
username: str | None = None,
ip_address: str | None = None,
details: dict | None = None,
) -> None:
async with async_session() as session:
log = AppLog(
category="audit",
user_id=user_id,
username=username,
action=action,
ip_address=ip_address,
details=json.dumps(details) if details else None,
)
session.add(log)
await session.commit()
async def log_usage(
user_id: int | None = None,
username: str | None = None,
endpoint: str | None = None,
method: str | None = None,
status_code: int | None = None,
duration_ms: float | None = None,
) -> None:
async with async_session() as session:
log = AppLog(
category="usage",
user_id=user_id,
username=username,
endpoint=endpoint,
method=method,
status_code=status_code,
duration_ms=duration_ms,
)
session.add(log)
await session.commit()
async def log_error(
user_id: int | None = None,
username: str | None = None,
endpoint: str | None = None,
method: str | None = None,
error_type: str | None = None,
error_message: str | None = None,
traceback: str | None = None,
) -> None:
details = {}
if error_type:
details["error_type"] = error_type
if error_message:
details["error_message"] = error_message
if traceback:
details["traceback"] = traceback
async with async_session() as session:
log = AppLog(
category="error",
user_id=user_id,
username=username,
endpoint=endpoint,
method=method,
details=json.dumps(details) if details else None,
)
session.add(log)
await session.commit()
async def get_logs(
category: str | None = None,
user_id: int | None = None,
search: str | None = None,
date_from: str | None = None,
date_to: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[dict], int]:
async with async_session() as session:
query = select(AppLog)
count_query = select(func.count(AppLog.id))
if category:
query = query.where(AppLog.category == category)
count_query = count_query.where(AppLog.category == category)
if user_id is not None:
query = query.where(AppLog.user_id == user_id)
count_query = count_query.where(AppLog.user_id == user_id)
if search:
pattern = f"%{search}%"
search_filter = (
AppLog.action.ilike(pattern)
| AppLog.endpoint.ilike(pattern)
| AppLog.username.ilike(pattern)
| AppLog.details.ilike(pattern)
)
query = query.where(search_filter)
count_query = count_query.where(search_filter)
if date_from:
query = query.where(AppLog.created_at >= date_from)
count_query = count_query.where(AppLog.created_at >= date_from)
if date_to:
query = query.where(AppLog.created_at <= date_to)
count_query = count_query.where(AppLog.created_at <= date_to)
total = (await session.execute(count_query)).scalar() or 0
query = query.order_by(AppLog.created_at.desc()).limit(limit).offset(offset)
result = await session.execute(query)
logs = [row.to_dict() for row in result.scalars().all()]
return logs, total
async def get_log_stats() -> dict:
async with async_session() as session:
result = await session.execute(
text(
"SELECT category, COUNT(*) as count FROM app_logs GROUP BY category"
)
)
stats = {row.category: row.count for row in result}
return {
"audit": stats.get("audit", 0),
"usage": stats.get("usage", 0),
"error": stats.get("error", 0),
"total": sum(stats.values()),
}
async def delete_old_logs(retention_days: int) -> int:
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
async with async_session() as session:
result = await session.execute(
delete(AppLog).where(AppLog.created_at < cutoff)
)
await session.commit()
return result.rowcount
async def _retention_loop() -> None:
while True:
await asyncio.sleep(3600) # hourly
try:
deleted = await delete_old_logs(Config.LOG_RETENTION_DAYS)
if deleted:
logger.info("Log retention: deleted %d old log entries", deleted)
except Exception:
logger.exception("Error in log retention cleanup")
def start_log_retention_loop() -> None:
global _retention_task
if _retention_task is None or _retention_task.done():
_retention_task = asyncio.create_task(_retention_loop())
@@ -0,0 +1,201 @@
"""Notification service for security alerts and task reminders."""
import asyncio
import json
import logging
from datetime import date, datetime, timezone
from sqlalchemy import func, select, text
from fabledassistant.models import async_session
from fabledassistant.models.app_log import AppLog
from fabledassistant.models.note import Note
from fabledassistant.models.setting import Setting
from fabledassistant.models.user import User
from fabledassistant.services.email import is_smtp_configured, send_email
from fabledassistant.services.logging import log_audit
logger = logging.getLogger(__name__)
_notification_task: asyncio.Task | None = None
SECURITY_EVENT_LABELS = {
"login": "New Login",
"login_failed": "Failed Login Attempt",
"logout": "Logout",
"password_change": "Password Changed",
}
async def _get_user_notification_pref(user_id: int, key: str) -> bool:
"""Check if a user has a notification preference enabled (default True)."""
async with async_session() as session:
result = await session.execute(
select(Setting).where(Setting.user_id == user_id, Setting.key == key)
)
setting = result.scalar_one_or_none()
# Default to enabled
return setting.value != "false" if setting else True
async def _get_user_email(user_id: int) -> str | None:
"""Get a user's email address."""
async with async_session() as session:
user = await session.get(User, user_id)
return user.email if user else None
async def notify_security_event(
user_id: int, event_type: str, details: dict | None = None
) -> None:
"""Send a security alert email to a user if they have alerts enabled and an email set."""
try:
if not await is_smtp_configured():
return
if not await _get_user_notification_pref(user_id, "notify_security_alerts"):
return
email = await _get_user_email(user_id)
if not email:
return
label = SECURITY_EVENT_LABELS.get(event_type, event_type)
ip = details.get("ip_address", "Unknown") if details else "Unknown"
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
detail_rows = ""
if details:
for k, v in details.items():
if k == "ip_address":
continue
detail_rows += f'<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">{k}</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{v}</td></tr>'
html = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
<h1 style="margin: 0; font-size: 20px;">Security Alert</h1>
</div>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; color: #374151; font-size: 16px; font-weight: 600;">{label}</p>
<table style="width: 100%; border-collapse: collapse;">
<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">Time</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{timestamp}</td></tr>
<tr><td style="padding: 6px 12px; color: #6b7280; font-size: 14px;">IP Address</td><td style="padding: 6px 12px; color: #374151; font-size: 14px;">{ip}</td></tr>
{detail_rows}
</table>
<p style="margin: 16px 0 0; color: #9ca3af; font-size: 12px;">If this wasn't you, change your password immediately.</p>
</div>
</div>
"""
await send_email(email, f"Fabled Assistant - {label}", html)
except Exception:
logger.exception("Failed to send security notification for user %d", user_id)
async def check_due_tasks() -> None:
"""Check for tasks due today and send reminder emails."""
if not await is_smtp_configured():
return
today = date.today()
today_str = today.isoformat()
async with async_session() as session:
# Find tasks due today or overdue, not done
result = await session.execute(
select(Note)
.where(
Note.status.isnot(None),
Note.status != "done",
Note.due_date <= today,
)
)
tasks = result.scalars().all()
if not tasks:
return
# Group by user
tasks_by_user: dict[int, list[Note]] = {}
for task in tasks:
if task.user_id:
tasks_by_user.setdefault(task.user_id, []).append(task)
for user_id, user_tasks in tasks_by_user.items():
try:
# Check notification pref
if not await _get_user_notification_pref(user_id, "notify_task_reminders"):
continue
email = await _get_user_email(user_id)
if not email:
continue
# Dedup: check if we already sent a reminder today
dedup_result = await session.execute(
select(func.count(AppLog.id)).where(
AppLog.action == "task_reminder",
AppLog.user_id == user_id,
AppLog.created_at >= today_str,
)
)
if (dedup_result.scalar() or 0) > 0:
continue
# Build task list HTML
task_rows = ""
for task in user_tasks:
overdue = task.due_date < today if task.due_date else False
date_color = "#ef4444" if overdue else "#374151"
date_label = f'<span style="color: {date_color};">{task.due_date.isoformat()}</span>' if task.due_date else ""
overdue_badge = ' <span style="color: #ef4444; font-weight: 600; font-size: 12px;">(overdue)</span>' if overdue else ""
task_rows += f"""
<tr>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; color: #374151; font-size: 14px;">{task.title}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; font-size: 14px;">{date_label}{overdue_badge}</td>
<td style="padding: 8px 12px; border-bottom: 1px solid #e5e7eb; color: #6b7280; font-size: 14px;">{task.status}</td>
</tr>
"""
html = f"""
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; max-width: 560px; margin: 0 auto; padding: 24px;">
<div style="background: #6366f1; color: #fff; padding: 16px 24px; border-radius: 8px 8px 0 0; text-align: center;">
<h1 style="margin: 0; font-size: 20px;">Task Reminders</h1>
</div>
<div style="background: #f9fafb; padding: 24px; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 16px; color: #374151;">You have {len(user_tasks)} task(s) due:</p>
<table style="width: 100%; border-collapse: collapse;">
<tr style="background: #f3f4f6;">
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Task</th>
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Due</th>
<th style="padding: 8px 12px; text-align: left; font-size: 13px; color: #6b7280;">Status</th>
</tr>
{task_rows}
</table>
</div>
</div>
"""
await send_email(email, f"Fabled Assistant - {len(user_tasks)} Task(s) Due", html)
await log_audit(
"task_reminder",
user_id=user_id,
details={"task_count": len(user_tasks), "task_ids": [t.id for t in user_tasks]},
)
logger.info("Sent task reminder to user %d (%d tasks)", user_id, len(user_tasks))
except Exception:
logger.exception("Failed to send task reminder for user %d", user_id)
async def _notification_loop() -> None:
while True:
await asyncio.sleep(3600) # hourly
try:
await check_due_tasks()
except Exception:
logger.exception("Error in notification loop")
def start_notification_loop() -> None:
global _notification_task
if _notification_task is None or _notification_task.done():
_notification_task = asyncio.create_task(_notification_loop())