"""Pydantic schemas for the email_group domain.""" from __future__ import annotations import re from datetime import datetime from uuid import UUID from pydantic import BaseModel, Field, field_validator # Google restricts group local-parts to: lowercase letters, digits, # dots, dashes, underscores, plus signs. Length 1..64. The rules we # enforce (matching what the Google Admin SDK accepts): # • Any char from [a-z0-9._+-] # • Must not start or end with `.` (Google rejects `.x` / `x.`) # • Must not contain consecutive dots `..` # • Length 1..64 # `_`, `+`, `-` at start/end are legal per Google — the earlier # stricter regex required leading+trailing alphanumeric, which was # inconsistent with the docstring and the frontend "sounds valid" # cases. Code-review pass 2 finding E1. _LOCAL_PART_ALLOWED = re.compile(r"^[a-z0-9._+\-]+$") def _validate_local_part(v: str) -> str: v = (v or "").strip().lower() if not v: raise ValueError("local part must not be empty") if len(v) > 64: raise ValueError("local part must be at most 64 characters") if not _LOCAL_PART_ALLOWED.match(v): raise ValueError( "local part must consist of lowercase letters, digits, " "and the characters . - _ +" ) if v.startswith(".") or v.endswith("."): raise ValueError("local part must not start or end with a dot") if ".." in v: raise ValueError("local part must not contain consecutive dots") return v # ── Transfer owner ── class EmailGroupTransferOwnerRequest(BaseModel): """Transfer (or delegate) a single ownership seat on a mail group. * ``to_person_id`` — the new owner's IdentityPerson UUID. * ``from_person_id`` — the current owner whose seat is being replaced. Required because a group can have many owners and Helix doesn't want to guess which one is leaving; the UI shows a dropdown of current owners and sends the chosen id back. * ``reason`` — ≥4 chars. Surfaced in audit + the new-owner notification so "why did I just receive this group?" is always answered on the receiving side. """ to_person_id: UUID from_person_id: UUID reason: str = Field(..., min_length=4, max_length=500) # ── Public directory (all employees can see) ── class EmailGroupOwnerResponse(BaseModel): person_id: UUID display_name: str | None = None work_email: str | None = None display_name_employee_no: str | None = None display_name_employment_status: str | None = None class EmailGroupOwnerDirectoryEntry(BaseModel): """One row of the ``GET /email-groups/owners-directory`` response. Distinct active owner across all mail groups, with name + email. The same fields are already exposed via ``EmailGroupOwnerResponse`` on every per-group row of the public listing — this is a search- convenience aggregation, not a new PII surface. ``display_name`` and ``work_email`` are typed ``str | None`` for parity with ``EmailGroupOwnerResponse`` and ``IdentityPerson`` — a NULL on either side is rare but possible (HRIS occasionally feeds a record with one missing). The endpoint coerces those to empty strings on the wire so the picker label fallback chain (``display_name || work_email || id``) never trips on an explicit ``null`` it didn't expect. """ id: UUID display_name: str | None = None work_email: str | None = None class EmailGroupResponse(BaseModel): id: UUID email: str name: str description: str | None = None status: str owners: list[EmailGroupOwnerResponse] # Sorted unique list of alias addresses that also route to this # group (Google's editable + non-editable aliases merged). Empty # list when the group has been synced but has no aliases; ``None`` # would mean "never synced", but for the public directory we coerce # that to ``[]`` at the router edge so the UI doesn't have to # special-case it. aliases: list[str] = [] # Direct member count from Google (nested groups not expanded). # ``None`` for unsynced rows so the UI can render ``—`` instead of # a misleading "0 members". direct_members_count: int | None = None created_at: datetime updated_at: datetime class EmailGroupListResponse(BaseModel): data: list[EmailGroupResponse] total: int page: int page_size: int # ── Create (via EmployeeRequest) ── class EmailGroupCreateRequest(BaseModel): """Request body for ``POST /email-groups/requests``. ``local_part`` is the bit before the ``@``. ``suffix`` must be one of the whitelist configured in ``platform_settings``. Additional owners (beyond the requester) are identified by ``identity_persons.id``. """ local_part: str = Field(..., min_length=1, max_length=64) suffix: str = Field(..., min_length=3, max_length=253) name: str = Field(..., min_length=1, max_length=255) description: str | None = Field(None, max_length=2000) reason: str = Field(..., min_length=1, max_length=500) # Cap the additional-owner list — without this an LLM client could # post ``[uuid] * 5000`` and bloat the email_group_request row plus # the audit detail. 50 is generous (largest real group on prod has # ~12 co-owners). additional_owner_ids: list[UUID] = Field(default_factory=list, max_length=50) @field_validator("local_part") @classmethod def _check_local_part(cls, v: str) -> str: return _validate_local_part(v) @field_validator("suffix") @classmethod def _check_suffix(cls, v: str) -> str: v = (v or "").strip().lower() if not v or "@" in v: raise ValueError("suffix must be a bare domain (no @)") return v # ── 邮件组管理 admin writes (static groups) ── class GroupMemberAddRequest(BaseModel): """Add addresses to a static group. ``emails`` may include EXTERNAL addresses: Google groups routinely carry vendor / contractor / alias members, and refusing anything that does not resolve to an ``identity_persons`` row would make the admin surface unable to reproduce the groups it manages. The cap matches :data:`~app.domains.email_group.admin_service.MAX_BATCH_MEMBERS` — each entry is a separate Google call, so the bound keeps one request inside the platform HTTP timeout. """ emails: list[str] = Field(..., min_length=1, max_length=50) # No ``pattern=`` here on purpose. A regex would reject ``"owner"`` # with a raw FastAPI 422 before the service's own normalisation and # its curated ``INVALID_MEMBER_ROLE`` message could run, which is # invisible from the browser (the UI only ever sends uppercase) and # confusing from a script or MCP client. The service owns the # vocabulary; this is only the transport bound. role: str = Field("MEMBER", min_length=1, max_length=16) reason: str | None = Field(None, max_length=500) class GroupMemberBatchRemoveRequest(BaseModel): """Remove addresses from a static group. Owners are refused here on purpose — losing an owner is a different operation with a different guard, so it goes through ``POST /{id}/owners/remove``. """ emails: list[str] = Field(..., min_length=1, max_length=50) reason: str | None = Field(None, max_length=500) class GroupOwnerGrantRequest(BaseModel): email: str = Field(..., min_length=3, max_length=255) reason: str | None = Field(None, max_length=500) class GroupOwnerRevokeRequest(BaseModel): """Drop one owner. ``demote_to_member=True`` (the default) keeps them in the group as a plain member — the usual intent behind "they should not be responsible for this any more". ``False`` removes them outright. """ email: str = Field(..., min_length=3, max_length=255) demote_to_member: bool = True reason: str | None = Field(None, max_length=500) class GroupDisplayUpdateRequest(BaseModel): """Rename a static group / edit its description. Both fields are optional and ``None`` means "leave alone", so an empty-string ``description`` is a meaningful clear rather than a no-op. """ name: str | None = Field(None, min_length=1, max_length=255) description: str | None = Field(None, max_length=2000) class GroupAliasRequest(BaseModel): """Add or remove one alias on a static group. ``reason`` is here because removing an alias is the one write on this desk that can make a group DELETABLE: a real second address raises the cleanup workbench's ``has_real_alias`` BLOCKING signal, so dropping the last one moves the row toward ``risk: low``. Every other mutation here (member add/remove, owner grant/revoke, create, delete, batch cleanup) already records a why; this one did not, so the step that unlocked an irreversible delete was the single unexplained event in the chain. Optional rather than required: the MCP twins and the self-service alias flow both call this without one, and a hard requirement would break them for no safety gain — an absent reason is recorded as absent, which is honest, while a coerced one is noise. """ alias_email: str = Field(..., min_length=3, max_length=255) reason: str | None = Field(None, max_length=500) class AdminGroupCreateRequest(BaseModel): """HR / global-admin group creation. Same shape as the self-service request minus the "you are automatically an owner" rule: ``owner_ids`` is the COMPLETE owner set and at least one entry is required. Google demands ≥1 owner per group, and an ownerless group becomes an offboarding blocker. """ local_part: str = Field(..., min_length=1, max_length=64) suffix: str = Field(..., min_length=3, max_length=253) name: str = Field(..., min_length=1, max_length=255) description: str | None = Field(None, max_length=2000) reason: str = Field(..., min_length=4, max_length=500) owner_ids: list[UUID] = Field(..., min_length=1, max_length=50) @field_validator("local_part") @classmethod def _check_local_part(cls, v: str) -> str: return _validate_local_part(v) @field_validator("suffix") @classmethod def _check_suffix(cls, v: str) -> str: v = (v or "").strip().lower() if not v or "@" in v: raise ValueError("suffix must be a bare domain (no @)") return v # ── Alias (via EmployeeRequest) ── class EmailAliasCreateRequest(BaseModel): """Add an alias to a user mailbox or a mail group. ``target_kind`` = 'user' | 'group'. ``target_email`` is the thing that should RECEIVE mail sent to the alias. HR or global admin may set ``target_email`` to anything; regular employees must either match their own email or a group they own. """ target_kind: str = Field(..., pattern=r"^(user|group)$") target_email: str = Field(..., min_length=3, max_length=255) alias_local_part: str = Field(..., min_length=1, max_length=64) alias_suffix: str = Field(..., min_length=3, max_length=253) reason: str = Field(..., min_length=1, max_length=500) @field_validator("target_email") @classmethod def _check_target(cls, v: str) -> str: v = (v or "").strip().lower() if "@" not in v: raise ValueError("target_email must be a full email address") return v @field_validator("alias_local_part") @classmethod def _check_alias_local_part(cls, v: str) -> str: return _validate_local_part(v) @field_validator("alias_suffix") @classmethod def _check_alias_suffix(cls, v: str) -> str: v = (v or "").strip().lower() if not v or "@" in v: raise ValueError("alias_suffix must be a bare domain (no @)") return v # ── Password reset (via EmployeeRequest) ── class PasswordResetRequest(BaseModel): reason: str | None = Field(None, max_length=500) # ── Alias listing + deletion (self-service + admin) ── class EmailAliasItem(BaseModel): """One alias on a Google user mailbox. ``editable`` is False for ``nonEditableAliases`` returned by Google (domain-level aliases auto-created when an Admin adds a domain alias to the workspace). The Google API rejects deletion attempts on non-editable aliases with a 403, so the UI disables the delete button for these rows. """ alias_email: str editable: bool class EmailAliasListResponse(BaseModel): """Response shape for ``GET /me/email-aliases`` and the admin variant. Surfaces the primary work email so the UI can render ``alias → primary`` as a single block. """ person_id: UUID primary_email: str aliases: list[EmailAliasItem] class EmailAliasDeleteRequest(BaseModel): """Body for ``DELETE /me/email-aliases``. Path-param-encoding the alias email is awkward (the @ is fine but URL parsing across CDNs is inconsistent), so we accept the alias in the request body. ``reason`` is optional — for self-deletes the user already authenticated; for admin deletes the audit trail keeps the actor's id which is enough. """ alias_email: str = Field(..., min_length=3, max_length=255) reason: str | None = Field(None, max_length=500) @field_validator("alias_email") @classmethod def _check_alias(cls, v: str) -> str: v = (v or "").strip().lower() if "@" not in v: raise ValueError("alias_email must be a full email address") return v class PasswordResetAdminRequest(BaseModel): """IT Admin resetting SOMEONE ELSE's password. Separate from the self-service ``PasswordResetRequest`` because it bypasses the rate limit and requires an explicit justification. """ target_person_id: UUID reason: str = Field(..., min_length=3, max_length=500) # ── Suffix whitelist (exposed to frontend) ── class SuffixWhitelistResponse(BaseModel): suffixes: list[str] # ── My group memberships (self-service "我的邮件组") ── class MemberGroupItem(BaseModel): """One mail group the caller is a member of. ``email`` / ``name`` come from Google's ``groups.list(userKey)``. ``is_owner`` / ``sole_owner`` are derived from the locally-synced ``email_group_owners`` table (NOT a per-group Google call): * ``is_owner`` — the caller is one of the group's owners. * ``sole_owner`` — per the MIRROR, the caller is the only owner. The UI shows a caution and points at the transfer-owner flow, but does NOT disable 退出: this flag comes from ``email_group_owners``, which only holds owners resolvable to an employee by work email and is refreshed on a 6-hour tick, while ``POST /me/email-groups/leave`` decides the same question from Google's LIVE roster. Disabling on a stale flag produced a permanently dead button plus advice the user could not act on, so the API is the authority and its 409 is the refusal. ``group_id`` is the local mirror row id when the group has been synced into ``email_groups`` (so the UI can deep-link to the directory for transfer); ``None`` when Google knows the membership but the 6h sync hasn't mirrored the group yet. """ email: str name: str | None = None is_owner: bool = False sole_owner: bool = False group_id: UUID | None = None # Cloud Identity dynamic group? When True the UI disables 退出 and # tells the user自助退出无效, contact IT — membership is rule-computed # so a manual leave is a no-op. Derived from the synced # ``email_groups.is_dynamic`` (NULL → treated as False / static). is_dynamic: bool = False class MemberGroupListResponse(BaseModel): person_id: UUID primary_email: str groups: list[MemberGroupItem] # ── Join requests (in-Helix owner approval) ── class JoinGroupRequest(BaseModel): """Body for ``POST /email-groups/{id}/join-requests``. The group is a path param; the only field is an optional reason (surfaced to the approving owner + in audit). """ reason: str | None = Field(None, max_length=500) class JoinDecisionRequest(BaseModel): """Body for ``POST /email-groups/join-requests/{id}/decide``. ``decision`` is ``approve`` | ``reject``. ``note`` is optional (owner can attach a reason, shown to the requester in the notification). """ decision: str = Field(..., pattern=r"^(approve|reject)$") note: str | None = Field(None, max_length=500) class JoinRequestRequester(BaseModel): id: UUID display_name: str | None = None work_email: str | None = None class JoinRequestResponse(BaseModel): """One join-request row, enriched with group + requester labels. Powers both the requester's tracker and the owner's 待审批 queue. """ id: UUID email_group_id: UUID group_email: str group_name: str | None = None requester: JoinRequestRequester reason: str | None = None status: str decided_by_id: UUID | None = None decided_by_name: str | None = None decided_at: datetime | None = None decision_note: str | None = None created_at: datetime # ── Group member management (owner / admin) ── class GroupMemberItem(BaseModel): """One member of a mail group, as returned by Google. ``role`` is ``OWNER`` | ``MANAGER`` | ``MEMBER``. The UI disables the remove button for ``OWNER`` rows (owner removal goes through the transfer flow so the group never goes ownerless). """ email: str role: str | None = None type: str | None = None status: str | None = None display_name: str | None = None class GroupMemberListResponse(BaseModel): group_id: UUID group_email: str members: list[GroupMemberItem] class GroupMemberRemoveRequest(BaseModel): """Body for ``POST /email-groups/{id}/members/remove``.""" member_email: str = Field(..., min_length=3, max_length=255) reason: str | None = Field(None, max_length=500) @field_validator("member_email") @classmethod def _check_member_email(cls, v: str) -> str: v = (v or "").strip().lower() if "@" not in v: raise ValueError("member_email must be a full email address") return v class GroupLeaveRequest(BaseModel): """Body for ``POST /me/email-groups/leave``. ``group_email`` is the group the caller wants to leave. ``reason`` is optional — the caller already authenticated; the audit row keeps the actor + group, which is enough. """ group_email: str = Field(..., min_length=3, max_length=255) reason: str | None = Field(None, max_length=500) @field_validator("group_email") @classmethod def _check_group_email(cls, v: str) -> str: v = (v or "").strip().lower() if "@" not in v: raise ValueError("group_email must be a full email address") return v # ── Dynamic email-group management (HR-editable membership rules) ── class DynamicRuleModel(BaseModel): """The structured membership rule HR edits in the builder. Mirrors ``app.domains.email_group.dynamic_cel.StructuredRule``: * ``field`` — one custom-schema field key (see ``field_catalog`` in the detail response), or ``None`` for a people-only rule. * ``match_values`` — attribute values OR'd together on ``field``. * ``include_emails`` — extra people OR'd in by email prefix. * ``negate`` — wrap the whole disjunction in ``!( … )`` (e.g. "everyone whose Country != CN"). """ field: str | None = None match_values: list[str] = Field(default_factory=list) include_emails: list[str] = Field(default_factory=list) negate: bool = False class DynamicGroupHealth(BaseModel): """Health summary for a dynamic group row in the list.""" # ``ok`` | ``dead_value`` (rule names a value matching 0 users) | # ``google_managed`` (rule not structured-parseable — edit in Google) status: str = "ok" dead_values: list[str] = Field(default_factory=list) class DynamicGroupListItem(BaseModel): id: UUID email: str name: str description: str | None = None direct_members_count: int | None = None rule: DynamicRuleModel | None = None parseable: bool = True raw_query: str | None = None alert_on_missing_values: bool = False health: DynamicGroupHealth aliases: list[str] = Field(default_factory=list) last_synced_at: datetime | None = None class DynamicGroupListResponse(BaseModel): groups: list[DynamicGroupListItem] # True when the Google user-attribute scan that powers health/value # options failed — the UI shows a soft "health unavailable" banner # rather than falsely reporting every value as dead. attributes_available: bool = True class FieldValueOption(BaseModel): value: str count: int class FieldCatalogEntry(BaseModel): field: str label: str options: list[FieldValueOption] = Field(default_factory=list) class DynamicGroupDetail(BaseModel): id: UUID email: str name: str description: str | None = None direct_members_count: int | None = None rule: DynamicRuleModel | None = None parseable: bool = True raw_query: str | None = None alert_on_missing_values: bool = False health: DynamicGroupHealth aliases: list[str] = Field(default_factory=list) field_catalog: list[FieldCatalogEntry] = Field(default_factory=list) last_synced_at: datetime | None = None attributes_available: bool = True class MemberDelta(BaseModel): """Expected-vs-actual (or before-vs-after) membership diff.""" expected_count: int actual_count: int added: list[str] = Field(default_factory=list) # in expected, not actual removed: list[str] = Field(default_factory=list) # in actual, not expected evaluable: bool = True # False when the rule isn't locally evaluable class DynamicGroupReconcileResponse(BaseModel): group_id: UUID group_email: str delta: MemberDelta class DynamicRulePreviewRequest(BaseModel): """Preview the membership impact of a rule before saving.""" rule: DynamicRuleModel class DynamicRulePreviewResponse(BaseModel): # Compiled CEL that WOULD be pushed to Google. compiled_query: str delta: MemberDelta # True when applying would drop more than the guard threshold of the # current membership — the UI must ask for explicit confirmation. large_drop: bool = False drop_ratio: float = 0.0 class DynamicGroupCreateRequest(BaseModel): email: str = Field(..., min_length=3, max_length=255) name: str = Field(..., min_length=1, max_length=255) description: str | None = Field(None, max_length=1000) rule: DynamicRuleModel @field_validator("email") @classmethod def _norm_email(cls, v: str) -> str: v = (v or "").strip().lower() if "@" not in v: raise ValueError("email must be a full group address") return v class DynamicRuleUpdateRequest(BaseModel): rule: DynamicRuleModel # Must be True to apply a change that drops more than the guard # threshold of current members (the preview flags ``large_drop``). confirm_large_drop: bool = False class DynamicGroupDisplayUpdateRequest(BaseModel): name: str | None = Field(None, min_length=1, max_length=255) description: str | None = Field(None, max_length=1000) class DynamicGroupAliasRequest(BaseModel): alias_email: str = Field(..., min_length=3, max_length=255) @field_validator("alias_email") @classmethod def _norm_alias(cls, v: str) -> str: v = (v or "").strip().lower() if "@" not in v: raise ValueError("alias_email must be a full email address") return v class DynamicGroupMissingAlertRequest(BaseModel): enabled: bool