From e36042e24db73adb299a220b13314b876169af2a Mon Sep 17 00:00:00 2001 From: romantarkin Date: Tue, 19 Aug 2025 12:49:12 +0500 Subject: [PATCH] unsubscribe --- frontend/src/App.js | 2 + frontend/src/index.css | 2 + frontend/src/pages/UnsubscribePage.js | 124 ++++++++ frontend/src/pages/UnsubscribePage.module.css | 295 ++++++++++++++++++ .../src/controllers/subscriberController.js | 63 ++++ mail-service/src/routes/subscriber.js | 2 + mail-service/src/service/dynamicConsumer.js | 22 +- 7 files changed, 507 insertions(+), 3 deletions(-) create mode 100644 frontend/src/pages/UnsubscribePage.js create mode 100644 frontend/src/pages/UnsubscribePage.module.css diff --git a/frontend/src/App.js b/frontend/src/App.js index b990212..8809109 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -6,6 +6,7 @@ import UsersPage from './pages/UsersPage'; import SmtpServersPage from './pages/SmtpServersPage'; import EmailTemplatesPage from './pages/EmailTemplatesPage'; import UnsubscribedPage from './pages/UnsubscribedPage'; +import UnsubscribePage from './pages/UnsubscribePage'; import GroupsPage from './pages/GroupsPage'; import DeliveryHistoryPage from './pages/DeliveryHistoryPage'; import CampaignPage from './pages/CampaignPage'; @@ -27,6 +28,7 @@ function App() { } /> + } /> { + const [searchParams] = useSearchParams(); + const [email, setEmail] = useState(searchParams.get('email') || ''); + const [loading, setLoading] = useState(false); + const [success, setSuccess] = useState(false); + const [error, setError] = useState(''); + const [submitted, setSubmitted] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + if (!email.trim()) { + setError('Пожалуйста, введите email адрес'); + return; + } + + setLoading(true); + setError(''); + setSubmitted(true); + + try { + const response = await fetch('/api/subscribers/unsubscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email: email.trim() }), + }); + + const data = await response.json(); + + if (response.ok) { + setSuccess(true); + } else { + setError(data.error || 'Произошла ошибка при отписке'); + } + } catch (err) { + setError('Ошибка сети. Пожалуйста, попробуйте позже.'); + } finally { + setLoading(false); + } + }; + + const handleEmailChange = (e) => { + setEmail(e.target.value); + if (submitted) { + setError(''); + setSuccess(false); + setSubmitted(false); + } + }; + + if (success) { + return ( +
+
+ +
+
+
+

Отписка выполнена

+

+ Вы успешно отписались от рассылки. Мы больше не будем отправлять вам письма. +

+

+ Email: {email} +

+

+ Если вы передумали, вы всегда можете подписаться снова, обратившись к нам. +

+
+
+ ); + } + + return ( +
+
+ +
+
+

Отписаться от рассылки

+

+ Введите ваш email адрес, чтобы отписаться от получения наших писем +

+ +
+ + +
+ + {error &&
{error}
} + + + +
+

• Отписка вступает в силу немедленно

+

• Вы больше не будете получать наши письма

+

• Ваши данные будут сохранены для предотвращения повторной рассылки

+
+
+
+ ); +}; + +export default UnsubscribePage; \ No newline at end of file diff --git a/frontend/src/pages/UnsubscribePage.module.css b/frontend/src/pages/UnsubscribePage.module.css new file mode 100644 index 0000000..3c3550a --- /dev/null +++ b/frontend/src/pages/UnsubscribePage.module.css @@ -0,0 +1,295 @@ +.wrapper { + min-height: 100vh; + display: flex; + justify-content: center; + align-items: center; + background: linear-gradient(135deg, #e0e7ff 0%, #f0fdfa 100%); + padding: 16px; + position: relative; +} + +[data-theme="dark"] .wrapper { + background: linear-gradient(135deg, #1e1b4b 0%, #0f172a 100%); +} + +.themeToggleContainer { + position: absolute; + top: 20px; + right: 20px; +} + +.form { + display: flex; + flex-direction: column; + width: 400px; + max-width: 100%; + gap: 24px; + padding: 40px; + border-radius: 16px; + background: var(--card-background); + box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.15); + border: 1px solid var(--border-color); + transition: box-shadow 0.3s; +} + +.title { + margin: 0; + font-weight: 700; + font-size: 28px; + color: #6366f1; + text-align: center; + letter-spacing: 1px; +} + +.description { + margin: 0; + text-align: center; + color: var(--text-secondary); + font-size: 16px; + line-height: 1.5; +} + +.inputGroup { + display: flex; + flex-direction: column; + gap: 8px; +} + +.label { + font-weight: 600; + color: var(--text-color); + font-size: 14px; +} + +.input { + padding: 12px 14px; + border-radius: 8px; + border: 1px solid var(--border-color); + font-size: 16px; + outline: none; + transition: border 0.2s; + background: var(--card-background); + color: var(--text-color); + min-height: 48px; +} + +.input:focus { + border: 1.5px solid #6366f1; + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1); +} + +.button { + padding: 12px 0; + border-radius: 8px; + border: none; + background: linear-gradient(90deg, #ef4444 0%, #dc2626 100%); + color: #fff; + font-weight: 600; + font-size: 18px; + cursor: pointer; + box-shadow: 0 2px 8px 0 rgba(239, 68, 68, 0.2); + transition: all 0.2s; + min-height: 48px; +} + +.button:hover { + background: linear-gradient(90deg, #dc2626 0%, #b91c1c 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px 0 rgba(239, 68, 68, 0.3); +} + +.button:disabled { + opacity: 0.7; + cursor: not-allowed; + transform: none; +} + +.error { + color: #ef4444; + text-align: center; + font-size: 15px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + padding: 12px; + margin: 0; +} + +.info { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 8px; + padding: 16px; + margin: 0; +} + +[data-theme="dark"] .info { + background: #1e293b; + border-color: #334155; +} + +.info p { + margin: 0 0 8px 0; + font-size: 14px; + color: var(--text-secondary); + line-height: 1.4; +} + +.info p:last-child { + margin-bottom: 0; +} + +/* Success state styles */ +.successIcon { + width: 64px; + height: 64px; + border-radius: 50%; + background: linear-gradient(90deg, #10b981 0%, #059669 100%); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-size: 32px; + font-weight: bold; + margin: 0 auto 16px; + box-shadow: 0 4px 12px 0 rgba(16, 185, 129, 0.3); +} + +.message { + margin: 0; + text-align: center; + color: var(--text-color); + font-size: 16px; + line-height: 1.5; +} + +.emailInfo { + margin: 0; + text-align: center; + color: var(--text-secondary); + font-size: 14px; + padding: 12px; + background: #f1f5f9; + border-radius: 6px; +} + +[data-theme="dark"] .emailInfo { + background: #334155; +} + +.note { + margin: 0; + text-align: center; + color: var(--text-secondary); + font-size: 14px; + font-style: italic; + line-height: 1.4; +} + +/* Адаптивные стили для планшетов */ +@media (max-width: 1024px) { + .form { + width: 380px; + padding: 36px; + } + + .title { + font-size: 26px; + } + + .input { + font-size: 15px; + padding: 10px 12px; + } + + .button { + font-size: 17px; + } +} + +/* Адаптивные стили для мобильных устройств */ +@media (max-width: 768px) { + .wrapper { + padding: 12px; + align-items: flex-start; + padding-top: 60px; + } + + .themeToggleContainer { + top: 16px; + right: 16px; + } + + .form { + width: 100%; + max-width: 450px; + padding: 32px 24px; + border-radius: 12px; + } + + .title { + font-size: 24px; + } + + .input { + font-size: 16px; + padding: 12px 14px; + } + + .button { + font-size: 18px; + } + + .successIcon { + width: 56px; + height: 56px; + font-size: 28px; + } +} + +@media (max-width: 480px) { + .wrapper { + padding: 8px; + padding-top: 40px; + } + + .themeToggleContainer { + top: 12px; + right: 12px; + } + + .form { + padding: 28px 20px; + border-radius: 10px; + } + + .title { + font-size: 22px; + } + + .input { + font-size: 15px; + padding: 10px 12px; + } + + .button { + font-size: 16px; + } + + .successIcon { + width: 48px; + height: 48px; + font-size: 24px; + } +} + +/* Улучшенная поддержка touch устройств */ +@media (hover: none) and (pointer: coarse) { + .input { + min-height: 52px; + } + + .button { + min-height: 52px; + } +} \ No newline at end of file diff --git a/mail-service/src/controllers/subscriberController.js b/mail-service/src/controllers/subscriberController.js index 85efa10..89ae797 100644 --- a/mail-service/src/controllers/subscriberController.js +++ b/mail-service/src/controllers/subscriberController.js @@ -81,4 +81,67 @@ export default { res.status(500).json({ error: err.message }); } }, + async unsubscribe(req, res) { + try { + const { id } = req.params; + const subscriber = await Subscriber.findByPk(id); + + if (!subscriber) { + return res.status(404).json({ error: 'Subscriber not found' }); + } + + // Обновляем статус на "unsubscribed" и устанавливаем время отписки + await subscriber.update({ + status: 'unsubscribed', + unsubscribed_at: new Date() + }); + + res.json({ + message: 'Subscriber successfully unsubscribed', + subscriber: { + id: subscriber.id, + email: subscriber.email, + name: subscriber.name, + status: subscriber.status, + unsubscribed_at: subscriber.unsubscribed_at + } + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }, + async unsubscribeByEmail(req, res) { + try { + const { email } = req.body; + + if (!email) { + return res.status(400).json({ error: 'Email is required' }); + } + + const subscriber = await Subscriber.findOne({ where: { email } }); + + if (!subscriber) { + return res.status(404).json({ error: 'Subscriber not found' }); + } + + // Обновляем статус на "unsubscribed" и устанавливаем время отписки + await subscriber.update({ + status: 'unsubscribed', + unsubscribed_at: new Date() + }); + + res.json({ + message: 'Subscriber successfully unsubscribed', + subscriber: { + id: subscriber.id, + email: subscriber.email, + name: subscriber.name, + status: subscriber.status, + unsubscribed_at: subscriber.unsubscribed_at + } + }); + } catch (err) { + res.status(500).json({ error: err.message }); + } + }, }; \ No newline at end of file diff --git a/mail-service/src/routes/subscriber.js b/mail-service/src/routes/subscriber.js index 6f2e1d3..f8f15d6 100644 --- a/mail-service/src/routes/subscriber.js +++ b/mail-service/src/routes/subscriber.js @@ -8,5 +8,7 @@ router.get('/', subscriberController.getAll); router.get('/:id', subscriberController.getById); router.put('/:id', subscriberController.update); router.delete('/:id', subscriberController.delete); +router.patch('/:id/unsubscribe', subscriberController.unsubscribe); +router.post('/unsubscribe', subscriberController.unsubscribeByEmail); export default router; \ No newline at end of file diff --git a/mail-service/src/service/dynamicConsumer.js b/mail-service/src/service/dynamicConsumer.js index c63e869..38aa4af 100644 --- a/mail-service/src/service/dynamicConsumer.js +++ b/mail-service/src/service/dynamicConsumer.js @@ -265,22 +265,38 @@ async function processEmailTask(task, topic) { // Добавляем трекинг-пиксель для отслеживания открытия письма const trackingPixel = ``; - // Обрабатываем ссылки для отслеживания кликов + // Обрабатываем ссылки для отслеживания кликов (исключаем ссылки отписки) const htmlWithClickTracking = task.html.replace( / { + // Не отслеживаем ссылки отписки + if (url.includes('/unsubscribe')) { + return match; + } const trackingUrl = `https://${domain}/api/mail/track/click/${deliveryLog.id}?url=${encodeURIComponent(url)}`; return ` +

Если вы больше не хотите получать наши письма, вы можете + отписаться от рассылки. +

+ + `; + + const htmlWithTracking = htmlWithClickTracking + trackingPixel + unsubscribeLink; + + // Добавляем ссылку для отписки в текстовую версию письма + const textWithUnsubscribe = task.text + `\n\n---\nЕсли вы больше не хотите получать наши письма, вы можете отписаться от рассылки: https://${domain}/unsubscribe?email=${encodeURIComponent(task.email)}`; const mailOptions = { from: smtp.from_email, to: task.email, subject: task.subject, - text: task.text, + text: textWithUnsubscribe, html: htmlWithTracking, headers: { 'List-Unsubscribe': ``,