diff --git a/gvsds/apps/gerp/form_service.py b/gvsds/apps/gerp/form_service.py index dadb588c..a916a9f4 100644 --- a/gvsds/apps/gerp/form_service.py +++ b/gvsds/apps/gerp/form_service.py @@ -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 diff --git a/gvsds/apps/gerp/migrations/0019_user_mailbox.py b/gvsds/apps/gerp/migrations/0019_user_mailbox.py new file mode 100644 index 00000000..ef433ba0 --- /dev/null +++ b/gvsds/apps/gerp/migrations/0019_user_mailbox.py @@ -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'], + }, + ), + ] diff --git a/gvsds/apps/gerp/models.py b/gvsds/apps/gerp/models.py index f460a727..09f1f744 100644 --- a/gvsds/apps/gerp/models.py +++ b/gvsds/apps/gerp/models.py @@ -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 和关联属性访问 diff --git a/gvsds/apps/gerp/stalwart.py b/gvsds/apps/gerp/stalwart.py new file mode 100644 index 00000000..f550e1aa --- /dev/null +++ b/gvsds/apps/gerp/stalwart.py @@ -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": } +- 改密:PATCH /api/principal/<邮箱> + [{"action":"set","field":"secrets","value":"<新密码>"}] +- 删除:DELETE /api/principal/<邮箱> +- 查询:GET /api/principal/<邮箱> / GET /api/principal +认证:Authorization: Basic base64(),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(), + } diff --git a/gvsds/apps/gerp/views/__init__.py b/gvsds/apps/gerp/views/__init__.py index 5f809961..dbc08a0b 100644 --- a/gvsds/apps/gerp/views/__init__.py +++ b/gvsds/apps/gerp/views/__init__.py @@ -123,3 +123,12 @@ from .payment_config import ( PaymentConfigDeleteView, ) from .dashboard import DashboardStatsView +from .mail import ( + MailSessionView, + MailApplyView, + MailChangePasswordView, + MailMyMailboxView, + MailJmapView, + MailAdminListView, + MailDisableView, +) diff --git a/gvsds/apps/gerp/views/mail.py b/gvsds/apps/gerp/views/mail.py new file mode 100644 index 00000000..820a17ee --- /dev/null +++ b/gvsds/apps/gerp/views/mail.py @@ -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}}) diff --git a/gvsds/urls.py b/gvsds/urls.py index 42ca8f42..be3e4788 100644 --- a/gvsds/urls.py +++ b/gvsds/urls.py @@ -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'),