version
This commit is contained in:
parent
98e5091742
commit
6e89deb3f5
@ -4,7 +4,7 @@ import styles from './SideMenu.module.css';
|
|||||||
const SideMenu = ({ active, onSelect }) => {
|
const SideMenu = ({ active, onSelect }) => {
|
||||||
return (
|
return (
|
||||||
<aside className={styles.menu}>
|
<aside className={styles.menu}>
|
||||||
<div className={styles.project}>CoreSync Marketing</div>
|
<div className={styles.project}>CoreSync MRM</div>
|
||||||
<nav className={styles.nav}>
|
<nav className={styles.nav}>
|
||||||
<div className={styles.section}>Email-рассылки</div>
|
<div className={styles.section}>Email-рассылки</div>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { useUser } from '../context/UserContext';
|
import { useUser } from '../context/UserContext';
|
||||||
import EditTemplateModal from '../modals/EditTemplateModal';
|
import EditTemplateModal from '../modals/EditTemplateModal';
|
||||||
import CreateTemplateModal from '../modals/CreateTemplateModal';
|
import CreateTemplateModal from '../modals/CreateTemplateModal';
|
||||||
|
import TemplateVersionsPage from './TemplateVersionsPage';
|
||||||
import Paginator from '../components/Paginator';
|
import Paginator from '../components/Paginator';
|
||||||
|
|
||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
@ -18,11 +19,12 @@ function EmailTemplatesPage() {
|
|||||||
const [deleteLoading, setDeleteLoading] = useState(null);
|
const [deleteLoading, setDeleteLoading] = useState(null);
|
||||||
const [createTemplate, setCreateTemplate] = useState(null);
|
const [createTemplate, setCreateTemplate] = useState(null);
|
||||||
const [createLoading, setCreateLoading] = useState(false);
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
const [selectedTemplate, setSelectedTemplate] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchTemplates(page);
|
if (!selectedTemplate) fetchTemplates(page);
|
||||||
// eslint-disable-next-line
|
// eslint-disable-next-line
|
||||||
}, [page]);
|
}, [page, selectedTemplate]);
|
||||||
|
|
||||||
const fetchTemplates = async (page) => {
|
const fetchTemplates = async (page) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -131,6 +133,10 @@ function EmailTemplatesPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (selectedTemplate) {
|
||||||
|
return <TemplateVersionsPage template={selectedTemplate} onBack={() => setSelectedTemplate(null)} token={token} />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', marginBottom: 12 }}>
|
||||||
@ -151,12 +157,12 @@ function EmailTemplatesPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{templates.map(t => (
|
{templates.map(t => (
|
||||||
<tr key={t.id} style={{ borderBottom: '1px solid #e5e7eb' }}>
|
<tr key={t.id} style={{ borderBottom: '1px solid #e5e7eb', cursor: 'pointer' }} onClick={() => setSelectedTemplate(t)}>
|
||||||
<td style={tdStyle}>{t.id}</td>
|
<td style={tdStyle}>{t.id}</td>
|
||||||
<td style={tdStyle}>{t.name}</td>
|
<td style={tdStyle}>{t.name}</td>
|
||||||
<td style={tdStyle}>
|
<td style={tdStyle}>
|
||||||
<button onClick={() => handleEdit(t)} style={btnStyle}>Редактировать</button>
|
<button onClick={e => { e.stopPropagation(); handleEdit(t); }} style={btnStyle}>Редактировать</button>
|
||||||
<button onClick={() => handleDelete(t.id)} style={btnStyle} disabled={deleteLoading === t.id}>
|
<button onClick={e => { e.stopPropagation(); handleDelete(t.id); }} style={btnStyle} disabled={deleteLoading === t.id}>
|
||||||
{deleteLoading === t.id ? 'Удаление...' : 'Удалить'}
|
{deleteLoading === t.id ? 'Удаление...' : 'Удалить'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
1253
mail-service/package-lock.json
generated
1253
mail-service/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -4,8 +4,10 @@
|
|||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"dns": "^0.2.2",
|
||||||
"dotenv": "^17.2.0",
|
"dotenv": "^17.2.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
|
"kafkajs": "^2.2.4",
|
||||||
"mysql2": "^3.14.2",
|
"mysql2": "^3.14.2",
|
||||||
"sequelize": "^6.37.7"
|
"sequelize": "^6.37.7"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,20 @@ import { EmailTemplateVersion, EmailTemplate } from '../models/index.js';
|
|||||||
export default {
|
export default {
|
||||||
async create(req, res) {
|
async create(req, res) {
|
||||||
try {
|
try {
|
||||||
const version = await EmailTemplateVersion.create(req.body);
|
const { template_id, subject, body_html, body_text, is_active } = req.body;
|
||||||
|
const latest = await EmailTemplateVersion.findOne({
|
||||||
|
where: { template_id },
|
||||||
|
order: [['version_number', 'DESC']],
|
||||||
|
});
|
||||||
|
const version_number = latest ? latest.version_number + 1 : 1;
|
||||||
|
const version = await EmailTemplateVersion.create({
|
||||||
|
template_id,
|
||||||
|
version_number,
|
||||||
|
subject,
|
||||||
|
body_html,
|
||||||
|
body_text,
|
||||||
|
is_active: is_active !== undefined ? is_active : true,
|
||||||
|
});
|
||||||
res.status(201).json(version);
|
res.status(201).json(version);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
res.status(400).json({ error: err.message });
|
res.status(400).json({ error: err.message });
|
||||||
|
|||||||
@ -3,6 +3,7 @@ dotenv.config();
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
import { sequelize } from './models/index.js';
|
import { sequelize } from './models/index.js';
|
||||||
import routes from './routes/index.js';
|
import routes from './routes/index.js';
|
||||||
|
import { processScheduledCampaigns } from './service/queueFillerJob.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
@ -24,6 +25,11 @@ app.use('/api/mail', routes);
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Периодически заполняем очередь для scheduled кампаний
|
||||||
|
setInterval(() => {
|
||||||
|
processScheduledCampaigns().catch(err => console.error('Queue fill error:', err));
|
||||||
|
}, 60 * 1000); // раз в минуту
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
console.log(`Mail Service listening on port ${PORT}`);
|
console.log(`Mail Service listening on port ${PORT}`);
|
||||||
|
|||||||
@ -8,7 +8,7 @@ export default (sequelize) => {
|
|||||||
group_id: { type: DataTypes.INTEGER, allowNull: false },
|
group_id: { type: DataTypes.INTEGER, allowNull: false },
|
||||||
subject_override: { type: DataTypes.STRING },
|
subject_override: { type: DataTypes.STRING },
|
||||||
scheduled_at: { type: DataTypes.DATE },
|
scheduled_at: { type: DataTypes.DATE },
|
||||||
status: { type: DataTypes.ENUM('draft', 'scheduled', 'sent', 'failed'), defaultValue: 'draft' },
|
status: { type: DataTypes.ENUM('draft', 'scheduled', 'sending', 'sent', 'failed'), defaultValue: 'draft' },
|
||||||
created_at: { type: DataTypes.DATE, defaultValue: Sequelize.NOW },
|
created_at: { type: DataTypes.DATE, defaultValue: Sequelize.NOW },
|
||||||
}, { tableName: 'campaigns', timestamps: false });
|
}, { tableName: 'campaigns', timestamps: false });
|
||||||
return Campaign;
|
return Campaign;
|
||||||
|
|||||||
29
mail-service/src/service/mailSender.js
Normal file
29
mail-service/src/service/mailSender.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Kafka } from 'kafkajs';
|
||||||
|
// import nodemailer from 'nodemailer'; // Для реальной отправки
|
||||||
|
|
||||||
|
const kafka = new Kafka({
|
||||||
|
clientId: process.env.KAFKA_CLIENT_ID || 'mail-sender',
|
||||||
|
brokers: [process.env.KAFKA_BROKER || 'localhost:9092'],
|
||||||
|
});
|
||||||
|
const consumer = kafka.consumer({ groupId: process.env.KAFKA_GROUP_ID || 'mail-sender-group' });
|
||||||
|
|
||||||
|
export async function startMailSender(processTask) {
|
||||||
|
await consumer.connect();
|
||||||
|
// Подписываемся на все топики mail-send-*
|
||||||
|
await consumer.subscribe({ topic: /^mail-send-.+$/, fromBeginning: false });
|
||||||
|
await consumer.run({
|
||||||
|
eachMessage: async ({ topic, partition, message }) => {
|
||||||
|
const task = JSON.parse(message.value.toString());
|
||||||
|
// processTask(task) должен реализовывать отправку писем подписчикам через SMTP
|
||||||
|
// task.smtpServerId теперь один, а не массив
|
||||||
|
await processTask(task, topic);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Пример processTask:
|
||||||
|
// async function processTask(task, topic) {
|
||||||
|
// // Здесь логика отправки писем через SMTP
|
||||||
|
// // task.campaignId, task.mx, task.subscribers, task.smtpServerId
|
||||||
|
// // topic - имя топика (mail-send-mx-smtpId)
|
||||||
|
// }
|
||||||
48
mail-service/src/service/queueFiller.js
Normal file
48
mail-service/src/service/queueFiller.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import dns from 'dns/promises';
|
||||||
|
import { Kafka } from 'kafkajs';
|
||||||
|
|
||||||
|
const kafka = new Kafka({
|
||||||
|
clientId: process.env.KAFKA_CLIENT_ID || 'queue-filler',
|
||||||
|
brokers: [process.env.KAFKA_BROKER || 'localhost:9092'],
|
||||||
|
});
|
||||||
|
const producer = kafka.producer();
|
||||||
|
|
||||||
|
async function getMxDomain(email) {
|
||||||
|
const domain = email.split('@')[1];
|
||||||
|
try {
|
||||||
|
const mxRecords = await dns.resolveMx(domain);
|
||||||
|
if (mxRecords && mxRecords.length > 0) {
|
||||||
|
// Берём самый приоритетный MX
|
||||||
|
return mxRecords.sort((a, b) => a.priority - b.priority)[0].exchange;
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
return domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fillQueueForCampaign(campaign, subscribers, smtpServers) {
|
||||||
|
await producer.connect();
|
||||||
|
// Группируем подписчиков по MX-домену
|
||||||
|
const mxMap = {};
|
||||||
|
for (const sub of subscribers) {
|
||||||
|
const mx = await getMxDomain(sub.email);
|
||||||
|
if (!mxMap[mx]) mxMap[mx] = [];
|
||||||
|
mxMap[mx].push(sub);
|
||||||
|
}
|
||||||
|
// Для каждого MX и каждого SMTP создаём задачу в Kafka для КАЖДОГО подписчика
|
||||||
|
for (const [mx, subs] of Object.entries(mxMap)) {
|
||||||
|
for (const smtp of smtpServers) {
|
||||||
|
const topic = `mail-send-${mx}-${smtp.id}`;
|
||||||
|
const messages = subs.map(sub => ({
|
||||||
|
value: JSON.stringify({
|
||||||
|
campaignId: campaign.id,
|
||||||
|
mx,
|
||||||
|
subscriberId: sub.id,
|
||||||
|
email: sub.email,
|
||||||
|
smtpServerId: smtp.id,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
await producer.send({ topic, messages });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await producer.disconnect();
|
||||||
|
}
|
||||||
63
mail-service/src/service/queueFillerJob.js
Normal file
63
mail-service/src/service/queueFillerJob.js
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Campaign, MailingGroup, GroupSubscriber, Subscriber, SmtpServer } from '../models/index.js';
|
||||||
|
import { fillQueueForCampaign } from './queueFiller.js';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import { Kafka } from 'kafkajs';
|
||||||
|
|
||||||
|
const BATCH_SIZE = 10000;
|
||||||
|
|
||||||
|
async function clearKafkaTopics(prefix = 'mail-send-') {
|
||||||
|
const kafka = new Kafka({
|
||||||
|
clientId: process.env.KAFKA_CLIENT_ID || 'queue-filler',
|
||||||
|
brokers: [process.env.KAFKA_BROKER || 'localhost:9092'],
|
||||||
|
});
|
||||||
|
const admin = kafka.admin();
|
||||||
|
await admin.connect();
|
||||||
|
const topics = await admin.listTopics();
|
||||||
|
const toDelete = topics.filter(t => t.startsWith(prefix));
|
||||||
|
if (toDelete.length > 0) {
|
||||||
|
await admin.deleteTopics({ topics: toDelete });
|
||||||
|
// Пересоздавать топики не нужно — Kafka создаст их автоматически при отправке сообщений
|
||||||
|
}
|
||||||
|
await admin.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processScheduledCampaigns() {
|
||||||
|
// Очищаем все mail-send-* топики перед построением
|
||||||
|
await clearKafkaTopics('mail-send-');
|
||||||
|
// 1. Найти все кампании в статусе scheduled
|
||||||
|
const campaigns = await Campaign.findAll({
|
||||||
|
where: { status: 'scheduled' },
|
||||||
|
include: [MailingGroup, SmtpServer],
|
||||||
|
});
|
||||||
|
for (const campaign of campaigns) {
|
||||||
|
// 2. Получить id всех подписчиков группы батчами
|
||||||
|
let offset = 0;
|
||||||
|
let allSubscriberIds = [];
|
||||||
|
while (true) {
|
||||||
|
const groupSubs = await GroupSubscriber.findAll({
|
||||||
|
where: { group_id: campaign.group_id },
|
||||||
|
attributes: ['subscriber_id'],
|
||||||
|
offset,
|
||||||
|
limit: BATCH_SIZE,
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
if (groupSubs.length === 0) break;
|
||||||
|
allSubscriberIds.push(...groupSubs.map(gs => gs.subscriber_id));
|
||||||
|
if (groupSubs.length < BATCH_SIZE) break;
|
||||||
|
offset += BATCH_SIZE;
|
||||||
|
}
|
||||||
|
// 3. Получить подписчиков батчами и сразу отправлять в очередь (не держим всех в памяти)
|
||||||
|
for (let i = 0; i < allSubscriberIds.length; i += BATCH_SIZE) {
|
||||||
|
const batchIds = allSubscriberIds.slice(i, i + BATCH_SIZE);
|
||||||
|
const subscribers = await Subscriber.findAll({
|
||||||
|
where: { id: { [Op.in]: batchIds } },
|
||||||
|
attributes: ['id', 'email'],
|
||||||
|
raw: true,
|
||||||
|
});
|
||||||
|
const smtpServers = await campaign.getSmtpServers();
|
||||||
|
await fillQueueForCampaign(campaign, subscribers, smtpServers);
|
||||||
|
}
|
||||||
|
// 4. Обновить статус кампании на sending
|
||||||
|
await campaign.update({ status: 'sending' });
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user