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:
jze9
2026-07-23 18:37:22 +05:00
parent 9894fa9320
commit c1cf1ddd2f
11 changed files with 282 additions and 17 deletions

View File

@@ -28,12 +28,13 @@ OLLAMA_URL=http://ollama:11434
SECRET_KEY=change-me-in-production-use-openssl-rand-hex-32
ACCESS_TOKEN_EXPIRE_MINUTES=10080
# SMTP (Yandex)
SMTP_HOST=smtp.yandex.ru
SMTP_PORT=465
SMTP_USER=noreply@jze9.ru
# SMTP (собственный Postfix+Dovecot, mail.jze9mail.ru, STARTTLS)
SMTP_HOST=mail.jze9mail.ru
SMTP_PORT=587
SMTP_USER=noreply
SMTP_PASSWORD=changeme
SMTP_FROM=noreply@jze9.ru
SMTP_FROM=noreply@jze9mail.ru
SMTP_TLS_VERIFY=true
# App
APP_URL=https://academic.jze9.ru

View File

@@ -98,6 +98,37 @@ async def get_me(current_user: User = Depends(get_current_user)) -> UserInToken:
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)
async def verify_email(token: str, db: AsyncSession = Depends(get_db)) -> dict:
"""Подтвердить email по токену из письма."""

View File

@@ -45,11 +45,12 @@ class Settings(BaseSettings):
ACCESS_TOKEN_EXPIRE_MINUTES: int = 10080 # 7 дней
# SMTP
SMTP_HOST: str = "smtp.yandex.ru"
SMTP_PORT: int = 465
SMTP_USER: str = "noreply@jze9.ru"
SMTP_HOST: str = "mail.jze9mail.ru"
SMTP_PORT: int = 587
SMTP_USER: str = "noreply"
SMTP_PASSWORD: str = "changeme"
SMTP_FROM: str = "noreply@jze9.ru"
SMTP_FROM: str = "noreply@jze9mail.ru"
SMTP_TLS_VERIFY: bool = True
# App
APP_URL: str = "https://academic.jze9.ru"

View File

@@ -40,6 +40,9 @@ export const authApi = {
verifyEmail: (token: string) =>
api.post(`/auth/verify-email/${token}`),
resendVerification: () =>
api.post('/auth/resend-verification'),
};
export const tasksApi = {

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

View File

@@ -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 { clsx } from 'clsx';
import { useAuthStore } from '../store/auth';
import { EmailVerificationModal } from './EmailVerificationModal';
interface LayoutProps {
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">
{children}
</main>
<EmailVerificationModal />
</div>
);
}

View File

@@ -14,6 +14,7 @@ import { Task } from './pages/Task';
import { Pricing } from './pages/Pricing';
import { Login } from './pages/Login';
import { Register } from './pages/Register';
import { VerifyEmail } from './pages/VerifyEmail';
import { AdminLayout } from './pages/admin/AdminLayout';
import { Dashboard } from './pages/admin/Dashboard';
import { Users as AdminUsers } from './pages/admin/Users';
@@ -48,6 +49,7 @@ function PublicApp() {
<Route path="/pricing" element={<Pricing />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/verify-email/:token" element={<VerifyEmail />} />
</Routes>
</Layout>
);

View 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>
);
}

View File

@@ -15,7 +15,7 @@ class OllamaClient:
def __init__(self) -> None:
self.base_url = settings.OLLAMA_URL
self.model = "llama3:8b"
self.model = "qwen2.5:7b"
self.timeout = 60.0 # секунд
def check_paraphrase(self, text_a: str, text_b: str) -> dict:

View File

@@ -24,11 +24,12 @@ class Settings(BaseSettings):
RABBITMQ_URL: str = "amqp://guest:guest@rabbitmq:5672/"
# SMTP
SMTP_HOST: str = "smtp.yandex.ru"
SMTP_PORT: int = 465
SMTP_USER: str = "noreply@jze9.ru"
SMTP_HOST: str = "mail.jze9mail.ru"
SMTP_PORT: int = 587
SMTP_USER: str = "noreply"
SMTP_PASSWORD: str = "changeme"
SMTP_FROM: str = "noreply@jze9.ru"
SMTP_FROM: str = "noreply@jze9mail.ru"
SMTP_TLS_VERIFY: bool = True
# App
APP_URL: str = "https://academic.jze9.ru"

View File

@@ -2,6 +2,7 @@
import logging
import smtplib
import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@@ -37,7 +38,20 @@ class EmailSender:
msg.attach(MIMEText(html_body, "html", "utf-8"))
try:
with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
tls_ctx = ssl.create_default_context()
if not settings.SMTP_TLS_VERIFY:
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}")