"""Self-service + admin endpoints for managing email aliases on ``identity_persons``. Two route trees: * ``/api/v1/me/email-aliases`` — self-service (any authenticated user). Reads / deletes the caller's own aliases. * ``/api/v1/users/{person_id}/email-aliases`` — admin only (HR / IT-admin i.e. anyone passing ``can_alias_for_others``). Creation still goes through ``POST /api/v1/email-groups/aliases`` — the existing endpoint handles user-mailbox AND group aliases together; that shape is unchanged. The new endpoints exist only for the missing read + delete operations. The ``alias_email`` is delivered in the request body, not the path — URL-encoding ``@`` is technically legal but inconsistently handled by proxies / CDNs and ``%40`` in browser history reads badly. A POST-style DELETE-with-body keeps the wire shape simple. """ from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Request from sqlalchemy.ext.asyncio import AsyncSession from app.auth.models import AuthUser from app.core.database import get_db from app.core.dependencies import get_current_user from app.core.exceptions import ForbiddenError, NotFoundError from app.core.rbac import can_alias_for_others from app.core.response import success_response from app.domains.email_group.schemas import ( EmailAliasDeleteRequest, EmailAliasListResponse, GroupLeaveRequest, MemberGroupListResponse, ) from app.domains.email_group.service import EmailGroupService from app.identity.person_models import IdentityPerson # Two separate APIRouters: one for ``/me/...`` (employee) and one for # ``/users/{id}/...`` (admin). Keeps the OpenAPI schema obvious about # which routes are personal vs target-other. me_router = APIRouter(prefix="/api/v1/me", tags=["email_aliases"]) users_router = APIRouter(prefix="/api/v1/users", tags=["email_aliases"]) @me_router.get( "/email-aliases", response_model=None, summary="List the caller's own email aliases (Google Workspace).", ) async def list_my_email_aliases( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Surfaces both editable and non-editable aliases. ``editable=True`` items can be removed via DELETE; ``editable=False`` are Google domain-level aliases (managed at the Workspace tenant level by an admin) and the UI disables their delete button. """ person = await db.get(IdentityPerson, current_user.id) if person is None: raise NotFoundError("IdentityPerson", str(current_user.id)) service = EmailGroupService(db) result: EmailAliasListResponse = await service.list_aliases_for_person( person=person, ) # The list endpoint's only side effect is refreshing the DB column # to match Google's current state — commit so the next read sees # the same value. await db.commit() return success_response(data=result, request=request) @me_router.delete( "/email-aliases", response_model=None, summary="Delete one of the caller's own email aliases.", ) async def delete_my_email_alias( request: Request, body: EmailAliasDeleteRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await db.get(IdentityPerson, current_user.id) if person is None: raise NotFoundError("IdentityPerson", str(current_user.id)) actor_person = person service = EmailGroupService(db) er = await service.delete_alias_for_person( person=person, alias_email=body.alias_email, actor=actor_person, actor_roles=current_user.role_names or [], reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response( data={ "request_id": str(er.id), "request_number": er.request_number, "alias_email": body.alias_email, }, request=request, ) @me_router.get( "/email-groups", response_model=None, summary="List the mail groups the caller is a member of.", ) async def list_my_member_groups( request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Live membership read from Google Workspace. Each item carries ``is_owner`` / ``sole_owner`` (derived from the synced owner table) so the UI can badge owners and disable "退出" for the sole owner of a group. """ person = await db.get(IdentityPerson, current_user.id) if person is None: raise NotFoundError("IdentityPerson", str(current_user.id)) service = EmailGroupService(db) result: MemberGroupListResponse = ( await service.list_member_groups_for_person(person=person) ) return success_response(data=result, request=request) @me_router.post( "/email-groups/leave", response_model=None, summary="Leave a mail group the caller is a member of.", ) async def leave_my_email_group( request: Request, body: GroupLeaveRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): person = await db.get(IdentityPerson, current_user.id) if person is None: raise NotFoundError("IdentityPerson", str(current_user.id)) service = EmailGroupService(db) er = await service.leave_group_for_person( person=person, group_email=body.group_email, actor=person, reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() # ``already_absent`` ⇒ Google reported the caller was not a DIRECT # member (404 on members.delete). For a group they were shown in, # this almost always means an INDIRECT (nested-group) membership — # the direct delete is a no-op, so the UI surfaces a "可能是间接成员, # 请联系 IT" note instead of a plain success toast. already_absent = bool((er.detail or {}).get("already_absent_on_google")) return success_response( data={ "request_id": str(er.id), "request_number": er.request_number, "group_email": body.group_email, "already_absent": already_absent, }, request=request, ) @users_router.get( "/{person_id}/email-aliases", response_model=None, summary="(Admin) List a target person's email aliases.", ) async def admin_list_user_email_aliases( person_id: UUID, request: Request, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_alias_for_others(current_user.role_names or []): raise ForbiddenError( "Only HR / IT Admin can list other people's aliases.", ) person = await db.get(IdentityPerson, person_id) if person is None: raise NotFoundError("IdentityPerson", str(person_id)) service = EmailGroupService(db) result = await service.list_aliases_for_person(person=person) await db.commit() return success_response(data=result, request=request) @users_router.delete( "/{person_id}/email-aliases", response_model=None, summary="(Admin) Delete one of a target person's email aliases.", ) async def admin_delete_user_email_alias( person_id: UUID, request: Request, body: EmailAliasDeleteRequest, current_user: AuthUser = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): if not can_alias_for_others(current_user.role_names or []): raise ForbiddenError( "Only HR / IT Admin can delete other people's aliases.", ) person = await db.get(IdentityPerson, person_id) if person is None: raise NotFoundError("IdentityPerson", str(person_id)) actor_person = await db.get(IdentityPerson, current_user.id) if actor_person is None: raise NotFoundError("IdentityPerson", str(current_user.id)) service = EmailGroupService(db) er = await service.delete_alias_for_person( person=person, alias_email=body.alias_email, actor=actor_person, actor_roles=current_user.role_names or [], reason=body.reason, trace_id=request.state.trace_id, ) await db.commit() return success_response( data={ "request_id": str(er.id), "request_number": er.request_number, "alias_email": body.alias_email, }, request=request, ) __all__ = ["me_router", "users_router"]