1409 lines
68 KiB
JavaScript
1409 lines
68 KiB
JavaScript
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();
|
||
}
|
||
}
|
||
console.log(`🌐 APP_URL из .env: ${process.env.APP_URL || 'не указан (будет использован localhost:3000)'}`);
|
||
} 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; |