feat: SMTP email notification channel with UI and ingest

Migration 006, email config DB/env, smtplib sender, settings API,
Settings UI section, and dispatch on event/problem ingest.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
PTah
2026-05-29 16:12:55 +10:00
parent 20fa7e2c27
commit 9b177c145b
13 changed files with 945 additions and 195 deletions
+175 -1
View File
@@ -117,6 +117,74 @@
</form>
<p class="settings-hint">JSON POST: <code>kind</code> = event | problem | test; те же пороги severity, что у Telegram.</p>
</section>
<section class="card settings-card">
<h2>Email (SMTP)</h2>
<form class="settings-form" @submit.prevent="saveEmail">
<label class="settings-field">
<input v-model="emailForm.enabled" type="checkbox" />
Включить email
</label>
<label class="settings-field">
Мин. severity
<select v-model="emailForm.min_severity">
<option value="info">info</option>
<option value="warning">warning</option>
<option value="high">high</option>
<option value="critical">critical</option>
</select>
</label>
<label class="settings-field">
SMTP host
<input v-model="emailForm.smtp_host" type="text" autocomplete="off" :placeholder="smtpHostPlaceholder" />
</label>
<label class="settings-field">
SMTP port
<input v-model.number="emailForm.smtp_port" type="number" min="1" max="65535" />
</label>
<label class="settings-field">
SMTP user
<input v-model="emailForm.smtp_user" type="text" autocomplete="off" />
</label>
<label class="settings-field">
SMTP password
<input
v-model="emailForm.smtp_password"
type="password"
autocomplete="off"
:placeholder="smtpPasswordPlaceholder"
/>
</label>
<label class="settings-field">
From
<input v-model="emailForm.mail_from" type="email" autocomplete="off" />
</label>
<label class="settings-field">
To (через запятую или ;)
<input v-model="emailForm.mail_to" type="text" autocomplete="off" :placeholder="mailToPlaceholder" />
</label>
<label class="settings-field settings-inline">
<input v-model="emailForm.smtp_starttls" type="checkbox" />
STARTTLS
</label>
<label class="settings-field settings-inline">
<input v-model="emailForm.smtp_ssl" type="checkbox" />
SSL (порт 465)
</label>
<p v-if="emailLoaded" class="settings-meta">
Источник: <code>{{ emailLoaded.source }}</code>
· настроен: {{ emailLoaded.configured ? "да" : "нет" }}
</p>
<div class="settings-actions">
<button type="submit" :disabled="savingEmail">
{{ savingEmail ? "Сохранение…" : "Сохранить" }}
</button>
<button type="button" class="secondary" :disabled="testingEmail" @click="testEmail">
{{ testingEmail ? "Отправка" : "Проверить email" }}
</button>
</div>
</form>
</section>
</template>
</div>
</template>
@@ -144,15 +212,33 @@ interface WebhookSettings {
source: string;
}
interface EmailSettings {
enabled: boolean;
configured: boolean;
smtp_host_hint: string | null;
smtp_port: number;
smtp_user: string | null;
smtp_password_hint: string | null;
mail_from: string | null;
mail_to_hint: string | null;
smtp_starttls: boolean;
smtp_ssl: boolean;
min_severity: string;
source: string;
}
const loading = ref(true);
const savingTelegram = ref(false);
const savingWebhook = ref(false);
const savingEmail = ref(false);
const testingTelegram = ref(false);
const testingWebhook = ref(false);
const testingEmail = ref(false);
const error = ref("");
const success = ref("");
const telegramLoaded = ref<TelegramSettings | null>(null);
const webhookLoaded = ref<WebhookSettings | null>(null);
const emailLoaded = ref<EmailSettings | null>(null);
const telegramForm = reactive({
enabled: false,
@@ -169,6 +255,19 @@ const webhookForm = reactive({
secret: "",
});
const emailForm = reactive({
enabled: false,
min_severity: "warning",
smtp_host: "",
smtp_port: 587,
smtp_user: "",
smtp_password: "",
mail_from: "",
mail_to: "",
smtp_starttls: true,
smtp_ssl: false,
});
const tokenPlaceholder = computed(() =>
telegramLoaded.value?.bot_token_hint
? `оставить ${telegramLoaded.value.bot_token_hint}`
@@ -185,16 +284,26 @@ const urlPlaceholder = computed(() =>
const webhookSecretPlaceholder = computed(() =>
webhookLoaded.value?.secret_hint ? `оставить ${webhookLoaded.value.secret_hint}` : "необязательно",
);
const smtpHostPlaceholder = computed(() =>
emailLoaded.value?.smtp_host_hint ? `оставить ${emailLoaded.value.smtp_host_hint}` : "smtp.example.com",
);
const smtpPasswordPlaceholder = computed(() =>
emailLoaded.value?.smtp_password_hint ? `оставить ${emailLoaded.value.smtp_password_hint}` : "необязательно",
);
const mailToPlaceholder = computed(() =>
emailLoaded.value?.mail_to_hint ? `оставить ${emailLoaded.value.mail_to_hint}` : "admin@example.com",
);
async function load() {
loading.value = true;
error.value = "";
try {
const data = await apiFetch<{ telegram: TelegramSettings; webhook: WebhookSettings }>(
const data = await apiFetch<{ telegram: TelegramSettings; webhook: WebhookSettings; email: EmailSettings }>(
"/api/v1/settings/notifications",
);
telegramLoaded.value = data.telegram;
webhookLoaded.value = data.webhook;
emailLoaded.value = data.email;
telegramForm.enabled = data.telegram.enabled;
telegramForm.min_severity = data.telegram.min_severity;
telegramForm.bot_token = "";
@@ -204,6 +313,16 @@ async function load() {
webhookForm.url = "";
webhookForm.secret_header = data.webhook.secret_header ?? "";
webhookForm.secret = "";
emailForm.enabled = data.email.enabled;
emailForm.min_severity = data.email.min_severity;
emailForm.smtp_host = "";
emailForm.smtp_port = data.email.smtp_port || 587;
emailForm.smtp_user = data.email.smtp_user ?? "";
emailForm.smtp_password = "";
emailForm.mail_from = data.email.mail_from ?? "";
emailForm.mail_to = "";
emailForm.smtp_starttls = data.email.smtp_starttls;
emailForm.smtp_ssl = data.email.smtp_ssl;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка загрузки";
} finally {
@@ -296,6 +415,55 @@ async function testWebhook() {
}
}
async function saveEmail() {
savingEmail.value = true;
error.value = "";
success.value = "";
try {
const body: Record<string, unknown> = {
enabled: emailForm.enabled,
min_severity: emailForm.min_severity,
smtp_port: emailForm.smtp_port,
smtp_starttls: emailForm.smtp_starttls,
smtp_ssl: emailForm.smtp_ssl,
};
if (emailForm.smtp_host.trim()) body.smtp_host = emailForm.smtp_host.trim();
if (emailForm.smtp_user.trim()) body.smtp_user = emailForm.smtp_user.trim();
if (emailForm.smtp_password.trim()) body.smtp_password = emailForm.smtp_password.trim();
if (emailForm.mail_from.trim()) body.mail_from = emailForm.mail_from.trim();
if (emailForm.mail_to.trim()) body.mail_to = emailForm.mail_to.trim();
emailLoaded.value = await apiFetch<EmailSettings>("/api/v1/settings/notifications/email", {
method: "PUT",
body: JSON.stringify(body),
});
emailForm.smtp_host = "";
emailForm.smtp_password = "";
emailForm.mail_to = "";
success.value = "Настройки email сохранены.";
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка сохранения";
} finally {
savingEmail.value = false;
}
}
async function testEmail() {
testingEmail.value = true;
error.value = "";
success.value = "";
try {
const res = await apiFetch<{ message: string }>("/api/v1/settings/notifications/email/test", {
method: "POST",
});
success.value = res.message;
} catch (e) {
error.value = e instanceof Error ? e.message : "Ошибка отправки";
} finally {
testingEmail.value = false;
}
}
onMounted(load);
</script>
@@ -337,6 +505,12 @@ onMounted(load);
max-width: 28rem;
}
.settings-inline {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.settings-meta {
font-size: 0.85rem;
color: #9aa4b2;