feat(mail): 云端联系人表与发信自动存储 + 联系人查询接口
- 新增 MailContact 模型(用户+邮箱唯一),迁移 0020 - MailJmapView 发信成功后自动保存收件人为常用联系人 - 新增 /API/Mail/Contacts 列表/搜索/新增接口;同步部署产物 index.html
This commit is contained in:
1 parent
4ad6403358
commit
9bc6819296
6 files changed
+169
-7
No files matched your search
@@ -5,16 +5,16 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>GERP 管理系统</title>
|
||||
<script type="module" crossorigin src="/assets/index-0_YjFHoJ.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-HdkpV1Kx.js"></script>
|
||||
<link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-DAkcZGNC.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/element-plus-DpxT9tdZ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/utils-C0vl14oO.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-DLnR_7e_.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vxe-table-CuF36J0U.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/formkit-BcsXCkSB.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vue-vendor-DLgebo11.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vendor-D8wgd3LJ.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vxe-table-CuIll8tr.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/formkit-od3HK-2F.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/vue-vendor-CXHjmsgU.js">
|
||||
<link rel="modulepreload" crossorigin href="/assets/serverScope-C6yhSV7-.js">
|
||||
<link rel="stylesheet" crossorigin href="/assets/style-BY2RAvcv.css">
|
||||
<link rel="stylesheet" crossorigin href="/assets/style-BrS5HC6e.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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 和关联属性访问
|
||||
|
||||
@@ -131,6 +131,7 @@ from .mail import (
|
||||
MailLoginView,
|
||||
MailLogoutView,
|
||||
MailJmapView,
|
||||
MailContactsView,
|
||||
MailAdminListView,
|
||||
MailDisableView,
|
||||
)
|
||||
@@ -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}})
|
||||
@@ -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'),
|
||||
|
||||
|
||||
Reference in new issue
Block a user