This commit is contained in:
2026-01-27 22:35:09 +05:00
parent a1c9c833f5
commit eb03509c26
2 changed files with 850 additions and 139 deletions

View File

@@ -1,4 +1,3 @@
// email-notifications.js
const nodemailer = require('nodemailer');
const { getDb } = require('./database');
@@ -7,7 +6,14 @@ class EmailNotifications {
this.transporter = null;
this.initialized = false;
this.notificationCooldown = 12 * 60 * 60 * 1000; // 12 часов в миллисекундах
this.spamBlockCooldown = 60 * 60 * 1000; // 60 минут при блокировке спама
this.spamBlockedUntil = null;
this.isSpamBlocked = false;
this.maxRetries = 3;
this.init();
// Запускаем обработку очереди каждые 5 минут
setInterval(() => this.processRetryQueue(), 5 * 60 * 1000);
}
async init() {
@@ -37,8 +43,22 @@ class EmailNotifications {
// Тестируем подключение
await this.transporter.verify();
this.initialized = true;
// Восстанавливаем состояние блокировки из БД
await this.restoreSpamBlockState();
console.log('✅ Email уведомления инициализированы');
console.log(`📧 Отправитель: ${process.env.YANDEX_EMAIL}`);
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 {
this.clearSpamBlock();
}
}
} catch (error) {
console.error('❌ Ошибка инициализации Email уведомлений:', error.message);
@@ -46,6 +66,358 @@ class EmailNotifications {
}
}
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() {
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) 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();
// Запускаем обработку очереди
this.processRetryQueue();
}
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.isSpamBlocked || !this.spamBlockedUntil) {
return false;
}
const now = new Date();
if (now >= this.spamBlockedUntil) {
this.clearSpamBlock();
return false;
}
return true;
}
async saveToQueue(to, subject, htmlContent, userId, taskId, notificationType, retryCount = 0) {
if (!getDb) {
console.warn('⚠️ БД не доступна для сохранения в очередь');
return false;
}
try {
const db = getDb();
return new Promise((resolve, reject) => {
db.run(
`INSERT INTO email_queue
(to_email, subject, html_content, user_id, task_id, notification_type, retry_count, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', CURRENT_TIMESTAMP)`,
[to, subject, htmlContent, userId, taskId, notificationType, retryCount],
function(err) {
if (err) {
console.error('❌ Ошибка сохранения в очередь:', err);
reject(err);
} else {
console.log(`📝 Email сохранен в очередь (ID: ${this.lastID}): ${to}, ${subject}`);
resolve(this.lastID);
}
}
);
});
} catch (error) {
console.error('❌ Ошибка сохранения в очередь:', error);
return false;
}
}
async updateQueueStatus(queueId, status, errorMessage = null, retryCount = null) {
if (!getDb) return false;
try {
const db = getDb();
return new Promise((resolve, reject) => {
let query = `UPDATE email_queue SET status = ?, updated_at = CURRENT_TIMESTAMP`;
const params = [status];
if (errorMessage) {
query += `, error_message = ?`;
params.push(errorMessage);
}
if (retryCount !== null) {
query += `, retry_count = ?`;
params.push(retryCount);
}
query += ` WHERE id = ?`;
params.push(queueId);
db.run(query, params, function(err) {
if (err) {
console.error('❌ Ошибка обновления статуса в очереди:', err);
reject(err);
} else {
console.log(`📝 Статус email в очереди обновлен (ID: ${queueId}): ${status}`);
resolve(this.changes > 0);
}
});
});
} catch (error) {
console.error('❌ Ошибка обновления статуса очереди:', error);
return false;
}
}
async removeFromQueue(queueId) {
if (!getDb) return false;
try {
const db = getDb();
return new Promise((resolve, reject) => {
db.run(
`DELETE FROM email_queue WHERE id = ?`,
[queueId],
function(err) {
if (err) {
console.error('❌ Ошибка удаления из очереди:', err);
reject(err);
} else {
console.log(`🗑️ Email удален из очереди (ID: ${queueId})`);
resolve(this.changes > 0);
}
}
);
});
} catch (error) {
console.error('❌ Ошибка удаления из очереди:', error);
return false;
}
}
async getPendingEmails(limit = 10) {
if (!getDb) return [];
try {
const db = getDb();
return new Promise((resolve, reject) => {
db.all(
`SELECT * FROM email_queue
WHERE status = 'pending'
ORDER BY created_at ASC
LIMIT ?`,
[limit],
(err, rows) => {
if (err) {
console.error('❌ Ошибка получения очереди:', err);
reject(err);
} else {
resolve(rows || []);
}
}
);
});
} catch (error) {
console.error('❌ Ошибка получения очереди:', error);
return [];
}
}
async processRetryQueue() {
if (this.isSpamBlockActive()) {
const minutesLeft = Math.ceil((this.spamBlockedUntil - new Date()) / (60 * 1000));
console.log(`⏸️ Пропуск обработки очереди: блокировка из-за спама (осталось ${minutesLeft} минут)`);
return;
}
if (!this.initialized || !this.transporter) {
console.warn('⚠️ Пропуск обработки очереди: Email не инициализирован');
return;
}
console.log('🔍 Проверка очереди email для повторной отправки...');
try {
const pendingEmails = await this.getPendingEmails(20);
if (pendingEmails.length === 0) {
console.log('📭 Очередь email пуста');
return;
}
console.log(`📧 Найдено ${pendingEmails.length} email в очереди`);
for (const email of pendingEmails) {
try {
// Проверяем, не превышено ли максимальное количество попыток
if (email.retry_count >= this.maxRetries) {
console.log(`⏭️ Пропуск email ${email.id}: превышено максимальное количество попыток (${email.retry_count})`);
await this.updateQueueStatus(email.id, 'failed', 'Превышено максимальное количество попыток отправки');
continue;
}
// Обновляем статус на "в процессе отправки"
await this.updateQueueStatus(email.id, 'sending');
// Отправляем email
const info = await this.transporter.sendMail({
from: `"School CRM" <${process.env.YANDEX_EMAIL}>`,
to: email.to_email,
subject: email.subject,
html: email.html_content,
text: email.html_content.replace(/<[^>]*>/g, '')
});
console.log(`✅ Email отправлен из очереди (ID: ${email.id}): ${email.to_email}, Message ID: ${info.messageId}`);
// Удаляем из очереди при успешной отправке
await this.removeFromQueue(email.id);
// Если есть связанная задача, обновляем историю уведомлений
if (email.user_id && email.task_id && email.notification_type) {
try {
await this.recordNotificationSent(email.user_id, email.task_id, email.notification_type);
console.log(`📝 История уведомлений обновлена для email из очереди (ID: ${email.id})`);
} catch (historyError) {
console.error('❌ Ошибка обновления истории для email из очереди:', historyError);
}
}
// Делаем небольшую паузу между отправками
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (emailError) {
console.error(`❌ Ошибка отправки email из очереди (ID: ${email.id}):`, emailError.message);
// Проверяем, является ли ошибка блокировкой спама
if (emailError.message.includes('554 5.7.1 Message rejected under suspicion of SPAM') ||
emailError.message.includes('suspicion of SPAM') ||
emailError.message.includes('SPAM')) {
console.log('🚫 Обнаружена блокировка из-за спама при обработке очереди');
await this.setSpamBlock();
// Увеличиваем счетчик попыток и возвращаем в очередь
const newRetryCount = (email.retry_count || 0) + 1;
await this.updateQueueStatus(
email.id,
'pending',
`SPAM блокировка: ${emailError.message}`,
newRetryCount
);
break; // Прерываем цикл при блокировке спама
}
// Для других ошибок просто увеличиваем счетчик попыток
const newRetryCount = (email.retry_count || 0) + 1;
await this.updateQueueStatus(
email.id,
'pending',
`Ошибка отправки: ${emailError.message}`,
newRetryCount
);
// Делаем паузу перед следующей попыткой
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
console.log(`✅ Обработка очереди email завершена`);
} catch (error) {
console.error('❌ Ошибка обработки очереди email:', error);
}
}
async canSendNotification(userId, taskId, notificationType) {
if (!getDb) return true; // Если БД не готова, разрешаем отправку
@@ -221,10 +593,23 @@ class EmailNotifications {
});
}
async sendEmailNotification(to, subject, htmlContent) {
async sendEmailNotification(to, subject, htmlContent, userId = null, taskId = null, notificationType = null) {
// Проверяем блокировку из-за спама
if (this.isSpamBlockActive()) {
const minutesLeft = Math.ceil((this.spamBlockedUntil - new Date()) / (60 * 1000));
console.log(`⏸️ Email отправка заблокирована из-за спама. Сохраняем в очередь. Осталось: ${minutesLeft} минут`);
// Сохраняем в очередь для последующей отправки
const queueId = await this.saveToQueue(to, subject, htmlContent, userId, taskId, notificationType);
return queueId ? { queued: true, queueId } : false;
}
if (!this.initialized || !this.transporter) {
console.warn('⚠️ Email уведомления отключены');
return false;
// Также сохраняем в очередь на случай, если сервис восстановится
const queueId = await this.saveToQueue(to, subject, htmlContent, userId, taskId, notificationType);
return queueId ? { queued: true, queueId } : false;
}
try {
@@ -233,15 +618,31 @@ class EmailNotifications {
to: to,
subject: subject,
html: htmlContent,
text: htmlContent.replace(/<[^>]*>/g, '') // Конвертируем HTML в текст
text: htmlContent.replace(/<[^>]*>/g, '')
});
console.log(`📧 Email отправлен: ${to}, Message ID: ${info.messageId}`);
return true;
return { sent: true, messageId: info.messageId };
} catch (error) {
console.error('❌ Ошибка отправки email:', error.message);
return false;
// Проверяем, является ли ошибка блокировкой спама
if (error.message.includes('554 5.7.1 Message rejected under suspicion of SPAM') ||
error.message.includes('suspicion of SPAM') ||
error.message.includes('SPAM')) {
console.log('🚫 Обнаружена блокировка из-за спама. Активируем блокировку на 60 минут.');
await this.setSpamBlock();
// Сохраняем email в очередь для повторной отправки после блокировки
const queueId = await this.saveToQueue(to, subject, htmlContent, userId, taskId, notificationType, 1);
return queueId ? { queued: true, queueId, spamBlocked: true } : false;
}
// Для других ошибок также сохраняем в очередь
const queueId = await this.saveToQueue(to, subject, htmlContent, userId, taskId, notificationType, 1);
return queueId ? { queued: true, queueId } : false;
}
}
@@ -386,14 +787,20 @@ class EmailNotifications {
htmlContent = this.getDefaultHtml(taskData);
}
const result = await this.sendEmailNotification(emailTo, subject, htmlContent);
const result = await this.sendEmailNotification(emailTo, subject, htmlContent, userId, taskId, notificationType);
// Если уведомление успешно отправлено, записываем в историю
if (result) {
// Если уведомление успешно отправлено (не в очередь), записываем в историю
if (result && result.sent) {
await this.recordNotificationSent(userId, taskId, notificationType);
console.log(`✅ Уведомление отправлено: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
} else if (result && result.queued) {
console.log(`📝 Уведомление сохранено в очередь (ID: ${result.queueId}): пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
if (result.spamBlocked) {
console.log(`🚫 Отправка заблокирована из-за спама. Email будет отправлен после разблокировки.`);
}
} else {
console.log(`Не удалось отправить уведомление: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
console.log(`Не удалось отправить/сохранить уведомление: пользователь ${userId}, задача ${taskId}, тип ${notificationType}`);
}
return result;
@@ -845,11 +1252,175 @@ class EmailNotifications {
});
}
async getEmailQueueStats() {
if (!getDb) return { pending: 0, failed: 0, total: 0 };
return new Promise((resolve, reject) => {
const db = getDb();
const query = `
SELECT
SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending,
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) as failed,
COUNT(*) as total
FROM email_queue
`;
db.get(query, [], (err, stats) => {
if (err) {
console.error('❌ Ошибка получения статистики очереди:', err);
resolve({ pending: 0, failed: 0, total: 0 });
} else {
resolve(stats || { pending: 0, failed: 0, total: 0 });
}
});
});
}
async getEmailQueueItems(status = null, limit = 50) {
if (!getDb) return [];
return new Promise((resolve, reject) => {
const db = getDb();
let query = `SELECT * FROM email_queue`;
const params = [];
if (status) {
query += ` WHERE status = ?`;
params.push(status);
}
query += ` ORDER BY created_at DESC LIMIT ?`;
params.push(limit);
db.all(query, params, (err, items) => {
if (err) {
console.error('❌ Ошибка получения элементов очереди:', err);
resolve([]);
} else {
resolve(items || []);
}
});
});
}
async retryFailedEmails() {
if (!getDb) return { retried: 0, total: 0 };
try {
const db = getDb();
// Получаем все проваленные email
const failedEmails = await new Promise((resolve, reject) => {
db.all(
`SELECT * FROM email_queue WHERE status = 'failed' AND retry_count < ?`,
[this.maxRetries],
(err, emails) => {
if (err) reject(err);
else resolve(emails || []);
}
);
});
if (failedEmails.length === 0) {
console.log('📭 Нет проваленных email для повторной отправки');
return { retried: 0, total: 0 };
}
console.log(`🔍 Найдено ${failedEmails.length} проваленных email для повторной отправки`);
let retriedCount = 0;
for (const email of failedEmails) {
try {
// Сбрасываем статус на pending для повторной попытки
await new Promise((resolve, reject) => {
db.run(
`UPDATE email_queue SET status = 'pending', retry_count = retry_count + 1 WHERE id = ?`,
[email.id],
function(err) {
if (err) reject(err);
else {
console.log(`🔄 Email ${email.id} подготовлен для повторной отправки`);
retriedCount++;
resolve();
}
}
);
});
} catch (error) {
console.error(`❌ Ошибка подготовки email ${email.id} для повторной отправки:`, error);
}
}
console.log(`✅ Подготовлено ${retriedCount} email для повторной отправки`);
// Запускаем обработку очереди
this.processRetryQueue();
return { retried: retriedCount, total: failedEmails.length };
} catch (error) {
console.error('❌ Ошибка при попытке повторной отправки email:', error);
return { retried: 0, total: 0 };
}
}
async clearOldQueueItems(days = 30) {
if (!getDb) return { deleted: 0 };
try {
const db = getDb();
const result = await new Promise((resolve, reject) => {
db.run(
`DELETE FROM email_queue
WHERE created_at < datetime('now', '-${days} days')
AND (status = 'sent' OR status = 'failed')`,
function(err) {
if (err) reject(err);
else resolve(this.changes || 0);
}
);
});
console.log(`🗑️ Удалено ${result} старых записей из очереди email (старше ${days} дней)`);
return { deleted: result };
} catch (error) {
console.error('❌ Ошибка очистки старых записей очереди:', error);
return { deleted: 0 };
}
}
isReady() {
return this.initialized;
}
getSpamBlockStatus() {
if (!this.isSpamBlocked || !this.spamBlockedUntil) {
return { blocked: false, blockedUntil: null, minutesLeft: 0 };
}
const now = new Date();
if (now >= this.spamBlockedUntil) {
this.clearSpamBlock();
return { blocked: false, blockedUntil: null, minutesLeft: 0 };
}
const minutesLeft = Math.ceil((this.spamBlockedUntil - now) / (60 * 1000));
return {
blocked: true,
blockedUntil: this.spamBlockedUntil,
minutesLeft: minutesLeft
};
}
}
// Singleton
const emailNotifications = new EmailNotifications();
// Экспортируем функцию для очистки старых записей (можно запускать по cron)
emailNotifications.clearOldQueueItems = emailNotifications.clearOldQueueItems.bind(emailNotifications);
module.exports = emailNotifications;