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:
1 parent
3d41d2a209
commit
0b1d71499e
7 files changed
+558
-3
No files matched your search
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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 和关联属性访问
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -123,3 +123,12 @@ from .payment_config import (
|
||||
PaymentConfigDeleteView,
|
||||
)
|
||||
from .dashboard import DashboardStatsView
|
||||
from .mail import (
|
||||
MailSessionView,
|
||||
MailApplyView,
|
||||
MailChangePasswordView,
|
||||
MailMyMailboxView,
|
||||
MailJmapView,
|
||||
MailAdminListView,
|
||||
MailDisableView,
|
||||
)
|
||||
@@ -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}})
|
||||
@@ -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'),
|
||||
|
||||
Reference in new issue
Block a user