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
+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>