feat(mail): 新增 Stalwart 员工邮箱管理与 JMAP 收发代理

- 新增 UserMailbox 模型与 0019 迁移(密码不落库,仅存 Stalwart)

- 新增 StalwartMailManager 服务层(v0.11 /api/principal 管理 API)

- 新增 /API/Mail 六个接口:Session/Apply/ChangePassword/MyMailbox/Jmap/AdminList/Disable

- JMAP 收发统一经后端代理转发,避免浏览器跨域

- 修复 form_service 误导入已删除的实例方法(_BuildFieldMap 等)
This commit is contained in:
TermiNexus committed 2026-08-08 12:59:50 +08:00
1 parent 3d41d2a209
commit 0b1d71499e
7 files changed
+558 -3

No files matched your search

-3
View File
@@ -20,9 +20,6 @@ from gvsds.apps.core.models import User, UserAvatar, User2FA, UserPending2FA
from gvsdsdk.form_service import (
FormServiceBase,
_PascalizeRecord,
_BuildFieldMap,
_ResolveFieldName,
_ConvertValueForField,
)
from gvsdsdk.order.models import Order, OrderItem, OrderLevel, OrderTemplate, OrderTemplateItem
from gvsds.apps.gerp.i18n import translate_field, translate_table
@@ -0,0 +1,36 @@
"""Add UserMailbox
用户邮箱表——记录员工与 Stalwart 邮箱账户的映射
(申请邮箱时在 Stalwart 创建 individual principal,本表记录归属与配额)。
"""
import datetime
from django.db import migrations, models
import gvsds.apps.core.models
class Migration(migrations.Migration):
dependencies = [
('gerp', '0018_workgroup_type_tags'),
]
operations = [
migrations.CreateModel(
name='UserMailbox',
fields=[
('user_mailbox_uuid', gvsds.apps.core.models.UUIDField(db_column='UserMailboxUUID', primary_key=True, serialize=False)),
('user_uuid', models.ForeignKey(db_column='UserUUID', db_index=True, on_delete=models.CASCADE, to='core.user')),
('mailbox_name', models.CharField(db_column='MailboxName', max_length=256, unique=True)),
('principal_id', models.IntegerField(blank=True, db_column='PrincipalId', null=True)),
('quota_mb', models.IntegerField(db_column='QuotaMB', default=1024)),
('status', models.SmallIntegerField(db_column='Status', default=1)),
('create_time', models.DateTimeField(db_column='CreateTime', default=datetime.datetime.utcnow)),
],
options={
'verbose_name': '用户邮箱',
'verbose_name_plural': '用户邮箱',
'db_table': 'UserMailbox',
'ordering': ['-CreateTime'],
},
),
]
+26
View File
@@ -686,6 +686,32 @@ class AbnormalUserLog(QModel):
return f'{self.AttemptType}:{self.IPAddress} @ {self.CreateTime}'
class UserMailbox(QModel):
"""用户邮箱表——记录员工与 Stalwart 邮箱账户的映射
申请邮箱时在 Stalwart 创建 individual principal(xxx@gvsds.com),
本表记录归属关系与配额;密码不落库(员工自设,仅存于 Stalwart)。
"""
UserMailboxUUID = UUIDField(primary_key=True, verbose_name='邮箱记录UUID')
UserUUID = models.ForeignKey(User, on_delete=models.CASCADE, db_column='UserUUID', db_index=True, verbose_name='用户UUID')
MailboxName = models.CharField(max_length=256, unique=True, verbose_name='邮箱名称')
PrincipalId = models.IntegerField(null=True, blank=True, verbose_name='Stalwart账户ID')
QuotaMB = models.IntegerField(default=1024, verbose_name='配额(MB)')
Status = models.SmallIntegerField(default=1, verbose_name='状态(1启用/0禁用)')
CreateTime = models.DateTimeField(default=datetime.datetime.utcnow, verbose_name='创建时间')
class Meta:
db_table = 'UserMailbox'
app_label = 'gerp'
ordering = ['-CreateTime']
verbose_name = '用户邮箱'
verbose_name_plural = '用户邮箱'
def __str__(self):
return self.MailboxName
# ==================== 从 gvsdsdk 导入重复模型 ====================
# 以下模型原在 gerp/models.py 定义,现统一依赖 gvsdsdk.models(映射 SAAS 主库)
# 注意:gvsdsdk 模型用 UUIDField(非 ForeignKey),不支持 select_related 和关联属性访问
+135
View File
@@ -0,0 +1,135 @@
"""模块:Stalwart 邮件服务管理
通过 Stalwart v0.11 管理 REST API(/api/principal)管理员工邮箱账户。
配置来自 secrets.ini 的 [stalwart] 段(BASE_URL / API_KEY / DOMAIN)。
已验证的 API 契约(v0.11):
- 创建:POST /api/principal
{"type":"individual","name":"<邮箱>","quota":<字节>,"description":"...",
"secrets":["<密码>"],"emails":["<邮箱>"],"roles":["user"]} -> {"data": <id>}
- 改密:PATCH /api/principal/<邮箱>
[{"action":"set","field":"secrets","value":"<新密码>"}]
- 删除:DELETE /api/principal/<邮箱>
- 查询:GET /api/principal/<邮箱> / GET /api/principal
认证:Authorization: Basic base64(<API_KEY>),API_KEY 形如 meowapi:MeowSecret2026。
"""
import base64
import logging
import requests
from gvsds import secrets as app_secrets
logger = logging.getLogger(__name__)
class StalwartMailError(Exception):
"""Stalwart 管理 API 调用失败。"""
def __init__(self, status_code, message):
super().__init__(message)
self.status_code = status_code
class StalwartMailManager:
"""Stalwart v0.11 邮箱账户管理服务层。"""
@staticmethod
def _BaseUrl():
return app_secrets.get('stalwart', 'BASE_URL', 'https://email.gvsds.com').rstrip('/')
@staticmethod
def _ApiKey():
return app_secrets.get('stalwart', 'API_KEY', '')
@staticmethod
def _Domain():
return app_secrets.get('stalwart', 'DOMAIN', 'gvsds.com')
@classmethod
def _AuthHeader(cls):
token = base64.b64encode(cls._ApiKey().encode('utf-8')).decode('ascii')
return {'Authorization': f'Basic {token}'}
@classmethod
def _Request(cls, method, path, **kwargs):
"""调用 Stalwart 管理 API,非 2xx 一律抛 StalwartMailError。"""
url = f'{cls._BaseUrl()}{path}'
headers = cls._AuthHeader()
headers.setdefault('Content-Type', 'application/json')
try:
resp = requests.request(method, url, headers=headers, timeout=15, **kwargs)
except requests.RequestException as e:
logger.error('[Stalwart] %s %s 请求失败: %s', method, path, e)
raise StalwartMailError(0, f'连接邮件服务器失败: {e}') from e
try:
payload = resp.json()
except ValueError:
payload = {}
if resp.status_code >= 400:
detail = payload.get('detail') if isinstance(payload, dict) else str(payload)
logger.error('[Stalwart] %s %s -> %s %s', method, path, resp.status_code, detail)
raise StalwartMailError(resp.status_code, detail or f'HTTP {resp.status_code}')
return payload
@classmethod
def CreateMailbox(cls, name, password, quota_mb=1024, description=''):
"""创建邮箱账户,返回 Stalwart principal id。
name 为邮箱本地部分或完整邮箱;quota_mb 为配额(MB)。
"""
domain = cls._Domain()
email = f'{name}@{domain}' if '@' not in name else name
payload = {
'type': 'individual',
'name': email,
'quota': quota_mb * 1024 * 1024,
'description': description or '',
'secrets': [password],
'emails': [email],
'roles': ['user'],
}
return cls._Request('POST', '/api/principal', json=payload)
@classmethod
def UpdatePassword(cls, email, new_password):
"""修改邮箱密码。"""
body = [{'action': 'set', 'field': 'secrets', 'value': new_password}]
return cls._Request('PATCH', f'/api/principal/{email}', json=body)
@classmethod
def DeleteMailbox(cls, email):
"""删除邮箱账户。"""
return cls._Request('DELETE', f'/api/principal/{email}')
@classmethod
def GetMailbox(cls, email):
"""查询单个邮箱账户,返回 dict 或 None(不存在)。"""
try:
return cls._Request('GET', f'/api/principal/{email}')
except StalwartMailError as e:
if e.status_code == 404:
return None
raise
@classmethod
def ListMailboxes(cls):
"""列出所有邮箱账户 principal(含域名/API key,按 items 过滤 individual)。"""
payload = cls._Request('GET', '/api/principal')
data = payload.get('data', {})
items = data.get('items', []) if isinstance(data, dict) else []
return [it for it in items if isinstance(it, dict)]
@classmethod
def CheckMailboxExists(cls, email):
"""邮箱账户是否已存在。"""
return cls.GetMailbox(email) is not None
@classmethod
def SessionConfig(cls):
"""返回前端浏览器直连 JMAP 所需的连接信息。"""
return {
'ApiUrl': f'{cls._BaseUrl()}/jmap/',
'DiscoverUrl': f'{cls._BaseUrl()}/.well-known/jmap',
'Domain': cls._Domain(),
}
+9
View File
@@ -123,3 +123,12 @@ from .payment_config import (
PaymentConfigDeleteView,
)
from .dashboard import DashboardStatsView
from .mail import (
MailSessionView,
MailApplyView,
MailChangePasswordView,
MailMyMailboxView,
MailJmapView,
MailAdminListView,
MailDisableView,
)
+343
View File
@@ -0,0 +1,343 @@
"""模块:GERP 邮件视图(Stalwart 邮箱)
提供员工邮箱的自助申请、改密、我的邮箱信息、JMAP 收发代理,以及管理员的列表/禁用。
URL 路径最后一段作为 action 名称,无需前端额外传 Action 字段。
支持 action:
Session - JMAP 连接配置
Apply - 员工申请邮箱(自设密码 + 可配置配额)
ChangePassword - 修改邮箱密码
MyMailbox - 我的邮箱信息
Jmap - JMAP 收发代理(前端统一走后端转发,避免浏览器跨域)
AdminList - 管理员:全部员工邮箱列表
Disable - 管理员:禁用邮箱(删除 Stalwart 账户)
"""
import base64
import logging
import re
import requests
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from gvsds import secrets as app_secrets
from gvsdsdk.model_utils import UUIDObj
from gvsdsdk.auth.permissions import IsAdminUser
from . import ELTAuthenticatedAPIView
from ..models import UserMailbox
from ..stalwart import StalwartMailManager, StalwartMailError
logger = logging.getLogger(__name__)
# 邮箱本地部分:字母数字开头,含字母数字/点/下划线/连字符,总长 3~63
_LOCAL_PART_RE = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9._-]{2,62}$')
# 密码强度:8~64 位,至少含一个字母和一个数字
_PASSWORD_RE = re.compile(r'^(?=.*[A-Za-z])(?=.*\d).{8,64}$')
_QUOTA_MIN_MB = 1
_QUOTA_MAX_MB = 10240
def _GetUserBytes(request):
"""取当前用户的 UserUUID 原始字节。"""
user = request.user
if user is None:
return None
val = getattr(user, 'UserUUID', None)
if val is None:
return None
if isinstance(val, bytes):
return val
try:
return UUIDObj(val).bytes
except Exception:
return None
def _UserCompanyNames(user_bytes):
"""返回用户所属公司名称列表(经 UserRole 关联)。"""
from gvsdsdk.models import UserRole as SdkUserRole, Company as SdkCompany
roles = SdkUserRole.objects.filter(UserUUID=user_bytes).values_list('CompanyUUID', flat=True)
comp_uuids = [c for c in roles if c]
if not comp_uuids:
return []
return list(SdkCompany.objects.filter(CompanyUUID__in=comp_uuids).values_list('CompanyName', flat=True))
def _CheckCompanyAllowed(user_bytes):
"""校验用户所属公司是否允许申请邮箱。
secrets.ini [stalwart] ALLOW_COMPANY 为空时仅要求用户属于任一公司;
配置了公司名(逗号分隔)时要求匹配其中之一。
"""
names = _UserCompanyNames(user_bytes)
if not names:
return False, '当前用户未归属任何公司,无法申请邮箱'
allowed = app_secrets.get('stalwart', 'ALLOW_COMPANY', '').strip()
if not allowed:
return True, ''
allowed_list = [n.strip() for n in allowed.split(',') if n.strip()]
if any(n in allowed_list for n in names):
return True, ''
return False, f'仅限 {allowed} 公司的员工申请邮箱'
class MailSessionView(ELTAuthenticatedAPIView):
"""返回浏览器直连 JMAP 所需的连接配置。"""
permission_classes = [IsAuthenticated]
def get(self, request):
return Response({'code': 0, 'data': StalwartMailManager.SessionConfig()})
class MailApplyView(ELTAuthenticatedAPIView):
"""员工自助申请邮箱(xxx@gvsds.com)。"""
permission_classes = [IsAuthenticated]
def post(self, request):
user_bytes = _GetUserBytes(request)
if not user_bytes:
return Response({'code': 1, 'msg': '无法识别当前用户'}, status=401)
# 每个用户最多一个邮箱
if UserMailbox.objects.filter(UserUUID=request.user).exists():
return Response({'code': 1, 'msg': '您已拥有邮箱,一个用户只能申请一个'}, status=400)
mailbox_name = (request.data.get('MailboxName') or '').strip()
password = request.data.get('Password') or ''
if not _LOCAL_PART_RE.match(mailbox_name):
return Response({'code': 1, 'msg': '邮箱名需为字母/数字/点/下划线/连字符,长度 3~63'}, status=400)
if not _PASSWORD_RE.match(password):
return Response({'code': 1, 'msg': '密码需 8~64 位且同时包含字母和数字'}, status=400)
try:
quota_mb = int(request.data.get('QuotaMB') or 1024)
except (TypeError, ValueError):
quota_mb = 1024
if not (_QUOTA_MIN_MB <= quota_mb <= _QUOTA_MAX_MB):
return Response({'code': 1, 'msg': f'配额需在 {_QUOTA_MIN_MB}~{_QUOTA_MAX_MB} MB 之间'}, status=400)
# 公司归属校验
ok, err = _CheckCompanyAllowed(user_bytes)
if not ok:
return Response({'code': 1, 'msg': err}, status=403)
domain = StalwartMailManager._Domain()
email = f'{mailbox_name}@{domain}'
# 唯一性校验(本表 + Stalwart 侧)
if UserMailbox.objects.filter(MailboxName=email).exists():
return Response({'code': 1, 'msg': '该邮箱已被申请'}, status=400)
try:
if StalwartMailManager.CheckMailboxExists(email):
return Response({'code': 1, 'msg': '该邮箱在邮件服务器已存在'}, status=400)
except StalwartMailError as e:
logger.error('[Mail] 检查邮箱存在失败: %s', e)
return Response({'code': 1, 'msg': f'邮件服务器异常: {e}'}, status=502)
# 创建 Stalwart 账户
try:
result = StalwartMailManager.CreateMailbox(
email, password, quota_mb=quota_mb, description=f'{getattr(request.user, "UserName", "")} 申请邮箱'
)
except StalwartMailError as e:
logger.error('[Mail] 创建邮箱失败: %s', e)
return Response({'code': 1, 'msg': f'创建邮箱失败: {e}'}, status=502)
principal_id = None
if isinstance(result, dict) and 'data' in result:
try:
principal_id = int(result['data'])
except (TypeError, ValueError):
principal_id = None
UserMailbox.objects.create(
UserUUID=request.user,
MailboxName=email,
PrincipalId=principal_id,
QuotaMB=quota_mb,
Status=1,
)
return Response({
'code': 0,
'msg': '申请成功',
'data': {'MailboxName': email, 'QuotaMB': quota_mb},
}, status=201)
class MailChangePasswordView(ELTAuthenticatedAPIView):
"""修改自己的邮箱密码。"""
permission_classes = [IsAuthenticated]
def post(self, request):
mailbox = UserMailbox.objects.filter(UserUUID=request.user, Status=1).first()
if mailbox is None:
return Response({'code': 1, 'msg': '您尚未申请邮箱'}, status=404)
new_password = request.data.get('NewPassword') or ''
if not _PASSWORD_RE.match(new_password):
return Response({'code': 1, 'msg': '新密码需 8~64 位且同时包含字母和数字'}, status=400)
try:
StalwartMailManager.UpdatePassword(mailbox.MailboxName, new_password)
except StalwartMailError as e:
logger.error('[Mail] 修改密码失败: %s', e)
return Response({'code': 1, 'msg': f'修改密码失败: {e}'}, status=502)
return Response({'code': 0, 'msg': '密码修改成功'})
class MailMyMailboxView(ELTAuthenticatedAPIView):
"""我的邮箱信息 + JMAP 连接配置(供浏览器直连)。"""
permission_classes = [IsAuthenticated]
def get(self, request):
mailbox = UserMailbox.objects.filter(UserUUID=request.user).first()
session = StalwartMailManager.SessionConfig()
if mailbox is None:
return Response({'code': 0, 'data': {'Mailbox': None, 'Session': session}})
return Response({
'code': 0,
'data': {
'Mailbox': {
'MailboxName': mailbox.MailboxName,
'QuotaMB': mailbox.QuotaMB,
'Status': mailbox.Status,
'CreateTime': mailbox.CreateTime.isoformat() if mailbox.CreateTime else None,
},
'Session': session,
},
})
class MailAdminListView(ELTAuthenticatedAPIView):
"""管理员:全部员工邮箱列表(含用户名)。"""
permission_classes = [IsAuthenticated, IsAdminUser]
def get(self, request):
from gvsds.apps.core.models import User as CoreUser
rows = list(UserMailbox.objects.all().order_by('-CreateTime'))
user_map = {}
for mb in rows:
key = mb.UserUUID_id if hasattr(mb, 'UserUUID_id') else mb.UserUUID
user_map.setdefault(key, None)
if user_map:
uuids = [k for k in user_map.keys() if k]
for u in CoreUser.objects.filter(UserUUID__in=uuids):
try:
user_map[u.UserUUID.bytes] = u.UserName
except Exception:
user_map[str(u.UserUUID)] = u.UserName
data = []
for mb in rows:
key = mb.UserUUID_id if hasattr(mb, 'UserUUID_id') else mb.UserUUID
data.append({
'UserMailboxUUID': UUIDObj(mb.UserMailboxUUID).str,
'UserName': user_map.get(key, '') or '',
'MailboxName': mb.MailboxName,
'QuotaMB': mb.QuotaMB,
'Status': mb.Status,
'CreateTime': mb.CreateTime.isoformat() if mb.CreateTime else None,
})
return Response({'code': 0, 'data': {'list': data, 'total': len(data)}})
class MailDisableView(ELTAuthenticatedAPIView):
"""管理员:禁用邮箱(删除 Stalwart 账户并置状态为 0)。"""
permission_classes = [IsAuthenticated, IsAdminUser]
def post(self, request):
from gvsdsdk.model_utils import UUIDObj as _UUIDObj
mailbox_uuid = request.data.get('UserMailboxUUID') or request.data.get('MailboxUUID')
mailbox = None
if mailbox_uuid:
try:
mailbox = UserMailbox.objects.filter(UserMailboxUUID=_UUIDObj(mailbox_uuid).bytes).first()
except (ValueError, AttributeError):
mailbox = None
if mailbox is None:
return Response({'code': 1, 'msg': '邮箱记录不存在'}, status=404)
try:
StalwartMailManager.DeleteMailbox(mailbox.MailboxName)
except StalwartMailError as e:
logger.error('[Mail] 删除邮箱失败: %s', e)
return Response({'code': 1, 'msg': f'删除邮箱失败: {e}'}, status=502)
mailbox.Status = 0
mailbox.save(update_fields=['Status'])
return Response({'code': 0, 'msg': '邮箱已禁用'})
class MailJmapView(ELTAuthenticatedAPIView):
"""JMAP 收发代理——前端统一走后端转发到 Stalwart。
请求体:
Password - 邮箱密码(仅内存传递,不落库)
MethodCalls - JMAP 方法数组 [[method, args, callId], ...]
Using - 可选,能力数组(默认 core/mail/submission)
AccountId - 可选,账户 ID(省略时后端用邮箱凭据自动发现)
响应:{code: 0, data: {AccountId, Data}},Data 为 Stalwart 原始 methodResponses。
"""
permission_classes = [IsAuthenticated]
def post(self, request):
mailbox = UserMailbox.objects.filter(UserUUID=request.user, Status=1).first()
if mailbox is None:
return Response({'code': 1, 'msg': '您尚未申请邮箱'}, status=404)
password = request.data.get('Password') or ''
method_calls = request.data.get('MethodCalls')
using = request.data.get('Using') or [
'urn:ietf:params:jmap:core',
'urn:ietf:params:jmap:mail',
'urn:ietf:params:jmap:submission',
]
account_id = request.data.get('AccountId') or ''
if not isinstance(method_calls, list) or not method_calls:
return Response({'code': 1, 'msg': '缺少 MethodCalls'}, status=400)
base = StalwartMailManager._BaseUrl()
token = base64.b64encode(f'{mailbox.MailboxName}:{password}'.encode('utf-8')).decode('ascii')
auth_headers = {'Authorization': f'Basic {token}', 'Content-Type': 'application/json'}
# 自动发现账户 ID(首次调用或未提供时)
if not account_id:
try:
resp = requests.get(f'{base}/.well-known/jmap', headers={'Authorization': auth_headers['Authorization']}, timeout=15)
if resp.status_code == 401:
return Response({'code': 1, 'msg': '邮箱或密码错误'}, status=401)
resp.raise_for_status()
session = resp.json()
account_id = (
session.get('primaryAccounts', {}).get('urn:ietf:params:jmap:mail')
or next(iter(session.get('accounts', {})), '')
)
except requests.RequestException as e:
logger.error('[Mail] JMAP 会话发现失败: %s', e)
return Response({'code': 1, 'msg': f'邮箱认证失败: {e}'}, status=502)
if not account_id:
return Response({'code': 1, 'msg': 'JMAP 会话异常:未找到邮箱账户'}, status=502)
# 注入 accountId(调用方未显式提供时)
for call in method_calls:
if isinstance(call, list) and len(call) >= 2 and isinstance(call[1], dict):
call[1].setdefault('accountId', account_id)
body = {'using': using, 'methodCalls': method_calls}
try:
resp = requests.post(f'{base}/jmap/', headers=auth_headers, json=body, timeout=30)
except requests.RequestException as e:
logger.error('[Mail] JMAP 转发失败: %s', e)
return Response({'code': 1, 'msg': f'邮件服务器连接失败: {e}'}, status=502)
try:
payload = resp.json()
except ValueError:
return Response({'code': 1, 'msg': '邮件服务器响应异常'}, status=502)
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)
return Response({'code': 0, 'data': {'AccountId': account_id, 'Data': payload}})
+9
View File
@@ -161,6 +161,15 @@ urlpatterns = [
# 仪表盘统计(按角色差异化)
path('API/Dashboard/Stats', gerp_views.DashboardStatsView.as_view(), name='dashboard_stats'),
# 员工邮箱(Stalwart 邮件服务器)
path('API/Mail/Session', gerp_views.MailSessionView.as_view(), name='mail_session'),
path('API/Mail/Apply', gerp_views.MailApplyView.as_view(), name='mail_apply'),
path('API/Mail/ChangePassword', gerp_views.MailChangePasswordView.as_view(), name='mail_change_password'),
path('API/Mail/MyMailbox', gerp_views.MailMyMailboxView.as_view(), name='mail_my_mailbox'),
path('API/Mail/Jmap', gerp_views.MailJmapView.as_view(), name='mail_jmap'),
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'),
path('API/doc/doc/GetDocs', gerp_views.DocView.as_view(), name='doc_list'),
path('API/doc/doc/GetDoc', gerp_views.DocView.as_view(), name='doc_get'),
path('API/doc/doc/AddDoc', gerp_views.DocView.as_view(), name='doc_add'),