feat: comprehensive code review fixes + receivers-only architecture

Security:
- Refuse startup with default secret_key in production (was just logging)
- Settings endpoint now requires admin role
- Password validation on initial setup
- DOM-based HTML sanitizer replaces regex in template previews
- Add *.log to .gitignore

Performance & reliability:
- Token refresh deduplication prevents race condition on concurrent 401s
- Theme media query listener registered once (no leak)
- IconPicker uses $derived instead of function call per render
- Snackbar uses single-batch state update instead of while loop
- Replace 11 inline hover handlers with CSS :hover in layout

Architecture - receivers-only:
- Delivery endpoints (chat_id, email, url, room_id, topic) now stored
  exclusively in TargetReceiver rows, never in target.config
- Migration extracts existing delivery fields to receiver rows
- Notifier and dispatcher remove all config fallbacks
- Frontend targets page shows receivers list per target with
  add/remove/toggle/test per receiver
- Single-receiver test endpoint: POST /targets/{id}/receivers/{id}/test

Code quality:
- Extract AuthLayout.svelte from login/setup (150 lines CSS dedup)
- Split telegram-bots page (754→51 lines + 3 tab components)
- Split notification-trackers page (547→432 lines + 4 components)
- Deduplicate _send_reply into shared handler.send_reply()
- Add locale column to template models, replace name-based detection
- Fix delete_notification_tracker dead protection check
- Fix check_telegram_bot query (filter by type, remove bogus OR)
- Add graceful scheduler shutdown in lifespan
- Consistent /bots?tab=telegram URLs across all nav links

i18n:
- Error page, chat actions, target types, provider types internationalized
- All new receiver UI strings in EN + RU
This commit is contained in:
2026-03-22 02:19:31 +03:00
parent b525e3e7f4
commit 751097b347
43 changed files with 2584 additions and 1685 deletions
+175
View File
@@ -0,0 +1,175 @@
<script lang="ts">
import { api } from '$lib/api';
import { t } from '$lib/i18n';
import { emailBotsCache } from '$lib/stores/caches.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import Card from '$lib/components/Card.svelte';
import IconPicker from '$lib/components/IconPicker.svelte';
import MdiIcon from '$lib/components/MdiIcon.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import ConfirmModal from '$lib/components/ConfirmModal.svelte';
import IconButton from '$lib/components/IconButton.svelte';
import { snackSuccess, snackError } from '$lib/stores/snackbar.svelte';
import type { EmailBot } from '$lib/types';
let { onreload }: { onreload: () => Promise<void> } = $props();
let emailBots = $derived(emailBotsCache.items);
let showEmailForm = $state(false);
let editingEmail = $state<number | null>(null);
let emailSubmitting = $state(false);
let emailTesting = $state<Record<number, boolean>>({});
let confirmDeleteEmail = $state<any>(null);
let error = $state('');
const defaultEmailForm = () => ({
name: '', icon: '', email: '', smtp_host: '', smtp_port: 587,
smtp_username: '', smtp_password: '', smtp_use_tls: true,
});
let emailForm = $state(defaultEmailForm());
function openNewEmail() { emailForm = defaultEmailForm(); editingEmail = null; showEmailForm = true; }
function editEmailBot(bot: EmailBot) {
emailForm = {
name: bot.name, icon: bot.icon || '', email: bot.email,
smtp_host: bot.smtp_host, smtp_port: bot.smtp_port,
smtp_username: bot.smtp_username, smtp_password: '',
smtp_use_tls: bot.smtp_use_tls,
};
editingEmail = bot.id; showEmailForm = true;
}
async function saveEmailBot(e: SubmitEvent) {
e.preventDefault(); error = ''; emailSubmitting = true;
try {
const body = { ...emailForm };
if (editingEmail) {
if (!body.smtp_password) delete (body as any).smtp_password;
await api(`/email-bots/${editingEmail}`, { method: 'PUT', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotUpdated'));
} else {
await api('/email-bots', { method: 'POST', body: JSON.stringify(body) });
snackSuccess(t('snack.emailBotCreated'));
}
emailForm = defaultEmailForm(); showEmailForm = false; editingEmail = null; await onreload();
} catch (err: any) { error = err.message; snackError(err.message); }
finally { emailSubmitting = false; }
}
function removeEmail(id: number) {
confirmDeleteEmail = {
id,
onconfirm: async () => {
try { await api(`/email-bots/${id}`, { method: 'DELETE' }); await onreload(); snackSuccess(t('snack.emailBotDeleted')); }
catch (err: any) { error = err.message; snackError(err.message); }
finally { confirmDeleteEmail = null; }
}
};
}
async function testEmailBot(botId: number) {
emailTesting = { ...emailTesting, [botId]: true };
try {
const res = await api(`/email-bots/${botId}/test`, { method: 'POST' });
if (res.success) snackSuccess(t('snack.emailBotTestSent'));
else snackError(res.error || 'Failed');
} catch (err: any) { snackError(err.message); }
emailTesting = { ...emailTesting, [botId]: false };
}
</script>
<PageHeader title={t('emailBot.title')} description={t('emailBot.description')}>
<button onclick={() => { showEmailForm ? (showEmailForm = false, editingEmail = null) : openNewEmail(); }}
class="px-3 py-1.5 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90">
{showEmailForm ? t('common.cancel') : t('emailBot.addBot')}
</button>
</PageHeader>
{#if showEmailForm}
<Card class="mb-6">
{#if error}<div class="bg-[var(--color-error-bg)] text-[var(--color-error-fg)] text-sm rounded-md p-3 mb-4">{error}</div>{/if}
<form onsubmit={saveEmailBot} class="space-y-3">
<div>
<label for="ebot-name" class="block text-sm font-medium mb-1">{t('emailBot.name')}</label>
<div class="flex gap-2">
<IconPicker value={emailForm.icon} onselect={(v: string) => emailForm.icon = v} />
<input id="ebot-name" bind:value={emailForm.name} required placeholder={t('emailBot.namePlaceholder')}
class="flex-1 px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div>
<label for="ebot-email" class="block text-sm font-medium mb-1">{t('emailBot.email')}</label>
<input id="ebot-email" bind:value={emailForm.email} required type="email" placeholder="notify@example.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="ebot-host" class="block text-sm font-medium mb-1">{t('emailBot.smtpHost')}</label>
<input id="ebot-host" bind:value={emailForm.smtp_host} required placeholder="smtp.gmail.com"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="ebot-port" class="block text-sm font-medium mb-1">{t('emailBot.smtpPort')}</label>
<input id="ebot-port" bind:value={emailForm.smtp_port} type="number" min="1" max="65535"
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="ebot-user" class="block text-sm font-medium mb-1">{t('emailBot.smtpUsername')}</label>
<input id="ebot-user" bind:value={emailForm.smtp_username} placeholder={t('emailBot.smtpUsernamePlaceholder')}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
<div>
<label for="ebot-pass" class="block text-sm font-medium mb-1">{t('emailBot.smtpPassword')}</label>
<input id="ebot-pass" bind:value={emailForm.smtp_password} type="password" placeholder={editingEmail ? t('emailBot.passwordUnchanged') : ''}
class="w-full px-3 py-2 border border-[var(--color-border)] rounded-md text-sm bg-[var(--color-background)]" />
</div>
</div>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input type="checkbox" bind:checked={emailForm.smtp_use_tls} />
{t('emailBot.useTls')}
</label>
<button type="submit" disabled={emailSubmitting}
class="px-4 py-2 bg-[var(--color-primary)] text-[var(--color-primary-foreground)] rounded-md text-sm font-medium hover:opacity-90 disabled:opacity-50">
{emailSubmitting ? t('common.loading') : (editingEmail ? t('common.save') : t('emailBot.addBot'))}
</button>
</form>
</Card>
{/if}
{#if emailBots.length === 0 && !showEmailForm}
<Card>
<EmptyState icon="mdiEmailOutline" message={t('emailBot.noBots')} />
</Card>
{:else}
<div class="space-y-3 stagger-children">
{#each emailBots as bot}
<Card hover entityId={bot.id}>
<div class="flex items-center justify-between">
<div>
<div class="flex items-center gap-2">
<span style="color: var(--color-primary);"><MdiIcon name={bot.icon || 'mdiEmailOutline'} size={20} /></span>
<p class="font-medium">{bot.name}</p>
</div>
<div class="flex items-center gap-2 mt-1 flex-wrap">
<span class="text-xs text-[var(--color-muted-foreground)] font-mono">{bot.email}</span>
<span class="text-xs px-1.5 py-0.5 rounded bg-[var(--color-muted)] text-[var(--color-muted-foreground)]">{bot.smtp_host}:{bot.smtp_port}</span>
{#if bot.smtp_use_tls}
<span class="text-xs px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-500">TLS</span>
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<IconButton icon="mdiEmailSend" title={t('emailBot.testConnection')} onclick={() => testEmailBot(bot.id)} disabled={emailTesting[bot.id]} />
<IconButton icon="mdiPencil" title={t('common.edit')} onclick={() => editEmailBot(bot)} />
<IconButton icon="mdiDelete" title={t('common.delete')} onclick={() => removeEmail(bot.id)} variant="danger" />
</div>
</div>
</Card>
{/each}
</div>
{/if}
<ConfirmModal open={confirmDeleteEmail !== null} message={t('emailBot.confirmDelete')}
onconfirm={() => confirmDeleteEmail?.onconfirm()} oncancel={() => confirmDeleteEmail = null} />