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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user