refactor: 移除邮件功能

发信能力整体下线,唯一的调用方(忘记密码)前端也从来没接过。

- utils/shortcuts.py 删 send_email,连带四个 django.core.mail 相关 import
- account/tasks.py 整个删除,里面只有 send_email_async 一个 actor
- 删 ApplyResetPasswordAPI / ResetPasswordAPI 及其路由、serializer,
  User.reset_password_token 和 reset_password_token_expire_time 两个
  字段(account/0010)
- 删 SMTPAPI / SMTPTestAPI 及其路由、三个 SMTP serializer、
  SysOptions.smtp_config
- account/templates/reset_password_email.html

options/0003 数据迁移删掉库里的 smtp_config 行:_init_option 会为每个
OptionKey 建行,key 从代码里去掉后它会变成孤儿,而且配过 SMTP 的话
value 里存着明文密码。

保留 ResetUserPasswordAPI(管理端直接重置密码,不发信,前端在用)和
User.email(注册要填)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 23:17:48 -06:00
parent 8d35d5c2de
commit 927f235537
13 changed files with 46 additions and 245 deletions

View File

@@ -0,0 +1,21 @@
# Generated by Django 6.0.4 on 2026-08-06 05:15
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('account', '0009_remove_user_tfa_token_remove_user_two_factor_auth'),
]
operations = [
migrations.RemoveField(
model_name='user',
name='reset_password_token',
),
migrations.RemoveField(
model_name='user',
name='reset_password_token_expire_time',
),
]

View File

@@ -36,8 +36,6 @@ class User(AbstractBaseUser):
# One of UserType # One of UserType
admin_type = models.TextField(default=AdminType.REGULAR_USER, choices=AdminType.choices) admin_type = models.TextField(default=AdminType.REGULAR_USER, choices=AdminType.choices)
problem_permission = models.TextField(default=ProblemPermission.NONE, choices=ProblemPermission.choices) problem_permission = models.TextField(default=ProblemPermission.NONE, choices=ProblemPermission.choices)
reset_password_token = models.TextField(null=True)
reset_password_token_expire_time = models.DateTimeField(null=True)
# SSO auth token # SSO auth token
auth_token = models.TextField(null=True) auth_token = models.TextField(null=True)
session_keys = JSONField(default=list, db_default=models.Value([], output_field=models.JSONField())) session_keys = JSONField(default=list, db_default=models.Value([], output_field=models.JSONField()))

View File

@@ -131,17 +131,6 @@ class EditUserProfileSerializer(serializers.Serializer):
language = serializers.CharField(max_length=32, allow_blank=True, required=False) language = serializers.CharField(max_length=32, allow_blank=True, required=False)
class ApplyResetPasswordSerializer(serializers.Serializer):
email = serializers.EmailField()
captcha = serializers.CharField()
class ResetPasswordSerializer(serializers.Serializer):
token = serializers.CharField()
password = serializers.CharField(min_length=6)
captcha = serializers.CharField()
class SSOSerializer(serializers.Serializer): class SSOSerializer(serializers.Serializer):
token = serializers.CharField() token = serializers.CharField()

View File

@@ -1,18 +0,0 @@
import logging
import dramatiq
from options.options import SysOptions
from utils.shortcuts import DRAMATIQ_WORKER_ARGS, send_email
logger = logging.getLogger(__name__)
@dramatiq.actor(**DRAMATIQ_WORKER_ARGS(max_retries=3))
def send_email_async(from_name, to_email, to_name, subject, content):
if not SysOptions.smtp_config:
return
try:
send_email(smtp_config=SysOptions.smtp_config, from_name=from_name, to_email=to_email, to_name=to_name, subject=subject, content=content)
except Exception as e:
logger.exception(e)

View File

@@ -1,31 +0,0 @@
<div>
<table cellpadding="0" align="center"
style="overflow:hidden;background:#fff;margin:0 auto;text-align:left;position:relative;font-size:14px; font-family:'lucida Grande',Verdana;line-height:1.5;box-shadow:0 0 3px #ccc;border:1px solid #ccc;border-radius:5px;border-collapse:collapse;">
<tbody>
<tr>
<th valign="middle"
style="height:38px;color:#fff; font-size:14px;line-height:38px; font-weight:bold;text-align:left;padding:10px 24px 6px; border-bottom:1px solid #467ec3;background:#518bcb;border-radius:5px 5px 0 0;">
{{ website_name }}</th>
</tr>
<tr>
<td>
<div style="padding:20px 35px 40px;">
<h2 style="font-weight:bold;margin-bottom:5px;font-size:14px;">Hello, {{ username }}:</h2>
<p style="margin-top:20px">
Please click <a href="{{ link }}">{{ link }}</a> to reset your password in 20 minutes.
</p>
<p style="margin-top:20px">
To protect your account, please do not use simple passwords.
</p>
<p style="margin-top:20px">
If you still have any questions, please contract system administrator.
</p>
<p style="margin-left:2em;"></p>
<p style="text-indent:0;text-align:right;">{{ website_name }}</p>
</div>
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -4,12 +4,10 @@ from utils.captcha.views import CaptchaAPIView
from ..views.oj import ( from ..views.oj import (
SSOAPI, SSOAPI,
ApplyResetPasswordAPI,
AvatarUploadAPI, AvatarUploadAPI,
Metrics, Metrics,
OpenAPIAppkeyAPI, OpenAPIAppkeyAPI,
ProfileProblemDisplayIDRefreshAPI, ProfileProblemDisplayIDRefreshAPI,
ResetPasswordAPI,
SessionManagementAPI, SessionManagementAPI,
UserActivityRankAPI, UserActivityRankAPI,
UserChangeEmailAPI, UserChangeEmailAPI,
@@ -29,8 +27,6 @@ urlpatterns = [
path("register", UserRegisterAPI.as_view()), path("register", UserRegisterAPI.as_view()),
path("change_password", UserChangePasswordAPI.as_view()), # DEPRECATED: 前端未调用 path("change_password", UserChangePasswordAPI.as_view()), # DEPRECATED: 前端未调用
path("change_email", UserChangeEmailAPI.as_view()), # DEPRECATED: 前端未调用 path("change_email", UserChangeEmailAPI.as_view()), # DEPRECATED: 前端未调用
path("apply_reset_password", ApplyResetPasswordAPI.as_view()), # DEPRECATED: 前端未调用
path("reset_password", ResetPasswordAPI.as_view()), # DEPRECATED: 前端未调用
path("captcha", CaptchaAPIView.as_view()), path("captcha", CaptchaAPIView.as_view()),
path("check_username_or_email", UsernameOrEmailCheck.as_view()), # DEPRECATED: 前端未调用 path("check_username_or_email", UsernameOrEmailCheck.as_view()), # DEPRECATED: 前端未调用
path("profile", UserProfileAPI.as_view(), name="user_profile_api"), path("profile", UserProfileAPI.as_view(), name="user_profile_api"),

View File

@@ -1,15 +1,12 @@
import asyncio import asyncio
import os import os
from datetime import timedelta
from importlib import import_module from importlib import import_module
from django.conf import settings from django.conf import settings
from django.contrib import auth from django.contrib import auth
from django.db.models import Count, Q from django.db.models import Count, Q
from django.template.loader import render_to_string
from django.utils import timezone from django.utils import timezone
from django.utils.decorators import method_decorator from django.utils.decorators import method_decorator
from django.utils.timezone import now
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from options.options import SysOptions from options.options import SysOptions
@@ -24,11 +21,9 @@ from utils.shortcuts import datetime2str, rand_str
from ..decorators import login_required from ..decorators import login_required
from ..models import AdminType, User, UserProfile from ..models import AdminType, User, UserProfile
from ..serializers import ( from ..serializers import (
ApplyResetPasswordSerializer,
EditUserProfileSerializer, EditUserProfileSerializer,
ImageUploadForm, ImageUploadForm,
RankInfoSerializer, RankInfoSerializer,
ResetPasswordSerializer,
SSOSerializer, SSOSerializer,
UserChangeEmailSerializer, UserChangeEmailSerializer,
UserChangePasswordSerializer, UserChangePasswordSerializer,
@@ -37,7 +32,6 @@ from ..serializers import (
UserProfileSerializer, UserProfileSerializer,
UserRegisterSerializer, UserRegisterSerializer,
) )
from ..tasks import send_email_async
class UserProfileAPI(AsyncAPIView): class UserProfileAPI(AsyncAPIView):
@@ -216,61 +210,6 @@ class UserChangePasswordAPI(APIView):
return self.error("Invalid old password") return self.error("Invalid old password")
# DEPRECATED: 前端未调用 (2026-05-26)
class ApplyResetPasswordAPI(APIView):
@validate_serializer(ApplyResetPasswordSerializer)
def post(self, request):
if request.user.is_authenticated:
return self.error("You have already logged in, are you kidding me? ")
data = request.data
captcha = Captcha(request)
if not captcha.check(data["captcha"]):
return self.error("Invalid captcha")
try:
user = User.objects.get(email__iexact=data["email"])
except User.DoesNotExist:
return self.error("User does not exist")
if user.reset_password_token_expire_time and 0 < int((user.reset_password_token_expire_time - now()).total_seconds()) < 20 * 60:
return self.error("You can only reset password once per 20 minutes")
user.reset_password_token = rand_str()
user.reset_password_token_expire_time = now() + timedelta(minutes=20)
user.save()
render_data = {
"username": user.username,
"website_name": SysOptions.website_name,
"link": f"{SysOptions.website_base_url}/reset-password/{user.reset_password_token}",
}
email_html = render_to_string("reset_password_email.html", render_data)
send_email_async.send(
from_name=SysOptions.website_name_shortcut,
to_email=user.email,
to_name=user.username,
subject="Reset your password",
content=email_html,
)
return self.success("Succeeded")
# DEPRECATED: 前端未调用 (2026-05-26)
class ResetPasswordAPI(APIView):
@validate_serializer(ResetPasswordSerializer)
def post(self, request):
data = request.data
captcha = Captcha(request)
if not captcha.check(data["captcha"]):
return self.error("Invalid captcha")
try:
user = User.objects.get(reset_password_token=data["token"])
except User.DoesNotExist:
return self.error("Token does not exist")
if user.reset_password_token_expire_time < now():
return self.error("Token has expired")
user.reset_password_token = None
user.set_password(data["password"])
user.save()
return self.success("Succeeded")
# DEPRECATED: 前端未调用 (2026-05-26) # DEPRECATED: 前端未调用 (2026-05-26)
class SessionManagementAPI(APIView): class SessionManagementAPI(APIView):
@login_required @login_required

View File

@@ -3,22 +3,6 @@ from utils.api import serializers
from .models import JudgeServer from .models import JudgeServer
class EditSMTPConfigSerializer(serializers.Serializer):
server = serializers.CharField(max_length=128)
port = serializers.IntegerField(default=25)
email = serializers.CharField(max_length=256)
password = serializers.CharField(max_length=128, required=False, allow_null=True, allow_blank=True)
tls = serializers.BooleanField()
class CreateSMTPConfigSerializer(EditSMTPConfigSerializer):
password = serializers.CharField(max_length=128)
class TestSMTPConfigSerializer(serializers.Serializer):
email = serializers.EmailField()
class CreateEditWebsiteConfigSerializer(serializers.Serializer): class CreateEditWebsiteConfigSerializer(serializers.Serializer):
website_base_url = serializers.CharField(max_length=128) website_base_url = serializers.CharField(max_length=128)
website_name = serializers.CharField(max_length=64) website_name = serializers.CharField(max_length=64)

View File

@@ -1,18 +1,14 @@
from django.urls import path from django.urls import path
from ..views import ( from ..views import (
SMTPAPI,
DashboardInfoAPI, DashboardInfoAPI,
JudgeServerAPI, JudgeServerAPI,
RandomUsernameAPI, RandomUsernameAPI,
SMTPTestAPI,
TestCasePruneAPI, TestCasePruneAPI,
WebsiteConfigAPI, WebsiteConfigAPI,
) )
urlpatterns = [ urlpatterns = [
path("smtp", SMTPAPI.as_view()), # DEPRECATED: 前端未调用
path("smtp_test", SMTPTestAPI.as_view()), # DEPRECATED: 前端未调用
path("website", WebsiteConfigAPI.as_view()), path("website", WebsiteConfigAPI.as_view()),
path("random_user", RandomUsernameAPI.as_view()), path("random_user", RandomUsernameAPI.as_view()),
path("judge_server", JudgeServerAPI.as_view()), path("judge_server", JudgeServerAPI.as_view()),

View File

@@ -4,7 +4,6 @@ import os
import random import random
import re import re
import shutil import shutil
import smtplib
from datetime import timedelta from datetime import timedelta
from asgiref.sync import sync_to_async from asgiref.sync import sync_to_async
@@ -20,83 +19,19 @@ from problem.models import Problem
from submission.models import Submission from submission.models import Submission
from utils.api import APIView, AsyncAPIView, CSRFExemptAPIView, validate_serializer from utils.api import APIView, AsyncAPIView, CSRFExemptAPIView, validate_serializer
from utils.cache import JsonDataLoader from utils.cache import JsonDataLoader
from utils.shortcuts import get_env, send_email from utils.shortcuts import get_env
from utils.websocket import push_config_update from utils.websocket import push_config_update
from utils.xss_filter import XSSHtml from utils.xss_filter import XSSHtml
from .models import JudgeServer from .models import JudgeServer
from .serializers import ( from .serializers import (
CreateEditWebsiteConfigSerializer, CreateEditWebsiteConfigSerializer,
CreateSMTPConfigSerializer,
EditJudgeServerSerializer, EditJudgeServerSerializer,
EditSMTPConfigSerializer,
JudgeServerHeartbeatSerializer, JudgeServerHeartbeatSerializer,
JudgeServerSerializer, JudgeServerSerializer,
TestSMTPConfigSerializer,
) )
# DEPRECATED: 前端未调用 (2026-05-26)
class SMTPAPI(APIView):
@super_admin_required
def get(self, request):
smtp = SysOptions.smtp_config
if not smtp:
return self.success(None)
smtp.pop("password")
return self.success(smtp)
@super_admin_required
@validate_serializer(CreateSMTPConfigSerializer)
def post(self, request):
SysOptions.smtp_config = request.data
return self.success()
@super_admin_required
@validate_serializer(EditSMTPConfigSerializer)
def put(self, request):
smtp = SysOptions.smtp_config
data = request.data
for item in ["server", "port", "email", "tls"]:
smtp[item] = data[item]
if "password" in data:
smtp["password"] = data["password"]
SysOptions.smtp_config = smtp
return self.success()
# DEPRECATED: 前端未调用 (2026-05-26)
class SMTPTestAPI(APIView):
@super_admin_required
@validate_serializer(TestSMTPConfigSerializer)
def post(self, request):
if not SysOptions.smtp_config:
return self.error("Please setup SMTP config at first")
try:
send_email(
smtp_config=SysOptions.smtp_config,
from_name=SysOptions.website_name_shortcut,
to_name=request.user.username,
to_email=request.data["email"],
subject="You have successfully configured SMTP",
content="You have successfully configured SMTP",
)
except smtplib.SMTPResponseException as e:
# guess error message encoding
msg = b"Failed to send email"
try:
msg = e.smtp_error
# qq mail
msg = msg.decode("gbk")
except Exception:
msg = msg.decode("utf-8", "ignore")
return self.error(msg)
except Exception as e:
msg = str(e)
return self.error(msg)
return self.success()
class WebsiteConfigAPI(AsyncAPIView): class WebsiteConfigAPI(AsyncAPIView):
async def get(self, request): async def get(self, request):
ret = await SysOptions.aget_many( ret = await SysOptions.aget_many(

View File

@@ -0,0 +1,24 @@
# 邮件功能已整体移除smtp_config 不再有读写方。_init_option 建过的那一行会留在
# 库里成为孤儿,且里面存着明文 SMTP 密码,这里一并删掉。
from django.db import migrations
def remove_smtp_config(apps, schema_editor):
SysOptions = apps.get_model("options", "SysOptions")
SysOptions.objects.filter(key="smtp_config").delete()
def restore_smtp_config(apps, schema_editor):
SysOptions = apps.get_model("options", "SysOptions")
SysOptions.objects.get_or_create(key="smtp_config", defaults={"value": {}})
class Migration(migrations.Migration):
dependencies = [
("options", "0002_add_sql_language"),
]
operations = [
migrations.RunPython(remove_smtp_config, restore_smtp_config),
]

View File

@@ -102,7 +102,6 @@ class OptionKeys:
allow_register = "allow_register" allow_register = "allow_register"
submission_list_show_all = "submission_list_show_all" submission_list_show_all = "submission_list_show_all"
class_list = "class_list" class_list = "class_list"
smtp_config = "smtp_config"
judge_server_token = "judge_server_token" judge_server_token = "judge_server_token"
throttling = "throttling" throttling = "throttling"
languages = "languages" languages = "languages"
@@ -117,7 +116,6 @@ class OptionDefaultValue:
allow_register = True allow_register = True
submission_list_show_all = True submission_list_show_all = True
class_list = [] class_list = []
smtp_config = {}
judge_server_token = default_token judge_server_token = default_token
throttling = {"ip": {"capacity": 100, "fill_rate": 0.1, "default_capacity": 50}, "user": {"capacity": 20, "fill_rate": 0.03, "default_capacity": 10}} throttling = {"ip": {"capacity": 100, "fill_rate": 0.1, "default_capacity": 50}, "user": {"capacity": 20, "fill_rate": 0.03, "default_capacity": 10}}
languages = languages languages = languages
@@ -242,14 +240,6 @@ class _SysOptionsMeta(type):
def class_list(cls, value): def class_list(cls, value):
cls._set_option(OptionKeys.class_list, value) cls._set_option(OptionKeys.class_list, value)
@my_property
def smtp_config(cls):
return cls._get_option(OptionKeys.smtp_config)
@smtp_config.setter
def smtp_config(cls, value):
cls._set_option(OptionKeys.smtp_config, value)
@my_property(ttl=DEFAULT_SHORT_TTL) @my_property(ttl=DEFAULT_SHORT_TTL)
def judge_server_token(cls): def judge_server_token(cls):
return cls._get_option(OptionKeys.judge_server_token) return cls._get_option(OptionKeys.judge_server_token)

View File

@@ -2,12 +2,9 @@ import os
import random import random
import re import re
from base64 import b64encode from base64 import b64encode
from email.utils import formataddr
from io import BytesIO from io import BytesIO
from django.core.mail import EmailMultiAlternatives, get_connection
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.html import strip_tags
def rand_str(length=32, type="lower_hex"): def rand_str(length=32, type="lower_hex"):
@@ -63,25 +60,6 @@ def natural_sort_key(s, _nsre=re.compile(r"(\d+)")):
return [int(text) if text.isdigit() else text.lower() for text in re.split(_nsre, s)] return [int(text) if text.isdigit() else text.lower() for text in re.split(_nsre, s)]
def send_email(smtp_config, from_name, to_email, to_name, subject, content):
connection = get_connection(
host=smtp_config["server"],
port=smtp_config["port"],
username=smtp_config["email"],
password=smtp_config["password"],
use_tls=smtp_config["tls"],
)
message = EmailMultiAlternatives(
subject=subject,
body=strip_tags(content),
from_email=formataddr((from_name, smtp_config["email"])),
to=[formataddr((to_name, to_email))],
connection=connection,
)
message.attach_alternative(content, "text/html")
return message.send()
def get_env(name, default=""): def get_env(name, default=""):
return os.environ.get(name, default) return os.environ.get(name, default)