diff --git a/.env.example b/.env.example
index f8aaa7e..fcdc8f3 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/services/api/app/api/auth.py b/services/api/app/api/auth.py
index 099e353..afd06ca 100644
--- a/services/api/app/api/auth.py
+++ b/services/api/app/api/auth.py
@@ -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 по токену из письма."""
diff --git a/services/api/app/config.py b/services/api/app/config.py
index 3698458..0cd313f 100644
--- a/services/api/app/config.py
+++ b/services/api/app/config.py
@@ -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"
diff --git a/services/frontend/src/api/client.ts b/services/frontend/src/api/client.ts
index 33fa9d0..261e3aa 100644
--- a/services/frontend/src/api/client.ts
+++ b/services/frontend/src/api/client.ts
@@ -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 = {
diff --git a/services/frontend/src/components/EmailVerificationModal.tsx b/services/frontend/src/components/EmailVerificationModal.tsx
new file mode 100644
index 0000000..645c3b1
--- /dev/null
+++ b/services/frontend/src/components/EmailVerificationModal.tsx
@@ -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 (
+
+
+
+
+
+
+
+ Подтвердите ваш email
+
+
{user.email}
+
+ {/* Статус */}
+
+
+
Email не подтверждён
+
+
+
+ Мы отправили письмо на{' '}
+ {user.email}.
+ Нажмите кнопку «Подтвердить» в письме для активации аккаунта.
+
+
+
+
+
+
+
+ );
+ }
+
+ if (verified) {
+ return (
+
+
+
+
+
Email подтверждён!
+
+
+
+ Email подтверждён
+
+
+
+ Аккаунт активирован. Теперь вы получаете уведомления о завершении задач.
+
+
+
+
+
+ );
+ }
+
+ return null;
+}
diff --git a/services/frontend/src/components/Layout.tsx b/services/frontend/src/components/Layout.tsx
index abe849c..8696a74 100644
--- a/services/frontend/src/components/Layout.tsx
+++ b/services/frontend/src/components/Layout.tsx
@@ -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) {
{children}
+
+
);
}
diff --git a/services/frontend/src/main.tsx b/services/frontend/src/main.tsx
index 3d0de64..d13083f 100644
--- a/services/frontend/src/main.tsx
+++ b/services/frontend/src/main.tsx
@@ -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() {
} />
} />
} />
+ } />
);
diff --git a/services/frontend/src/pages/VerifyEmail.tsx b/services/frontend/src/pages/VerifyEmail.tsx
new file mode 100644
index 0000000..b689255
--- /dev/null
+++ b/services/frontend/src/pages/VerifyEmail.tsx
@@ -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 (
+
+
+ {verify.isPending && (
+ <>
+
+
+
+
Подтверждаем email…
+
Пожалуйста, подождите
+ >
+ )}
+
+ {verify.isSuccess && (
+ <>
+
+
Email подтверждён!
+
+
+ Email подтверждён
+
+
+ Аккаунт активирован. Перенаправляем в личный кабинет…
+
+ >
+ )}
+
+ {verify.isError && (
+ <>
+
+
Ошибка подтверждения
+
+
+ Email не подтверждён
+
+
+ Ссылка недействительна или устарела. Запросите новое письмо в личном кабинете.
+
+
+ В личный кабинет
+
+ >
+ )}
+
+
+ );
+}
diff --git a/services/worker-gpu/app/ollama_client.py b/services/worker-gpu/app/ollama_client.py
index 6f853e9..ec4a369 100644
--- a/services/worker-gpu/app/ollama_client.py
+++ b/services/worker-gpu/app/ollama_client.py
@@ -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:
diff --git a/services/worker-notifier/app/config.py b/services/worker-notifier/app/config.py
index e58aa98..aae597f 100644
--- a/services/worker-notifier/app/config.py
+++ b/services/worker-notifier/app/config.py
@@ -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"
diff --git a/services/worker-notifier/app/email_sender.py b/services/worker-notifier/app/email_sender.py
index 11d9964..6b79464 100644
--- a/services/worker-notifier/app/email_sender.py
+++ b/services/worker-notifier/app/email_sender.py
@@ -2,6 +2,7 @@
import logging
import smtplib
+import ssl
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
@@ -37,9 +38,22 @@ class EmailSender:
msg.attach(MIMEText(html_body, "html", "utf-8"))
try:
- with smtplib.SMTP_SSL(settings.SMTP_HOST, settings.SMTP_PORT) as smtp:
- smtp.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
- smtp.send_message(msg)
+ 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}")
except smtplib.SMTPException as e:
logger.error(f"SMTP ошибка при отправке письма на {to_email!r}: {e}")