unsubscribe

This commit is contained in:
romantarkin 2025-08-19 12:49:12 +05:00
parent aeb0ffc0d2
commit e36042e24d
7 changed files with 507 additions and 3 deletions

View File

@ -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() {
<Router>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/unsubscribe" element={<UnsubscribePage />} />
<Route
path="/dashboard"
element={

View File

@ -25,6 +25,7 @@ code {
:root {
--background-color: #ffffff;
--text-color: #000000;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--card-background: #ffffff;
--hover-background: #f9fafb;
@ -35,6 +36,7 @@ code {
[data-theme="dark"] {
--background-color: #1a1a1a;
--text-color: #ffffff;
--text-secondary: #a0a0a0;
--border-color: #333333;
--card-background: #2d2d2d;
--hover-background: #3a3a3a;

View File

@ -0,0 +1,124 @@
import React, { useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import styles from './UnsubscribePage.module.css';
import ThemeToggle from '../components/ThemeToggle';
const UnsubscribePage = () => {
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 (
<div className={styles.wrapper}>
<div className={styles.themeToggleContainer}>
<ThemeToggle />
</div>
<div className={styles.form}>
<div className={styles.successIcon}></div>
<h2 className={styles.title}>Отписка выполнена</h2>
<p className={styles.message}>
Вы успешно отписались от рассылки. Мы больше не будем отправлять вам письма.
</p>
<p className={styles.emailInfo}>
Email: <strong>{email}</strong>
</p>
<p className={styles.note}>
Если вы передумали, вы всегда можете подписаться снова, обратившись к нам.
</p>
</div>
</div>
);
}
return (
<div className={styles.wrapper}>
<div className={styles.themeToggleContainer}>
<ThemeToggle />
</div>
<form onSubmit={handleSubmit} className={styles.form}>
<h2 className={styles.title}>Отписаться от рассылки</h2>
<p className={styles.description}>
Введите ваш email адрес, чтобы отписаться от получения наших писем
</p>
<div className={styles.inputGroup}>
<label htmlFor="email" className={styles.label}>
Email адрес
</label>
<input
id="email"
type="email"
placeholder="your@email.com"
value={email}
onChange={handleEmailChange}
required
className={styles.input}
disabled={loading}
autoComplete="email"
/>
</div>
{error && <div className={styles.error}>{error}</div>}
<button type="submit" className={styles.button} disabled={loading}>
{loading ? 'Отписываемся...' : 'Отписаться'}
</button>
<div className={styles.info}>
<p> Отписка вступает в силу немедленно</p>
<p> Вы больше не будете получать наши письма</p>
<p> Ваши данные будут сохранены для предотвращения повторной рассылки</p>
</div>
</form>
</div>
);
};
export default UnsubscribePage;

View File

@ -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;
}
}

View File

@ -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 });
}
},
};

View File

@ -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;

View File

@ -265,22 +265,38 @@ async function processEmailTask(task, topic) {
// Добавляем трекинг-пиксель для отслеживания открытия письма
const trackingPixel = `<img src="https://${domain}/api/mail/track/open/${deliveryLog.id}" width="1" height="1" style="display:none;" />`;
// Обрабатываем ссылки для отслеживания кликов
// Обрабатываем ссылки для отслеживания кликов (исключаем ссылки отписки)
const htmlWithClickTracking = task.html.replace(
/<a\s+href=["']([^"']+)["']/gi,
(match, url) => {
// Не отслеживаем ссылки отписки
if (url.includes('/unsubscribe')) {
return match;
}
const trackingUrl = `https://${domain}/api/mail/track/click/${deliveryLog.id}?url=${encodeURIComponent(url)}`;
return `<a href="${trackingUrl}"`;
}
);
const htmlWithTracking = htmlWithClickTracking + trackingPixel;
// Добавляем ссылку для отписки в конец письма
const unsubscribeLink = `
<div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; font-size: 12px; color: #6b7280;">
<p style="margin: 0 0 10px 0;">Если вы больше не хотите получать наши письма, вы можете
<a href="https://${domain}/unsubscribe?email=${encodeURIComponent(task.email)}" style="color: #ef4444; text-decoration: none;">отписаться от рассылки</a>.
</p>
</div>
`;
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': `<mailto:${smtp.from_email}?subject=unsubscribe>`,