feat(auth): подтверждение email + актуализация SMTP/Ollama конфигов
- Резенд письма верификации (/auth/resend-verification), модалка на фронте с поллингом статуса, страница /verify-email/:token - SMTP переведён на собственный Postfix (mail.jze9mail.ru, STARTTLS, SMTP_TLS_VERIFY) вместо Yandex-заглушки в дефолтах и .env.example - OLLAMA_URL и модель в worker-gpu синхронизированы с новым GPU-хостом (llama3:8b -> qwen2.5:7b, которой раньше не было на сервере) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
11
.env.example
11
.env.example
@@ -28,12 +28,13 @@ OLLAMA_URL=http://ollama:11434
|
|||||||
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
|
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
ACCESS_TOKEN_EXPIRE_MINUTES=10080
|
||||||
|
|
||||||
# SMTP (Yandex)
|
# SMTP (собственный Postfix+Dovecot, mail.jze9mail.ru, STARTTLS)
|
||||||
SMTP_HOST=smtp.yandex.ru
|
SMTP_HOST=mail.jze9mail.ru
|
||||||
SMTP_PORT=465
|
SMTP_PORT=587
|
||||||
SMTP_USER=noreply@jze9.ru
|
SMTP_USER=noreply
|
||||||
SMTP_PASSWORD=changeme
|
SMTP_PASSWORD=changeme
|
||||||
SMTP_FROM=noreply@jze9.ru
|
SMTP_FROM=noreply@jze9mail.ru
|
||||||
|
SMTP_TLS_VERIFY=true
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_URL=https://academic.jze9.ru
|
APP_URL=https://academic.jze9.ru
|
||||||
|
|||||||
@@ -98,6 +98,37 @@ async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken:
|
|||||||
return UserInToken.model_validate(current_user)
|
return UserInToken.model_validate(current_user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def resend_verification(
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""Повторно отправить письмо с подтверждением email."""
|
||||||
|
if current_user.is_verified:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Email уже подтверждён",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not current_user.verification_token:
|
||||||
|
current_user.verification_token = secrets.token_urlsafe(32)
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(current_user)
|
||||||
|
|
||||||
|
try:
|
||||||
|
celery_app.send_task(
|
||||||
|
"notify.send_verification",
|
||||||
|
args=[current_user.email, current_user.name, current_user.verification_token],
|
||||||
|
queue="queue.notify",
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Не удалось поставить задачу повторной верификации: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Не удалось отправить письмо, попробуйте позже",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK)
|
@router.post("/verify-email/{token}", status_code=status.HTTP_200_OK)
|
||||||
async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
|
async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
|
||||||
"""Подтвердить email по токену из письма."""
|
"""Подтвердить email по токену из письма."""
|
||||||
|
|||||||
@@ -45,11 +45,12 @@ class Settings(BaseSettings):
|
|||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней
|
||||||
|
|
||||||
# SMTP
|
# SMTP
|
||||||
SMTP_HOST: str = "smtp.yandex.ru"
|
SMTP_HOST: str = "mail.jze9mail.ru"
|
||||||
SMTP_PORT: int = 465
|
SMTP_PORT: int = 587
|
||||||
SMTP_USER: str = "noreply@jze9.ru"
|
SMTP_USER: str = "noreply"
|
||||||
SMTP_PASSWORD: str = "changeme"
|
SMTP_PASSWORD: str = "changeme"
|
||||||
SMTP_FROM: str = "noreply@jze9.ru"
|
SMTP_FROM: str = "noreply@jze9mail.ru"
|
||||||
|
SMTP_TLS_VERIFY: bool = True
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_URL: str = "https://academic.jze9.ru"
|
APP_URL: str = "https://academic.jze9.ru"
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export const authApi = {
|
|||||||
|
|
||||||
verifyEmail: (token: string) =>
|
verifyEmail: (token: string) =>
|
||||||
api.post(`/auth/verify-email/${token}`),
|
api.post(`/auth/verify-email/${token}`),
|
||||||
|
|
||||||
|
resendVerification: () =>
|
||||||
|
api.post('/auth/resend-verification'),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tasksApi = {
|
export const tasksApi = {
|
||||||
|
|||||||
126
services/frontend/src/components/EmailVerificationModal.tsx
Normal file
126
services/frontend/src/components/EmailVerificationModal.tsx
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
|
import { Mail, X, CheckCircle2, AlertCircle } from 'lucide-react';
|
||||||
|
import toast from 'react-hot-toast';
|
||||||
|
import { authApi } from '../api/client';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
export function EmailVerificationModal() {
|
||||||
|
const { user, updateUser } = useAuthStore();
|
||||||
|
const location = useLocation();
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
const [verified, setVerified] = useState(false);
|
||||||
|
|
||||||
|
// Не показывать на странице подтверждения — там своя UI
|
||||||
|
if (location.pathname.startsWith('/verify-email')) return null;
|
||||||
|
|
||||||
|
const resend = useMutation({
|
||||||
|
mutationFn: () => authApi.resendVerification(),
|
||||||
|
onSuccess: () => toast.success('Письмо отправлено, проверьте почту'),
|
||||||
|
onError: () => toast.error('Не удалось отправить письмо'),
|
||||||
|
});
|
||||||
|
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['email-verification-poll'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await authApi.me();
|
||||||
|
if (res.data.is_verified) {
|
||||||
|
updateUser({ is_verified: true });
|
||||||
|
setVerified(true);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
},
|
||||||
|
enabled: !!(user && !user.is_verified && !dismissed && !verified),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
staleTime: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || dismissed) return null;
|
||||||
|
if (!user.is_verified && !verified) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8">
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex justify-center mb-5">
|
||||||
|
<div className="p-4 bg-amber-50 rounded-full">
|
||||||
|
<Mail className="w-8 h-8 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 text-center mb-1">
|
||||||
|
Подтвердите ваш email
|
||||||
|
</h2>
|
||||||
|
<p className="text-center text-sm text-gray-400 mb-6">{user.email}</p>
|
||||||
|
|
||||||
|
{/* Статус */}
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-6 px-4 py-3 bg-amber-50 rounded-xl">
|
||||||
|
<AlertCircle className="w-4 h-4 text-amber-500 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-amber-700">Email не подтверждён</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 text-center mb-6 leading-relaxed">
|
||||||
|
Мы отправили письмо на{' '}
|
||||||
|
<span className="font-medium text-gray-800">{user.email}</span>.
|
||||||
|
Нажмите кнопку «Подтвердить» в письме для активации аккаунта.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => resend.mutate()}
|
||||||
|
disabled={resend.isPending}
|
||||||
|
className="w-full py-3 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{resend.isPending ? 'Отправляем...' : 'Подтвердить'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="w-full mt-3 py-2.5 text-sm text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
>
|
||||||
|
Напомнить позже
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (verified) {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
|
||||||
|
<div className="relative w-full max-w-md mx-4 bg-white rounded-2xl shadow-2xl p-8 text-center">
|
||||||
|
<div className="flex justify-center mb-5">
|
||||||
|
<div className="p-4 bg-green-50 rounded-full">
|
||||||
|
<CheckCircle2 className="w-8 h-8 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 mb-2">Email подтверждён!</h2>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center gap-2 mb-6 px-4 py-3 bg-green-50 rounded-xl">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500 shrink-0" />
|
||||||
|
<span className="text-sm font-medium text-green-700">Email подтверждён</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Аккаунт активирован. Теперь вы получаете уведомления о завершении задач.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="w-full py-3 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors"
|
||||||
|
>
|
||||||
|
Закрыть
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Link, NavLink, useNavigate } from 'react-router-dom';
|
|||||||
import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react';
|
import { GraduationCap, Search, BookOpen, Upload, LayoutDashboard, LogOut, User, BookMarked } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { useAuthStore } from '../store/auth';
|
import { useAuthStore } from '../store/auth';
|
||||||
|
import { EmailVerificationModal } from './EmailVerificationModal';
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -144,6 +145,8 @@ export function Layout({ children }: LayoutProps) {
|
|||||||
<main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<main className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<EmailVerificationModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Task } from './pages/Task';
|
|||||||
import { Pricing } from './pages/Pricing';
|
import { Pricing } from './pages/Pricing';
|
||||||
import { Login } from './pages/Login';
|
import { Login } from './pages/Login';
|
||||||
import { Register } from './pages/Register';
|
import { Register } from './pages/Register';
|
||||||
|
import { VerifyEmail } from './pages/VerifyEmail';
|
||||||
import { AdminLayout } from './pages/admin/AdminLayout';
|
import { AdminLayout } from './pages/admin/AdminLayout';
|
||||||
import { Dashboard } from './pages/admin/Dashboard';
|
import { Dashboard } from './pages/admin/Dashboard';
|
||||||
import { Users as AdminUsers } from './pages/admin/Users';
|
import { Users as AdminUsers } from './pages/admin/Users';
|
||||||
@@ -48,6 +49,7 @@ function PublicApp() {
|
|||||||
<Route path="/pricing" element={<Pricing />} />
|
<Route path="/pricing" element={<Pricing />} />
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/verify-email/:token" element={<VerifyEmail />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
|
|||||||
83
services/frontend/src/pages/VerifyEmail.tsx
Normal file
83
services/frontend/src/pages/VerifyEmail.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
|
import { useMutation } from '@tanstack/react-query';
|
||||||
|
import { CheckCircle2, XCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { authApi } from '../api/client';
|
||||||
|
import { useAuthStore } from '../store/auth';
|
||||||
|
|
||||||
|
export function VerifyEmail() {
|
||||||
|
const { token } = useParams<{ token: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { updateUser } = useAuthStore();
|
||||||
|
|
||||||
|
const verify = useMutation({
|
||||||
|
mutationFn: () => authApi.verifyEmail(token!),
|
||||||
|
onSuccess: () => {
|
||||||
|
updateUser({ is_verified: true });
|
||||||
|
setTimeout(() => navigate('/cabinet'), 3000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) verify.mutate();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-md mx-auto pt-16 text-center">
|
||||||
|
<div className="bg-white rounded-2xl border border-gray-100 p-10">
|
||||||
|
{verify.isPending && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-center mb-5">
|
||||||
|
<Loader2 className="w-14 h-14 text-brand-600 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-gray-900 mb-2">Подтверждаем email…</h1>
|
||||||
|
<p className="text-sm text-gray-500">Пожалуйста, подождите</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verify.isSuccess && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-center mb-5">
|
||||||
|
<div className="p-4 bg-green-50 rounded-full">
|
||||||
|
<CheckCircle2 className="w-14 h-14 text-green-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Email подтверждён!</h1>
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 bg-green-50 rounded-xl mb-4">
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-sm font-medium text-green-700">Email подтверждён</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
Аккаунт активирован. Перенаправляем в личный кабинет…
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{verify.isError && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-center mb-5">
|
||||||
|
<div className="p-4 bg-red-50 rounded-full">
|
||||||
|
<XCircle className="w-14 h-14 text-red-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-2">Ошибка подтверждения</h1>
|
||||||
|
<div className="inline-flex items-center gap-2 px-4 py-2 bg-red-50 rounded-xl mb-4">
|
||||||
|
<XCircle className="w-4 h-4 text-red-500" />
|
||||||
|
<span className="text-sm font-medium text-red-700">Email не подтверждён</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-500 mb-6">
|
||||||
|
Ссылка недействительна или устарела. Запросите новое письмо в личном кабинете.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/cabinet"
|
||||||
|
className="inline-block px-6 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors"
|
||||||
|
>
|
||||||
|
В личный кабинет
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ class OllamaClient:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.base_url = settings.OLLAMA_URL
|
self.base_url = settings.OLLAMA_URL
|
||||||
self.model = "llama3:8b"
|
self.model = "qwen2.5:7b"
|
||||||
self.timeout = 60.0 # секунд
|
self.timeout = 60.0 # секунд
|
||||||
|
|
||||||
def check_paraphrase(self, text_a: str, text_b: str) -> dict:
|
def check_paraphrase(self, text_a: str, text_b: str) -> dict:
|
||||||
|
|||||||
@@ -24,11 +24,12 @@ class Settings(BaseSettings):
|
|||||||
RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/"
|
RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/"
|
||||||
|
|
||||||
# SMTP
|
# SMTP
|
||||||
SMTP_HOST: str = "smtp.yandex.ru"
|
SMTP_HOST: str = "mail.jze9mail.ru"
|
||||||
SMTP_PORT: int = 465
|
SMTP_PORT: int = 587
|
||||||
SMTP_USER: str = "noreply@jze9.ru"
|
SMTP_USER: str = "noreply"
|
||||||
SMTP_PASSWORD: str = "changeme"
|
SMTP_PASSWORD: str = "changeme"
|
||||||
SMTP_FROM: str = "noreply@jze9.ru"
|
SMTP_FROM: str = "noreply@jze9mail.ru"
|
||||||
|
SMTP_TLS_VERIFY: bool = True
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_URL: str = "https://academic.jze9.ru"
|
APP_URL: str = "https://academic.jze9.ru"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import ssl
|
||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
|
|
||||||
@@ -37,9 +38,22 @@ class EmailSender:
|
|||||||
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
msg.attach(MIMEText(html_body, "html", "utf-8"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
|
tls_ctx = ssl.create_default_context()
|
||||||
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
if not settings.SMTP_TLS_VERIFY:
|
||||||
smtp.send_message(msg)
|
tls_ctx.check_hostname = False
|
||||||
|
tls_ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
|
||||||
|
if settings.SMTP_PORT == 465:
|
||||||
|
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT, context=tls_ctx) as smtp:
|
||||||
|
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
smtp.send_message(msg)
|
||||||
|
else:
|
||||||
|
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
|
||||||
|
smtp.ehlo()
|
||||||
|
smtp.starttls(context=tls_ctx)
|
||||||
|
smtp.ehlo()
|
||||||
|
smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
||||||
|
smtp.send_message(msg)
|
||||||
logger.info(f"Email отправлен: {to_email!r}, тема: {subject!r}")
|
logger.info(f"Email отправлен: {to_email!r}, тема: {subject!r}")
|
||||||
except smtplib.SMTPException as e:
|
except smtplib.SMTPException as e:
|
||||||
logger.error(f"SMTP ошибка при отправке письма на {to_email!r}: {e}")
|
logger.error(f"SMTP ошибка при отправке письма на {to_email!r}: {e}")
|
||||||
|
|||||||
Reference in New Issue
Block a user