From 9bc681929673d2a792c3e8bdb9d129a25a33bd14 Mon Sep 17 00:00:00 2001 From: TermiNexus Date: Sat, 8 Aug 2026 19:53:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(mail):=20=E4=BA=91=E7=AB=AF=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E8=A1=A8=E4=B8=8E=E5=8F=91=E4=BF=A1=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=AD=98=E5=82=A8=20+=20=E8=81=94=E7=B3=BB=E4=BA=BA?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MailContact 模型(用户+邮箱唯一),迁移 0020 - MailJmapView 发信成功后自动保存收件人为常用联系人 - 新增 /API/Mail/Contacts 列表/搜索/新增接口;同步部署产物 index.html --- TempLate/GERP/index.html | 14 +-- .../apps/gerp/migrations/0020_mail_contact.py | 39 +++++++++ gvsds/apps/gerp/models.py | 34 ++++++++ gvsds/apps/gerp/views/__init__.py | 1 + gvsds/apps/gerp/views/mail.py | 87 +++++++++++++++++++ gvsds/urls.py | 1 + 6 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 gvsds/apps/gerp/migrations/0020_mail_contact.py diff --git a/TempLate/GERP/index.html b/TempLate/GERP/index.html index 8211923f..3276305f 100644 --- a/TempLate/GERP/index.html +++ b/TempLate/GERP/index.html @@ -5,16 +5,16 @@ GERP 管理系统 - + - + - - - - + + + + - +
diff --git a/gvsds/apps/gerp/migrations/0020_mail_contact.py b/gvsds/apps/gerp/migrations/0020_mail_contact.py new file mode 100644 index 00000000..5bb18ece --- /dev/null +++ b/gvsds/apps/gerp/migrations/0020_mail_contact.py @@ -0,0 +1,39 @@ +"""Add MailContact + +邮箱联系人表——发件时自动存储常用联系人(云端 SQL 存储,按 用户+邮箱 去重)。 +""" +import datetime +from django.db import migrations, models +import gvsds.apps.core.models +import gvsds.apps.gerp.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('gerp', '0019_user_mailbox'), + ] + + operations = [ + migrations.CreateModel( + name='MailContact', + fields=[ + ('mail_contact_uuid', gvsds.apps.core.models.UUIDField(db_column='MailContactUUID', default=gvsds.apps.gerp.models._NewUuidBytes, primary_key=True, serialize=False, verbose_name='联系人UUID')), + ('user_uuid', models.ForeignKey(db_column='UserUUID', db_index=True, on_delete=models.CASCADE, to='core.user', verbose_name='归属用户UUID')), + ('contact_name', models.CharField(blank=True, db_column='ContactName', default='', max_length=128, verbose_name='联系人名称')), + ('contact_email', models.CharField(db_column='ContactEmail', db_index=True, max_length=256, verbose_name='联系人邮箱')), + ('create_time', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow, verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, db_column='UpdateTime', verbose_name='更新时间')), + ], + options={ + 'verbose_name': '邮箱联系人', + 'verbose_name_plural': '邮箱联系人', + 'db_table': 'MailContact', + 'ordering': ['-UpdateTime'], + }, + ), + migrations.AddConstraint( + model_name='mailcontact', + constraint=models.UniqueConstraint(fields=('user_uuid', 'contact_email'), name='uniq_user_contact_email'), + ), + ] diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py index 09f1f744..8d9421e1 100644 --- a/gvsds/apps/gerp/models.py +++ b/gvsds/apps/gerp/models.py @@ -13,10 +13,16 @@ from gvsdsdk.model_base import QModel import datetime +import uuid from django.db import models from gvsds.apps.core.models import UUIDField, User +def _NewUuidBytes(): + """生成随机 16 字节 UUID(主键默认值,迁移可序列化)。""" + return uuid.uuid4().bytes + + class Tenant(QModel): """租户表——多租户架构核心""" @@ -712,6 +718,34 @@ class UserMailbox(QModel): return self.MailboxName +class MailContact(QModel): + """邮箱联系人表——发件时自动存储常用联系人(云端 SQL 存储) + + 发信成功后自动把收件人存入本表(按 用户+邮箱 去重), + 写信时前端按关键字匹配下拉选择;联系人名取自联系人显示名(可后续编辑)。 + """ + + MailContactUUID = UUIDField(primary_key=True, default=_NewUuidBytes, verbose_name='联系人UUID') + UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, db_column='UserUUID', db_index=True, verbose_name='归属用户UUID') + ContactName = models.CharField(max_length=128, default='', blank=True, verbose_name='联系人名称') + ContactEmail = models.CharField(max_length=256, db_index=True, verbose_name='联系人邮箱') + CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间') + UpdateTime = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'MailContact' + app_label = 'gerp' + ordering = ['-UpdateTime'] + constraints = [ + models.UniqueConstraint(fields=['UserUUID', 'ContactEmail'], name='uniq_user_contact_email'), + ] + verbose_name = '邮箱联系人' + verbose_name_plural = '邮箱联系人' + + def __str__(self): + return self.ContactEmail + + # ==================== 从 gvsdsdk 导入重复模型 ==================== # 以下模型原在 gerp/models.py 定义,现统一依赖 gvsdsdk.models(映射 SAAS 主库) # 注意:gvsdsdk 模型用 UUIDField(非 ForeignKey),不支持 select_related 和关联属性访问 diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py index ea027f8c..a9c9ef68 100644 --- a/gvsds/apps/gerp/views/__init__.py +++ b/gvsds/apps/gerp/views/__init__.py @@ -131,6 +131,7 @@ from .mail import ( MailLoginView, MailLogoutView, MailJmapView, + MailContactsView, MailAdminListView, MailDisableView, ) diff --git a/gvsds/apps/gerp/views/mail.py b/gvsds/apps/gerp/views/mail.py index 97bd0432..c0a8ece6 100644 --- a/gvsds/apps/gerp/views/mail.py +++ b/gvsds/apps/gerp/views/mail.py @@ -18,6 +18,7 @@ import re import secrets as _pysecrets import threading import time +import uuid import requests from rest_framework.permissions import IsAuthenticated @@ -83,6 +84,46 @@ def _RevokeMailToken(token): with _MAIL_TOKEN_LOCK: _MAIL_TOKEN_STORE.pop(token, None) + +def _AutoSaveContacts(user, method_calls, payload): + """发信成功(EmailSubmission/set 返回 created)时自动存储收件人到联系人表(云端 SQL)。 + + 按 callId 匹配 methodCalls 中的 EmailSubmission/set 与响应的 created, + 提取 envelope.rcptTo 邮箱,按 用户+邮箱 去重写入 MailContact。 + """ + try: + from ..models import MailContact + except Exception: + return + if not isinstance(method_calls, list) or not isinstance(payload, dict): + return + responses = payload.get('methodResponses') or [] + if not isinstance(responses, list): + return + for call in method_calls: + if not isinstance(call, list) or len(call) < 3 or call[0] != 'EmailSubmission/set': + continue + call_id = call[2] + for resp in responses: + if not isinstance(resp, list) or len(resp) < 3 or resp[2] != call_id: + continue + rdata = resp[1] if isinstance(resp[1], dict) else {} + if not rdata.get('created'): + continue + args = call[1] if isinstance(call[1], dict) else {} + for cobj in (args.get('create') or {}).values(): + if not isinstance(cobj, dict): + continue + for r in (cobj.get('envelope') or {}).get('rcptTo') or []: + email = (r.get('email') if isinstance(r, dict) else None) or '' + email = email.strip().lower() + if not email: + continue + try: + MailContact.objects.get_or_create(UserUUID=user, ContactEmail=email) + except Exception as e: + logger.warning('[Mail] 自动存储联系人失败 %s: %s', email, e) + # 邮箱本地部分:字母数字开头,含字母数字/点/下划线/连字符,总长 3~63 _LOCAL_PART_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{2,62}$') # 密码强度:8~64 位,至少含一个字母和一个数字 @@ -207,6 +248,7 @@ class MailApplyView(ELTAuthenticatedAPIView): principal_id = None UserMailbox.objects.create( + UserMailboxUUID=uuid.uuid4().bytes, UserUUID=request.user, MailboxName=email, PrincipalId=principal_id, @@ -443,4 +485,49 @@ class MailJmapView(ELTAuthenticatedAPIView): if resp.status_code >= 400: detail = payload.get('detail') if isinstance(payload, dict) else '' return Response({'code': 1, 'msg': f'邮件服务器错误(HTTP {resp.status_code}){detail}'}, status=502) + # 发信成功后自动存储收件人为常用联系人(云端 SQL) + _AutoSaveContacts(request.user, method_calls, payload) return Response({'code': 0, 'data': {'AccountId': account_id, 'Data': payload}}) + + +class MailContactsView(ELTAuthenticatedAPIView): + """邮箱联系人——列表/搜索 + 新增(发件时自动存储,前端写信匹配下拉)。 + + GET /API/Mail/Contacts?Search=xx - 我的联系人(支持按邮箱/名称模糊搜索) + POST /API/Mail/Contacts - 新增/更新联系人(ContactEmail 必填) + """ + + permission_classes = [IsAuthenticated] + + def get(self, request): + from django.db.models import Q + from gvsdsdk.model_utils import UUIDObj + from ..models import MailContact + qs = MailContact.objects.filter(UserUUID=request.user) + search = (request.GET.get('Search') or request.GET.get('search') or '').strip() + if search: + qs = qs.filter(Q(ContactEmail__icontains=search) | Q(ContactName__icontains=search)) + rows = list(qs.order_by('-UpdateTime')[:200]) + data = [{ + 'MailContactUUID': UUIDObj(c.MailContactUUID).str, + 'ContactName': c.ContactName or '', + 'ContactEmail': c.ContactEmail, + 'CreateTime': c.CreateTime.isoformat() if c.CreateTime else None, + } for c in rows] + return Response({'code': 0, 'data': {'list': data, 'total': len(data)}}) + + def post(self, request): + from ..models import MailContact + email = (request.data.get('ContactEmail') or '').strip().lower() + if not email: + return Response({'code': 1, 'msg': '缺少联系人邮箱'}, status=400) + name = (request.data.get('ContactName') or '').strip() + obj, created = MailContact.objects.get_or_create( + UserUUID=request.user, + ContactEmail=email, + defaults={'ContactName': name}, + ) + if not created and name and name != obj.ContactName: + obj.ContactName = name + obj.save(update_fields=['ContactName']) + return Response({'code': 0, 'data': {'Created': created}}) diff --git a/gvsds/urls.py b/gvsds/urls.py index cf0e1301..7dbae48e 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -169,6 +169,7 @@ urlpatterns = [ path('API/Mail/Login', gerp_views.MailLoginView.as_view(), name='mail_login'), path('API/Mail/Logout', gerp_views.MailLogoutView.as_view(), name='mail_logout'), path('API/Mail/Jmap', gerp_views.MailJmapView.as_view(), name='mail_jmap'), + path('API/Mail/Contacts', gerp_views.MailContactsView.as_view(), name='mail_contacts'), path('API/Mail/AdminList', gerp_views.MailAdminListView.as_view(), name='mail_admin_list'), path('API/Mail/Disable', gerp_views.MailDisableView.as_view(), name='mail_disable'),