Files
minicrm/email-notifications.js
2026-01-28 16:43:32 +05:00

1409 lines
67 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const nodemailer = require('nodemailer');
const { getDb } = require('./database');
class EmailNotifications {
constructor() {
this.transporters = [];
this.activeTransporterIndex = 0;
this.initialized = false;
this.notificationCooldown = 12 * 60 * 60 * 1000; // 12 часов
this.spamBlockCooldown = 15 * 60 * 1000; // 15 минут при блокировке спама
this.spamBlockedUntil = null;
this.isSpamBlocked = false;
this.transporterNames = ['Yandex', 'SMTP1', 'SMTP2'];
// Новая переменная для игнорирования спам-блокировки
this.ignoreSpamBlock = process.env.IGNORE_SPAM_BLOCK === 'true';
if (this.ignoreSpamBlock) {
console.log('🔓 Режим игнорирования спам-блокировки включен');
console.log(' При детекции спама будет только ротация SMTP без глобальной блокировки');
}
this.init();
}
async init() {
try {
console.log('🔧 Инициализация Email уведомлений с несколькими SMTP...');
// Проверяем и инициализируем все SMTP аккаунты
const smtpConfigs = this.getSmtpConfigs();
if (smtpConfigs.length === 0) {
console.warn('⚠️ Настройки SMTP не указаны в .env');
console.warn(' Email уведомления будут отключены');
this.initialized = false;
return;
}
console.log(`📧 Найдено ${smtpConfigs.length} SMTP конфигураций`);
// Создаем транспортеры для каждого SMTP
this.transporters = [];
let successfulTransports = 0;
for (const config of smtpConfigs) {
try {
const transporter = nodemailer.createTransport({
host: config.host,
port: config.port,
secure: config.secure,
auth: {
user: config.user,
pass: config.password
},
tls: {
rejectUnauthorized: false
}
});
// Тестируем подключение
await transporter.verify();
this.transporters.push({
name: config.name,
transporter: transporter,
email: config.user,
host: config.host,
status: 'active',
port: config.port,
secure: config.secure,
configSource: config.source
});
successfulTransports++;
console.log(`${config.name} инициализирован: ${config.user}@${config.host}:${config.port}`);
} catch (error) {
console.warn(`⚠️ ${config.name} не доступен: ${error.message}`);
console.log(` Конфигурация: ${config.user}@${config.host}:${config.port}`);
}
}
if (this.transporters.length === 0) {
console.error('❌ Все SMTP серверы недоступны');
console.log(' Проверьте настройки в .env файле:');
console.log(' - YANDEX_EMAIL, YANDEX_PASSWORD');
console.log(' - EMAIL_LOGIN, EMAIL_PASSWORD, EMAIL_HOST, EMAIL_PORT');
console.log(' - EMAIL2_LOGIN, EMAIL2_PASSWORD, EMAIL2_HOST, EMAIL2_PORT');
this.initialized = false;
return;
}
this.initialized = true;
// Восстанавливаем состояние блокировки из БД
await this.restoreSpamBlockState();
console.log(`✅ Email уведомления инициализированы с ${this.transporters.length} SMTP серверами`);
console.log(`📧 Активный SMTP: ${this.transporters[this.activeTransporterIndex].name} (${this.transporters[this.activeTransporterIndex].email})`);
console.log(`🔓 Режим игнорирования спам-блокировки: ${this.ignoreSpamBlock ? 'ВКЛЮЧЕН' : 'ВЫКЛЮЧЕН'}`);
// Выводим статус всех SMTP
this.printSmtpStatus();
if (this.isSpamBlocked && this.spamBlockedUntil) {
const now = new Date();
if (now < this.spamBlockedUntil) {
const minutesLeft = Math.ceil((this.spamBlockedUntil - now) / (60 * 1000));
console.log(`⏸️ Email отправка заблокирована из-за спама. До разблокировки: ${minutesLeft} минут`);
} else {
await this.clearSpamBlock();
}
}
} catch (error) {
console.error('❌ Ошибка инициализации Email уведомлений:', error.message);
console.error(error.stack);
this.initialized = false;
}
}
// Получаем все SMTP конфигурации из env
getSmtpConfigs() {
const configs = [];
// Яндекс SMTP
if (process.env.YANDEX_EMAIL && process.env.YANDEX_PASSWORD) {
configs.push({
name: 'Yandex',
user: process.env.YANDEX_EMAIL,
password: process.env.YANDEX_PASSWORD,
host: process.env.YANDEX_SMTP_HOST || 'smtp.yandex.ru',
port: parseInt(process.env.YANDEX_SMTP_PORT) || 587,
secure: process.env.YANDEX_SMTP_SECURE === 'true',
source: 'YANDEX'
});
}
// SMTP 1
if (process.env.EMAIL_LOGIN && process.env.EMAIL_PASSWORD) {
configs.push({
name: 'SMTP1',
user: process.env.EMAIL_LOGIN,
password: process.env.EMAIL_PASSWORD,
host: process.env.EMAIL_HOST || 'smtp.gmail.com',
port: parseInt(process.env.EMAIL_PORT) || 587,
secure: process.env.EMAIL_SECURE === 'true',
source: 'EMAIL'
});
}
// SMTP 2
if (process.env.EMAIL2_LOGIN && process.env.EMAIL2_PASSWORD) {
configs.push({
name: 'SMTP2',
user: process.env.EMAIL2_LOGIN,
password: process.env.EMAIL2_PASSWORD,
host: process.env.EMAIL2_HOST || 'smtp.gmail.com',
port: parseInt(process.env.EMAIL2_PORT) || 587,
secure: process.env.EMAIL2_SECURE === 'true',
source: 'EMAIL2'
});
}
return configs;
}
// Выводим статус всех SMTP
printSmtpStatus() {
console.log('\n📊 Статус SMTP серверов:');
console.log('─'.repeat(80));
console.log('│ № │ Название │ Email │ Хост │ Порт │ Статус │');
console.log('├────┼──────────┼───────┼──────┼──────┼────────┤');
this.transporters.forEach((smtp, index) => {
const isActive = index === this.activeTransporterIndex ? ' ✅' : '';
console.log(`${(index + 1).toString().padEnd(2)}${smtp.name.padEnd(8)}${smtp.email.substring(0, 15).padEnd(15)}${smtp.host.substring(0, 15).padEnd(15)}${smtp.port.toString().padEnd(4)}${smtp.status.padEnd(6)}${isActive}`);
});
console.log('─'.repeat(80));
// Показываем конфигурации, которые не были добавлены
const allConfigs = this.getSmtpConfigs();
const missingConfigs = allConfigs.filter(config =>
!this.transporters.some(t => t.email === config.user)
);
if (missingConfigs.length > 0) {
console.log('\n⚠ Следующие конфигурации не были добавлены (ошибка инициализации):');
missingConfigs.forEach(config => {
console.log(`${config.name}: ${config.user}@${config.host}:${config.port}`);
});
}
}
getActiveTransporter() {
if (this.transporters.length === 0) {
return null;
}
if (this.activeTransporterIndex >= this.transporters.length) {
this.activeTransporterIndex = 0;
}
return this.transporters[this.activeTransporterIndex];
}
rotateTransporter() {
if (this.transporters.length <= 1) {
return false;
}
const oldIndex = this.activeTransporterIndex;
const originalIndex = oldIndex;
// Ищем следующий активный SMTP
let attempts = 0;
while (attempts < this.transporters.length) {
this.activeTransporterIndex = (this.activeTransporterIndex + 1) % this.transporters.length;
const nextTransporter = this.transporters[this.activeTransporterIndex];
// Пропускаем failed SMTP
if (nextTransporter.status === 'failed') {
attempts++;
continue;
}
// Если следующий SMTP активен, используем его
if (nextTransporter.status === 'active') {
console.log(`🔄 Смена SMTP сервера: ${this.transporters[oldIndex].name}${nextTransporter.name}`);
return true;
}
attempts++;
}
// Если все SMTP не активны или failed, ищем любой не-failed
for (let i = 0; i < this.transporters.length; i++) {
if (this.transporters[i].status !== 'failed') {
this.activeTransporterIndex = i;
console.log(`⚠️ Все активные SMTP не работают, переключение на ${this.transporters[i].name}`);
return true;
}
}
// Если все SMTP failed
this.activeTransporterIndex = 0;
console.log(`Все SMTP помечены как failed`);
return false;
}
markTransporterAsFailed(reason = 'unknown') {
if (this.transporters.length === 0) {
return false;
}
const current = this.transporters[this.activeTransporterIndex];
current.status = 'failed';
current.failureReason = reason;
current.failedAt = new Date();
console.log(`❌ SMTP ${current.name} помечен как нерабочий: ${reason}`);
return true;
}
async restoreSpamBlockState() {
if (!getDb) return;
try {
const db = getDb();
return new Promise((resolve, reject) => {
db.get(
`SELECT spam_blocked_until FROM email_settings WHERE setting_key = 'spam_block'`,
(err, row) => {
if (err) {
console.error('❌ Ошибка получения состояния блокировки:', err);
resolve();
return;
}
if (row && row.spam_blocked_until) {
const blockedUntil = new Date(row.spam_blocked_until);
const now = new Date();
if (now < blockedUntil) {
this.isSpamBlocked = true;
this.spamBlockedUntil = blockedUntil;
console.log(`🔄 Восстановлена блокировка из-за спама до: ${blockedUntil.toLocaleString('ru-RU')}`);
} else {
// Автоматически очищаем истекшую блокировку
this.clearSpamBlockFromDB();
}
}
resolve();
}
);
});
} catch (error) {
console.error('❌ Ошибка восстановления состояния блокировки:', error);
}
}
async setSpamBlock() {
// Если включен режим игнорирования, не устанавливаем блокировку
if (this.ignoreSpamBlock) {
console.log('🔓 Режим игнорирования спам-блокировки активен, глобальная блокировка не устанавливается');
return;
}
this.isSpamBlocked = true;
this.spamBlockedUntil = new Date(Date.now() + this.spamBlockCooldown);
console.log(`🚫 Email отправка заблокирована из-за спама до: ${this.spamBlockedUntil.toLocaleString('ru-RU')}`);
// Сохраняем в БД
await this.saveSpamBlockToDB();
}
async saveSpamBlockToDB() {
if (!getDb || this.ignoreSpamBlock) return;
try {
const db = getDb();
return new Promise((resolve, reject) => {
db.run(
`INSERT OR REPLACE INTO email_settings (setting_key, setting_value, spam_blocked_until, updated_at)
VALUES ('spam_block', 'blocked', ?, CURRENT_TIMESTAMP)`,
[this.spamBlockedUntil.toISOString()],
(err) => {
if (err) {
console.error('❌ Ошибка сохранения блокировки в БД:', err);
reject(err);
} else {
console.log('✅ Состояние блокировки сохранено в БД');
resolve();
}
}
);
});
} catch (error) {
console.error('❌ Ошибка сохранения блокировки:', error);
}
}
async clearSpamBlock() {
this.isSpamBlocked = false;
this.spamBlockedUntil = null;
console.log('✅ Блокировка из-за спама снята');
// Очищаем из БД
await this.clearSpamBlockFromDB();
}
async clearSpamBlockFromDB() {
if (!getDb) return;
try {
const db = getDb();
return new Promise((resolve, reject) => {
db.run(
`DELETE FROM email_settings WHERE setting_key = 'spam_block'`,
(err) => {
if (err) {
console.error('❌ Ошибка очистки блокировки из БД:', err);
reject(err);
} else {
console.log('✅ Состояние блокировки очищено из БД');
resolve();
}
}
);
});
} catch (error) {
console.error('❌ Ошибка очистки блокировки:', error);
}
}
isSpamBlockActive() {
// Если режим игнорирования включен, блокировка никогда не активна
if (this.ignoreSpamBlock) {
return false;
}
if (!this.isSpamBlocked || !this.spamBlockedUntil) {
return false;
}
const now = new Date();
if (now >= this.spamBlockedUntil) {
this.clearSpamBlock();
return false;
}
return true;
}
// Сброс всех failed статусов
resetAllFailedTransporters() {
let resetCount = 0;
this.transporters.forEach(transporter => {
if (transporter.status === 'failed') {
transporter.status = 'active';
delete transporter.failureReason;
delete transporter.failedAt;
resetCount++;
}
});
if (resetCount > 0) {
console.log(`🔄 Сброшены статусы ${resetCount} SMTP серверов (помечены как активные)`);
// Сбрасываем на первый активный
this.activeTransporterIndex = 0;
}
return resetCount;
}
// Принудительная разблокировка
async forceUnblockSpam() {
console.log('🔓 Принудительная разблокировка спам-блокировки');
this.isSpamBlocked = false;
this.spamBlockedUntil = null;
// Сбрасываем все failed SMTP
this.resetAllFailedTransporters();
// Очищаем из БД
await this.clearSpamBlockFromDB();
console.log('✅ Спам-блокировка снята, все SMTP сброшены');
return true;
}
async canSendNotification(userId, taskId, notificationType) {
if (!getDb) return true;
return new Promise((resolve, reject) => {
const db = getDb();
db.get(
`SELECT last_sent_at FROM notification_history
WHERE user_id = ? AND task_id = ? AND notification_type = ?`,
[userId, taskId, notificationType],
(err, record) => {
if (err) {
console.error('❌ Ошибка проверки истории уведомлений:', err);
resolve(true);
return;
}
if (!record) {
resolve(true);
return;
}
const lastSent = new Date(record.last_sent_at);
const now = new Date();
const timeDiff = now.getTime() - lastSent.getTime();
if (timeDiff >= this.notificationCooldown) {
resolve(true);
} else {
const hoursLeft = Math.ceil((this.notificationCooldown - timeDiff) / (60 * 60 * 1000));
console.log(`⏰ Уведомление пропущено: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
console.log(` Последнее уведомление было отправлено: ${lastSent.toLocaleString('ru-RU')}`);
console.log(` Следующее уведомление можно отправить через: ${hoursLeft} часов`);
console.log(` Время следующей отправки: ${new Date(lastSent.getTime() + this.notificationCooldown).toLocaleString('ru-RU')}`);
resolve(false);
}
}
);
});
}
async recordNotificationSent(userId, taskId, notificationType) {
if (!getDb) return;
return new Promise((resolve, reject) => {
const db = getDb();
db.run(
`INSERT OR REPLACE INTO notification_history
(user_id, task_id, notification_type, last_sent_at)
VALUES (?, ?, ?, CURRENT_TIMESTAMP)`,
[userId, taskId, notificationType],
(err) => {
if (err) {
console.error('❌ Ошибка записи истории уведомлений:', err);
reject(err);
} else {
console.log(`📝 Записана история уведомления: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
resolve();
}
}
);
});
}
async forceSendNotification(userId, taskId, notificationType) {
if (!getDb) return;
return new Promise((resolve, reject) => {
const db = getDb();
db.run(
`DELETE FROM notification_history
WHERE user_id = ? AND task_id = ? AND notification_type = ?`,
[userId, taskId, notificationType],
(err) => {
if (err) {
console.error('❌ Ошибка принудительного удаления истории:', err);
reject(err);
} else {
console.log(`♻️ История уведомления принудительно очищена: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
resolve();
}
}
);
});
}
async getUserNotificationSettings(userId) {
if (!getDb) return null;
return new Promise((resolve, reject) => {
const db = getDb();
db.get(`SELECT us.*, u.email as user_email, u.name as user_name
FROM user_settings us
LEFT JOIN users u ON us.user_id = u.id
WHERE us.user_id = ?`,
[userId], (err, settings) => {
if (err) {
reject(err);
} else {
resolve(settings);
}
});
});
}
async saveUserNotificationSettings(userId, settings) {
if (!getDb) return false;
return new Promise((resolve, reject) => {
const db = getDb();
const {
email_notifications = true,
notification_email = '',
telegram_notifications = false,
telegram_chat_id = '',
vk_notifications = false,
vk_user_id = ''
} = settings;
db.get("SELECT id FROM user_settings WHERE user_id = ?", [userId], (err, existing) => {
if (err) {
reject(err);
return;
}
if (existing) {
db.run(`UPDATE user_settings
SET email_notifications = ?,
notification_email = ?,
telegram_notifications = ?,
telegram_chat_id = ?,
vk_notifications = ?,
vk_user_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?`,
[
email_notifications ? 1 : 0,
notification_email,
telegram_notifications ? 1 : 0,
telegram_chat_id,
vk_notifications ? 1 : 0,
vk_user_id,
userId
], function(err) {
if (err) reject(err);
else resolve(true);
});
} else {
db.run(`INSERT INTO user_settings (
user_id, email_notifications, notification_email,
telegram_notifications, telegram_chat_id,
vk_notifications, vk_user_id
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[
userId,
email_notifications ? 1 : 0,
notification_email,
telegram_notifications ? 1 : 0,
telegram_chat_id,
vk_notifications ? 1 : 0,
vk_user_id
], function(err) {
if (err) reject(err);
else resolve(true);
});
}
});
});
}
async sendEmailNotification(to, subject, htmlContent, userId = null, taskId = null, notificationType = null) {
// Проверяем блокировку из-за спама, если не игнорируем
if (!this.ignoreSpamBlock && this.isSpamBlockActive()) {
const minutesLeft = Math.ceil((this.spamBlockedUntil - new Date()) / (60 * 1000));
console.log(`⏸️ Email отправка заблокирована из-за спама. Осталось: ${minutesLeft} минут`);
return { sent: false, error: 'spam_blocked', minutesLeft };
}
// Если игнорируем блокировку, но она активна - просто сообщаем
if (this.ignoreSpamBlock && this.isSpamBlockActive()) {
console.log(`🔓 Спам-блокировка активна, но игнорируется (режим IGNORE_SPAM_BLOCK=true)`);
}
if (!this.initialized || this.transporters.length === 0) {
console.warn('⚠️ Email уведомления отключены - нет доступных SMTP серверов');
return { sent: false, error: 'not_initialized' };
}
const originalTransporterIndex = this.activeTransporterIndex;
let attempts = 0;
const maxAttempts = this.transporters.length;
let spamDetectedCount = 0; // Счетчик SMTP с детекцией спама
let maxTotalAttempts = this.ignoreSpamBlock ? maxAttempts * 3 : maxAttempts; // Больше попыток при ignore режиме
while (attempts < maxTotalAttempts) {
const activeTransporter = this.getActiveTransporter();
try {
console.log(`📤 Попытка ${attempts + 1}/${maxTotalAttempts} через ${activeTransporter.name} (${activeTransporter.email})`);
const info = await activeTransporter.transporter.sendMail({
from: `"School CRM" <${activeTransporter.email}>`,
to: to,
subject: subject,
html: htmlContent,
text: htmlContent.replace(/<[^>]*>/g, '')
});
console.log(`✅ Email отправлен через ${activeTransporter.name}: ${to}, Message ID: ${info.messageId}`);
// Если это была не первая попытка, записываем в историю смены SMTP
if (attempts > 0) {
console.log(`🔄 Успешная отправка после ${attempts} неудачных попыток`);
}
return {
sent: true,
messageId: info.messageId,
smtpUsed: activeTransporter.name,
smtpEmail: activeTransporter.email,
attempts: attempts + 1
};
} catch (error) {
attempts++;
console.error(`❌ Ошибка отправки через ${activeTransporter.name}:`, error.message);
// Проверяем, является ли ошибка блокировкой спама
const isSpamError = error.message.includes('554 5.7.1 Message rejected under suspicion of SPAM') ||
error.message.includes('suspicion of SPAM') ||
error.message.includes('SPAM') ||
error.message.includes('spam');
if (isSpamError) {
spamDetectedCount++;
console.log(`⚠️ Детекция спама на ${activeTransporter.name}. Пробуем следующий SMTP...`);
// Помечаем текущий SMTP как проблемный
this.markTransporterAsFailed('spam_detected');
// НЕМЕДЛЕННО ПЕРЕКЛЮЧАЕМСЯ на следующий SMTP
this.rotateTransporter();
// Проверяем, нужно ли активировать глобальную блокировку
if (spamDetectedCount >= maxAttempts) {
if (!this.ignoreSpamBlock) {
console.log(`🚫 Спам детектирован на всех SMTP серверах. Активируем глобальную блокировку.`);
await this.setSpamBlock();
return {
sent: false,
error: 'spam_blocked_on_all_smtp',
attempts: attempts
};
} else {
console.log(`🔓 Спам детектирован на всех SMTP, но блокировка отключена. Цикл ротации.`);
// Если все SMTP помечены как failed, сбрасываем их статус
const allFailed = this.transporters.every(t => t.status === 'failed');
if (allFailed) {
console.log(`🔄 Все SMTP помечены как failed, сбрасываем статусы для повторного использования`);
this.resetAllFailedTransporters();
}
// Продолжаем попытки
if (attempts < maxTotalAttempts) {
await new Promise(resolve => setTimeout(resolve, 2000)); // Увеличиваем задержку
continue;
} else {
return {
sent: false,
error: 'all_smtp_failed_after_retries',
attempts: attempts
};
}
}
}
} else {
// Для не-спам ошибок просто переключаем SMTP
console.log(`🔄 Проблема с ${activeTransporter.name}, пробуем следующий SMTP...`);
this.rotateTransporter();
}
// Если это не последняя попытка, продолжаем
if (attempts < maxTotalAttempts) {
// Делаем паузу перед следующей попыткой
await new Promise(resolve => setTimeout(resolve, 1000));
continue;
} else {
// Все SMTP не сработали
console.log(`Все ${attempts} попыток отправки не сработали.`);
// Возвращаем активный транспортер к оригинальному
this.activeTransporterIndex = originalTransporterIndex;
return {
sent: false,
error: 'all_smtp_failed',
attempts: attempts
};
}
}
}
// Если мы здесь, значит что-то пошло не так
return { sent: false, error: 'unknown_error' };
}
async getTaskIdFromData(taskData) {
if (!taskData) {
console.error('❌ taskData is null or undefined');
return null;
}
if (taskData.id) {
return taskData.id;
}
if (taskData.task_id) {
return taskData.task_id;
}
if (taskData.taskId) {
return taskData.taskId;
}
if (taskData.assignment_id && getDb) {
console.log(`🔍 Ищу ID задачи через assignment_id: ${taskData.assignment_id}`);
try {
const db = getDb();
return await new Promise((resolve) => {
db.get("SELECT task_id FROM task_assignments WHERE id = ?",
[taskData.assignment_id],
(err, row) => {
if (err || !row) {
console.error(`Не удалось найти задачу по assignment_id ${taskData.assignment_id}:`, err?.message);
resolve(null);
} else {
console.log(`✅ Найдена задача: ${row.task_id} для assignment_id ${taskData.assignment_id}`);
resolve(row.task_id);
}
}
);
});
} catch (error) {
console.error('❌ Ошибка поиска задачи по assignment_id:', error);
return null;
}
}
console.error('❌ Не удалось определить ID задачи из данных:',
Object.keys(taskData).length > 0 ?
JSON.stringify(taskData, null, 2).substring(0, 500) : 'empty object');
return null;
}
async sendTaskNotification(userId, taskData, notificationType) {
try {
let taskId;
if (taskData.assignment_id && getDb) {
taskId = await this.getTaskIdFromData(taskData);
} else {
taskId = this.getTaskIdFromData(taskData);
}
if (!taskId) {
console.error('❌ Не удалось определить ID задачи для уведомления. Данные:', {
userId,
notificationType,
taskDataKeys: Object.keys(taskData || {}),
taskDataSample: taskData ? {
id: taskData.id,
task_id: taskData.task_id,
taskId: taskData.taskId,
assignment_id: taskData.assignment_id,
title: taskData.title
} : 'null'
});
return false;
}
console.log(`🔍 Отправка уведомления: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
const canSend = await this.canSendNotification(userId, taskId, notificationType);
if (!canSend) {
console.log(`⏰ Пропущено уведомление для пользователя ${userId}, задача ${taskId}, тип ${notificationType} (12-часовой кд)`);
return false;
}
const settings = await this.getUserNotificationSettings(userId);
if (!settings || !settings.email_notifications) {
console.log(`⚠️ Пользователь ${userId} отключил email уведомления`);
return false;
}
const emailTo = settings.notification_email || settings.user_email;
if (!emailTo) {
console.log(`⚠️ У пользователя ${userId} не указан email для уведомлений`);
return false;
}
let subject = '';
let htmlContent = '';
switch (notificationType) {
case 'created':
subject = `Новая задача: ${taskData.title}`;
htmlContent = this.getTaskCreatedHtml(taskData);
break;
case 'updated':
subject = `Обновлена задача: ${taskData.title}`;
htmlContent = this.getTaskUpdatedHtml(taskData);
break;
case 'rework':
subject = `Задача возвращена на доработку: ${taskData.title}`;
htmlContent = this.getTaskReworkHtml(taskData);
break;
case 'closed':
subject = `Задача закрыта: ${taskData.title}`;
htmlContent = this.getTaskClosedHtml(taskData);
break;
case 'status_changed':
subject = `Изменен статус задачи: ${taskData.title}`;
htmlContent = this.getStatusChangedHtml(taskData);
break;
case 'deadline':
subject = `Скоро срок выполнения: ${taskData.title}`;
htmlContent = this.getDeadlineHtml(taskData);
break;
case 'overdue':
subject = `Задача просрочена: ${taskData.title}`;
htmlContent = this.getOverdueHtml(taskData);
break;
default:
subject = `Уведомление по задаче: ${taskData.title}`;
htmlContent = this.getDefaultHtml(taskData);
}
const result = await this.sendEmailNotification(emailTo, subject, htmlContent, userId, taskId, notificationType);
if (result.sent) {
await this.recordNotificationSent(userId, taskId, notificationType);
console.log(`✅ Уведомление отправлено через ${result.smtpUsed} (попыток: ${result.attempts}): пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
return true;
} else {
if (result.error === 'spam_blocked') {
console.log(`🚫 Уведомление не отправлено: заблокировано из-за спама`);
} else if (result.error === 'all_smtp_failed') {
console.log(`❌ Уведомление не отправлено: все SMTP серверы не сработали (попыток: ${result.attempts})`);
} else {
console.log(`❌ Уведомление не отправлено: ${result.error}`);
}
return false;
}
} catch (error) {
console.error('❌ Ошибка отправки уведомления о задаче:', error);
console.error('Stack trace:', error.stack);
return false;
}
}
// HTML шаблоны (остаются без изменений)
getTaskCreatedHtml(taskData) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.task-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.task-info { margin-bottom: 15px; }
.button { display: inline-block; background: #667eea; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
.footer { margin-top: 20px; font-size: 12px; color: #666; }
.cooldown-notice { background: #f0f0f0; padding: 10px; border-radius: 5px; margin-top: 15px; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>📋 Новая задача</h2>
</div>
<div class="content">
<div class="task-title">${taskData.title || 'Без названия'}</div>
<div class="task-info">
<p><strong>Описание:</strong> ${taskData.description || 'Без описания'}</p>
${taskData.due_date ? `<p><strong>Срок выполнения:</strong> ${new Date(taskData.due_date).toLocaleString('ru-RU')}</p>` : ''}
${taskData.author_name ? `<p><strong>Создал:</strong> ${taskData.author_name}</p>` : ''}
</div>
<p>Для просмотра подробной информации перейдите в систему управления задачами.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}" class="button">Перейти в CRM</a>
<div class="cooldown-notice">
<p>⚠️ Следующее уведомление по этой задаче будет отправлено не ранее чем через 12 часов.</p>
<p>Вы можете изменить настройки уведомлений в личном кабинете.</p>
</div>
</div>
<div class="footer">
<p>Это автоматическое уведомление от School CRM системы.</p>
<p>Вы получили это письмо, потому что подписаны на уведомления о задачах.</p>
</div>
</div>
</body>
</html>
`;
}
getTaskUpdatedHtml(taskData) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.task-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.task-info { margin-bottom: 15px; }
.button { display: inline-block; background: #4CAF50; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
.footer { margin-top: 20px; font-size: 12px; color: #666; }
.cooldown-notice { background: #f0f0f0; padding: 10px; border-radius: 5px; margin-top: 15px; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🔄 Обновлена задача</h2>
</div>
<div class="content">
<div class="task-title">${taskData.title || 'Без названия'}</div>
<div class="task-info">
${taskData.author_name ? `<p><strong>Изменения внес:</strong> ${taskData.author_name}</p>` : ''}
<p><strong>Время:</strong> ${new Date().toLocaleString('ru-RU')}</p>
</div>
<p>Для просмотра изменений перейдите в систему управления задачами.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}" class="button">Перейти в CRM</a>
<div class="cooldown-notice">
<p>⚠️ Следующее уведомление по этой задаче будет отправлено не ранее чем через 12 часов.</p>
</div>
</div>
<div class="footer">
<p>Это автоматическое уведомление от School CRM системы.</p>
<p>Вы получили это письмо, потому что подписаны на уведомления о задачах.</p>
</div>
</div>
</body>
</html>
`;
}
getTaskReworkHtml(taskData) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #FF9800 0%, #F57C00 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.task-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.task-info { margin-bottom: 15px; }
.comment-box { background: #FFF3E0; padding: 15px; border-left: 4px solid #FF9800; margin: 15px 0; }
.button { display: inline-block; background: #FF9800; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
.footer { margin-top: 20px; font-size: 12px; color: #666; }
.cooldown-notice { background: #f0f0f0; padding: 10px; border-radius: 5px; margin-top: 15px; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🔄 Задача возвращена на доработку</h2>
</div>
<div class="content">
<div class="task-title">${taskData.title || 'Без названия'}</div>
<div class="task-info">
${taskData.author_name ? `<p><strong>Автор замечания:</strong> ${taskData.author_name}</p>` : ''}
</div>
<div class="comment-box">
<p><strong>Комментарий:</strong></p>
<p>${taskData.comment || 'Требуется доработка'}</p>
</div>
<p>Пожалуйста, исправьте замечания и обновите статус задачи.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}" class="button">Перейти к задаче</a>
<div class="cooldown-notice">
<p>⚠️ Следующее уведомление по этой задаче будет отправлено не ранее чем через 12 часов.</p>
</div>
</div>
<div class="footer">
<p>Это автоматическое уведомление от School CRM системы.</p>
<p>Вы получили это письмо, потому что подписаны на уведомления о задачах.</p>
</div>
</div>
</body>
</html>
`;
}
getTaskClosedHtml(taskData) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #9E9E9E 0%, #616161 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.task-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.task-info { margin-bottom: 15px; }
.button { display: inline-block; background: #9E9E9E; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
.footer { margin-top: 20px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>✅ Задача закрыта</h2>
</div>
<div class="content">
<div class="task-title">${taskData.title || 'Без названия'}</div>
<div class="task-info">
${taskData.author_name ? `<p><strong>Закрыта:</strong> ${taskData.author_name}</p>` : ''}
<p><strong>Время закрытия:</strong> ${new Date().toLocaleString('ru-RU')}</p>
</div>
<p>Задача завершена и перемещена в архив.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}" class="button">Перейти в CRM</a>
</div>
<div class="footer">
<p>Это автоматическое уведомление от School CRM системы.</p>
<p>Вы получили это письмо, потому что подписаны на уведомления о задачах.</p>
</div>
</div>
</body>
</html>
`;
}
getStatusChangedHtml(taskData) {
const statusText = this.getStatusText(taskData.status);
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #2196F3 0%, #1976D2 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.task-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.task-info { margin-bottom: 15px; }
.status-badge { display: inline-block; padding: 5px 10px; border-radius: 3px; color: white; }
.status-assigned { background: #FF9800; }
.status-in-progress { background: #2196F3; }
.status-completed { background: #4CAF50; }
.status-overdue { background: #F44336; }
.status-rework { background: #FF9800; }
.button { display: inline-block; background: #2196F3; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
.footer { margin-top: 20px; font-size: 12px; color: #666; }
.cooldown-notice { background: #f0f0f0; padding: 10px; border-radius: 5px; margin-top: 15px; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🔄 Изменен статус задачи</h2>
</div>
<div class="content">
<div class="task-title">${taskData.title || 'Без названия'}</div>
<div class="task-info">
<p><strong>Новый статус:</strong> <span class="status-badge status-${taskData.status}">${statusText}</span></p>
<p><strong>Изменил:</strong> ${taskData.user_name || taskData.author_name || 'Неизвестно'}</p>
<p><strong>Время:</strong> ${new Date().toLocaleString('ru-RU')}</p>
</div>
<p>Для просмотра деталей перейдите в систему управления задачами.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}" class="button">Перейти в CRM</a>
<div class="cooldown-notice">
<p>⚠️ Следующее уведомление по этой задаче будет отправлено не ранее чем через 12 часов.</p>
</div>
</div>
<div class="footer">
<p>Это автоматическое уведомление от School CRM системы.</p>
<p>Вы получили это письмо, потому что подписаны на уведомления о задачах.</p>
</div>
</div>
</body>
</html>
`;
}
getDeadlineHtml(taskData) {
const hoursLeft = taskData.hours_left || 24;
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #F44336 0%, #D32F2F 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.task-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.task-info { margin-bottom: 15px; }
.deadline-warning { background: #FFEBEE; padding: 15px; border-left: 4px solid #F44336; margin: 15px 0; }
.button { display: inline-block; background: #F44336; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
.footer { margin-top: 20px; font-size: 12px; color: #666; }
.cooldown-notice { background: #f0f0f0; padding: 10px; border-radius: 5px; margin-top: 15px; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>⚠️ Скоро срок выполнения</h2>
</div>
<div class="content">
<div class="task-title">${taskData.title || 'Без названия'}</div>
<div class="deadline-warning">
<p><strong>ВНИМАНИЕ!</strong> До окончания срока задачи осталось менее ${hoursLeft} часов!</p>
${taskData.due_date ? `<p><strong>Срок выполнения:</strong> ${new Date(taskData.due_date).toLocaleString('ru-RU')}</p>` : ''}
</div>
<p>Пожалуйста, завершите задачу в указанный срок.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}" class="button">Перейти к задаче</a>
<div class="cooldown-notice">
<p>⚠️ Следующее уведомление о дедлайне будет отправлено не ранее чем через 12 часов.</p>
<p>Если дедлайн изменится, вы получите новое уведомление.</p>
</div>
</div>
<div class="footer">
<p>Это автоматическое уведомление от School CRM системы.</p>
<p>Вы получили это письмо, потому что подписаны на уведомления о задачах.</p>
</div>
</div>
</body>
</html>
`;
}
getOverdueHtml(taskData) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #D32F2F 0%, #B71C1C 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.task-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.task-info { margin-bottom: 15px; }
.overdue-alert { background: #FFCDD2; padding: 15px; border-left: 4px solid #D32F2F; margin: 15px 0; }
.button { display: inline-block; background: #D32F2F; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
.footer { margin-top: 20px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>🚨 Задача просрочена</h2>
</div>
<div class="content">
<div class="task-title">${taskData.title || 'Без названия'}</div>
<div class="overdue-alert">
<p><strong>ВНИМАНИЕ!</strong> Срок выполнения задачи истек!</p>
${taskData.due_date ? `<p><strong>Срок выполнения был:</strong> ${new Date(taskData.due_date).toLocaleString('ru-RU')}</p>` : ''}
<p><strong>Текущее время:</strong> ${new Date().toLocaleString('ru-RU')}</p>
</div>
<p>Пожалуйста, завершите задачу как можно скорее и обновите ее статус.</p>
<a href="${process.env.APP_URL || 'http://localhost:3000'}" class="button">Перейти к задаче</a>
</div>
<div class="footer">
<p>Это автоматическое уведомление от School CRM системы.</p>
<p>Вы получили это письмо, потому что подписаны на уведомления о задачах.</p>
</div>
</div>
</body>
</html>
`;
}
getStatusText(status) {
const statusMap = {
'assigned': 'Назначена',
'in_progress': 'В работе',
'completed': 'Завершена',
'overdue': 'Просрочена',
'rework': 'На доработке',
'pending': 'В ожидании',
'in_review': 'На проверке',
'approved': 'Согласовано',
'rejected': 'Отклонено'
};
return statusMap[status] || status;
}
getDefaultHtml(taskData) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 10px 10px 0 0; }
.content { padding: 20px; border: 1px solid #ddd; border-top: none; }
.task-title { font-size: 18px; font-weight: bold; margin-bottom: 10px; }
.task-info { margin-bottom: 15px; }
.button { display: inline-block; background: #667eea; color: white; padding: 10px 20px; text-decoration: none; border-radius: 5px; }
.footer { margin-top: 20px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>📢 Уведомление от School CRM</h2>
</div>
<div class="content">
<div class="task-title">${taskData.title || 'Без названия'}</div>
<div class="task-info">
<p>${taskData.message || 'Новое уведомление по задаче'}</p>
</div>
<a href="${process.env.APP_URL || 'http://localhost:3000'}" class="button">Перейти в CRM</a>
</div>
<div class="footer">
<p>Это автоматическое уведомление от School CRM системы.</p>
<p>Вы получили это письмо, потому что подписаны на уведомления о задачах.</p>
</div>
</div>
</body>
</html>
`;
}
async getNotificationHistory(userId, taskId = null) {
if (!getDb) return [];
return new Promise((resolve, reject) => {
const db = getDb();
let query = `
SELECT nh.*, t.title as task_title
FROM notification_history nh
LEFT JOIN tasks t ON nh.task_id = t.id
WHERE nh.user_id = ?
`;
const params = [userId];
if (taskId) {
query += " AND nh.task_id = ?";
params.push(taskId);
}
query += " ORDER BY nh.last_sent_at DESC LIMIT 100";
db.all(query, params, (err, history) => {
if (err) {
reject(err);
} else {
resolve(history || []);
}
});
});
}
async getNotificationCooldownInfo(userId, taskId, notificationType) {
if (!getDb) return { canSend: true, timeUntilNext: 0 };
return new Promise((resolve, reject) => {
const db = getDb();
db.get(
`SELECT last_sent_at FROM notification_history
WHERE user_id = ? AND task_id = ? AND notification_type = ?`,
[userId, taskId, notificationType],
(err, record) => {
if (err) {
reject(err);
return;
}
if (!record) {
resolve({ canSend: true, timeUntilNext: 0, lastSent: null });
return;
}
const lastSent = new Date(record.last_sent_at);
const now = new Date();
const timeDiff = now.getTime() - lastSent.getTime();
const timeUntilNext = Math.max(0, this.notificationCooldown - timeDiff);
resolve({
canSend: timeDiff >= this.notificationCooldown,
timeUntilNext: timeUntilNext,
lastSent: lastSent,
nextAvailable: new Date(lastSent.getTime() + this.notificationCooldown)
});
}
);
});
}
getSmtpStatus() {
return this.transporters.map((smtp, index) => ({
name: smtp.name,
email: smtp.email,
host: smtp.host,
port: smtp.port,
status: smtp.status,
isActive: index === this.activeTransporterIndex,
failureReason: smtp.failureReason || null,
failedAt: smtp.failedAt || null,
configSource: smtp.configSource || 'unknown'
}));
}
isReady() {
return this.initialized && this.transporters.length > 0;
}
getSpamBlockStatus() {
if (!this.isSpamBlocked || !this.spamBlockedUntil || this.ignoreSpamBlock) {
return {
blocked: false,
blockedUntil: null,
minutesLeft: 0,
ignoreMode: this.ignoreSpamBlock
};
}
const now = new Date();
if (now >= this.spamBlockedUntil) {
this.clearSpamBlock();
return {
blocked: false,
blockedUntil: null,
minutesLeft: 0,
ignoreMode: this.ignoreSpamBlock
};
}
const minutesLeft = Math.ceil((this.spamBlockedUntil - now) / (60 * 1000));
return {
blocked: true,
blockedUntil: this.spamBlockedUntil,
minutesLeft: minutesLeft,
ignoreMode: this.ignoreSpamBlock
};
}
// Получить статистику работы SMTP
getSmtpStats() {
const total = this.transporters.length;
const active = this.transporters.filter(t => t.status === 'active').length;
const failed = this.transporters.filter(t => t.status === 'failed').length;
return {
total: total,
active: active,
failed: failed,
activePercentage: total > 0 ? Math.round((active / total) * 100) : 0,
currentActive: this.activeTransporterIndex,
ignoreSpamBlock: this.ignoreSpamBlock,
spamBlocked: this.isSpamBlocked,
initialized: this.initialized
};
}
}
// Singleton
const emailNotifications = new EmailNotifications();
module.exports = emailNotifications;