diff --git a/backend/app/main.py b/backend/app/main.py index 3ede0c8..7f25612 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -26,6 +26,24 @@ settings = get_settings() inbound_server = InboundSMTPServer() +async def migrate_mail_group_columns(connection) -> None: + await connection.execute( + text( + "ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS owner_id UUID " + "REFERENCES users(id)" + ) + ) + await connection.execute( + text( + "ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS send_permission " + "VARCHAR(30) DEFAULT 'org_members'" + ) + ) + await connection.execute( + text("ALTER TABLE mail_groups ADD COLUMN IF NOT EXISTS description TEXT") + ) + + async def migrate_user_org_columns(connection) -> None: await connection.execute( text("ALTER TABLE users ADD COLUMN IF NOT EXISTS org_role VARCHAR(20)") @@ -96,6 +114,7 @@ async def lifespan(_: FastAPI): await migrate_legacy_user_tables(connection) await connection.run_sync(Base.metadata.create_all) await migrate_user_org_columns(connection) + await migrate_mail_group_columns(connection) async with SessionFactory() as session: await ensure_platform_domain(session) await session.commit() diff --git a/backend/app/models.py b/backend/app/models.py index a97622e..789940d 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -198,11 +198,19 @@ class MailGroup(Base): name: Mapped[str] = mapped_column(String(120)) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) group_type: Mapped[str] = mapped_column(String(20), default="distribution") + owner_id: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id"), + nullable=True, + ) + send_permission: Mapped[str] = mapped_column(String(30), default="org_members") + description: Mapped[str | None] = mapped_column(Text, nullable=True) created_by_id: Mapped[uuid.UUID | None] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id"), nullable=True, ) + owner: Mapped["Account | None"] = relationship(foreign_keys=[owner_id]) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), diff --git a/backend/app/routers/aliases.py b/backend/app/routers/aliases.py index 260a8af..11aa2a5 100644 --- a/backend/app/routers/aliases.py +++ b/backend/app/routers/aliases.py @@ -5,8 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_postgres_session from app.deps.org_manager import require_org_manager from app.models import Account -from app.schemas import MailAliasCreate, MailAliasListResponse, MailAliasRead -from app.services.mail_aliases import create_mail_alias, list_mail_aliases +from app.schemas import MailAliasCreate, MailAliasListResponse, MailAliasRead, MailGroupEmailCheck +from app.services.mail_aliases import ( + check_alias_email_available, + create_mail_alias, + list_mail_aliases, +) router = APIRouter(prefix="/aliases", tags=["aliases"]) @@ -20,6 +24,24 @@ async def get_aliases( return MailAliasListResponse(items=items, counts=counts) +@router.get("/check-email", response_model=MailGroupEmailCheck) +async def check_email( + email: str, + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> MailGroupEmailCheck: + if manager.company_domain is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Company domain is required.", + ) + return await check_alias_email_available( + session, + manager.company_domain, + email, + ) + + @router.post("", response_model=MailAliasRead, status_code=status.HTTP_201_CREATED) async def add_alias( payload: MailAliasCreate, diff --git a/backend/app/routers/domains.py b/backend/app/routers/domains.py index c00c5a9..28815c1 100644 --- a/backend/app/routers/domains.py +++ b/backend/app/routers/domains.py @@ -57,14 +57,6 @@ async def verify_owned_domain_dns( domain: MailDomain, ) -> DnsVerificationResponse: settings = get_settings() - if domain.status == "active": - return DnsVerificationResponse( - domain=domain.domain_name, - status=domain.status, - verified=True, - checks=[], - ) - verification_token = await ensure_ownership_dns_record(session, domain) checks = verify_company_dns( domain_name=domain.domain_name, @@ -75,12 +67,15 @@ async def verify_owned_domain_dns( verification_token=verification_token, ) verified = all(check["verified"] for check in checks) - if verified: + if domain.status != "active" and verified: await activate_company_domain(session, domain) await session.commit() + status = "active" + else: + status = domain.status return DnsVerificationResponse( domain=domain.domain_name, - status=domain.status if not verified else "active", + status=status, verified=verified, checks=checks, ) diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 987239b..bf9b919 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -5,8 +5,12 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_postgres_session from app.deps.org_manager import require_org_manager from app.models import Account -from app.schemas import MailGroupCreate, MailGroupListResponse, MailGroupRead -from app.services.mail_groups import create_mail_group, list_mail_groups +from app.schemas import MailGroupCreate, MailGroupEmailCheck, MailGroupListResponse, MailGroupRead +from app.services.mail_groups import ( + check_group_email_available, + create_mail_group, + list_mail_groups, +) router = APIRouter(prefix="/groups", tags=["groups"]) @@ -20,6 +24,25 @@ async def get_groups( return MailGroupListResponse(items=items, counts=counts) +@router.get("/check-email", response_model=MailGroupEmailCheck) +async def check_email( + email: str, + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> MailGroupEmailCheck: + if manager.company_domain is None: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="Company domain is required.", + ) + result = await check_group_email_available( + session, + manager.company_domain, + email, + ) + return result + + @router.post("", response_model=MailGroupRead, status_code=status.HTTP_201_CREATED) async def add_group( payload: MailGroupCreate, diff --git a/backend/app/routers/people.py b/backend/app/routers/people.py index 66d1106..15aad25 100644 --- a/backend/app/routers/people.py +++ b/backend/app/routers/people.py @@ -10,10 +10,13 @@ from app.models import Account from app.routers.auth import validate_password from app.schemas import ( OrgMemberCreate, + OrgMemberImportRequest, + OrgMemberImportResponse, OrgMemberListResponse, OrgMemberRead, OrgMemberRoleUpdate, ) +from app.services.org_member_import import import_org_members from app.services.org_members import ( create_org_member, list_org_members, @@ -62,6 +65,23 @@ async def add_person( ) from error +@router.post("/import", response_model=OrgMemberImportResponse) +async def import_people( + payload: OrgMemberImportRequest, + manager: Account = Depends(require_org_manager), + session: AsyncSession = Depends(get_postgres_session), +) -> OrgMemberImportResponse: + try: + result = await import_org_members(session, manager=manager, payload=payload) + await session.commit() + return result + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(error), + ) from error + + @router.patch("/{member_id}/role", response_model=OrgMemberRead) async def update_member_role( member_id: UUID, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 39bfa52..7282965 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -104,10 +104,48 @@ class OrgMemberListResponse(BaseModel): counts: dict[str, int] +class OrgMemberImportRow(BaseModel): + first_name: str = Field(min_length=1, max_length=80) + last_name: str = Field(min_length=1, max_length=80) + work_email: str = Field(min_length=1, max_length=320) + invitation_email: EmailStr | None = None + password: str = Field(min_length=8, max_length=128) + + +class OrgMemberImportRequest(BaseModel): + org_role: Literal["user", "admin"] = "user" + send_invitation: bool = True + force_password_change: bool = True + rows: list[OrgMemberImportRow] = Field(min_length=1, max_length=300) + + +class OrgMemberImportRowResult(BaseModel): + row: int + email: str + success: bool + message: str + invitation_sent: bool = False + + +class OrgMemberImportResponse(BaseModel): + created: list[OrgMemberRead] + errors: list[OrgMemberImportRowResult] + created_count: int + error_count: int + + class MailGroupCreate(BaseModel): name: str = Field(min_length=1, max_length=120) email: EmailStr group_type: Literal["distribution", "dynamic"] = "distribution" + owner_id: UUID + send_permission: Literal[ + "org_members", + "group_members", + "owners_managers", + "anyone", + ] = "org_members" + description: str | None = Field(default=None, max_length=500) member_ids: list[UUID] = Field(default_factory=list) @@ -116,12 +154,20 @@ class MailGroupRead(BaseModel): name: str email: EmailStr group_type: str + owner_id: UUID | None = None + send_permission: str = "org_members" member_count: int created_at: datetime model_config = ConfigDict(from_attributes=True) +class MailGroupEmailCheck(BaseModel): + email: EmailStr + available: bool + conflict_type: str | None = None + + class MailGroupListResponse(BaseModel): items: list[MailGroupRead] counts: dict[str, int] diff --git a/backend/app/services/mail_aliases.py b/backend/app/services/mail_aliases.py index f7a26de..9582a6b 100644 --- a/backend/app/services/mail_aliases.py +++ b/backend/app/services/mail_aliases.py @@ -6,7 +6,7 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.models import Account, MailAlias, MailGroup -from app.schemas import MailAliasCreate, MailAliasRead +from app.schemas import MailAliasCreate, MailAliasRead, MailGroupEmailCheck def _alias_read(alias: MailAlias) -> MailAliasRead: @@ -39,6 +39,55 @@ async def list_mail_aliases( return items, counts +async def check_alias_email_available( + session: AsyncSession, + company_domain: str, + email: str, +) -> MailGroupEmailCheck: + normalized = str(email).lower() + if not normalized.endswith(f"@{company_domain}"): + return MailGroupEmailCheck( + email=normalized, + available=False, + conflict_type="invalid_domain", + ) + + account = await session.scalar( + select(Account).where(func.lower(Account.email) == normalized) + ) + if account: + return MailGroupEmailCheck( + email=normalized, + available=False, + conflict_type="user", + ) + + group = await session.scalar( + select(MailGroup).where(func.lower(MailGroup.email) == normalized) + ) + if group: + return MailGroupEmailCheck( + email=normalized, + available=False, + conflict_type="group", + ) + + alias = await session.scalar( + select(MailAlias).where( + MailAlias.company_domain == company_domain, + func.lower(MailAlias.alias_address) == normalized, + ) + ) + if alias: + return MailGroupEmailCheck( + email=normalized, + available=False, + conflict_type="alias", + ) + + return MailGroupEmailCheck(email=normalized, available=True) + + async def _destination_exists( session: AsyncSession, company_domain: str, @@ -80,26 +129,14 @@ async def create_mail_alias( if alias_address == forwards_to: raise ValueError("Alias cannot forward to itself.") - existing = await session.scalar( - select(MailAlias).where( - func.lower(MailAlias.alias_address) == alias_address - ) + availability = await check_alias_email_available( + session, + company_domain, + alias_address, ) - if existing: + if not availability.available: raise ValueError("That alias address is already in use.") - account_conflict = await session.scalar( - select(Account).where(func.lower(Account.email) == alias_address) - ) - if account_conflict: - raise ValueError("That address is already a user mailbox.") - - group_conflict = await session.scalar( - select(MailGroup).where(func.lower(MailGroup.email) == alias_address) - ) - if group_conflict: - raise ValueError("That address is already a group mailbox.") - if not await _destination_exists(session, company_domain, forwards_to): raise ValueError("Forwarding address must be an existing user or group.") diff --git a/backend/app/services/mail_groups.py b/backend/app/services/mail_groups.py index ad6e9da..f83dbd5 100644 --- a/backend/app/services/mail_groups.py +++ b/backend/app/services/mail_groups.py @@ -6,8 +6,8 @@ from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload -from app.models import Account, MailGroup, MailGroupMember -from app.schemas import MailGroupCreate, MailGroupRead +from app.models import Account, MailAlias, MailGroup, MailGroupMember +from app.schemas import MailGroupCreate, MailGroupEmailCheck, MailGroupRead def _group_read(group: MailGroup) -> MailGroupRead: @@ -16,11 +16,62 @@ def _group_read(group: MailGroup) -> MailGroupRead: name=group.name, email=group.email, group_type=group.group_type, + owner_id=group.owner_id, + send_permission=group.send_permission, member_count=len(group.members), created_at=group.created_at, ) +async def check_group_email_available( + session: AsyncSession, + company_domain: str, + email: str, +) -> MailGroupEmailCheck: + normalized = str(email).lower() + if not normalized.endswith(f"@{company_domain}"): + return MailGroupEmailCheck( + email=normalized, + available=False, + conflict_type="invalid_domain", + ) + + account = await session.scalar( + select(Account).where(func.lower(Account.email) == normalized) + ) + if account: + return MailGroupEmailCheck( + email=normalized, + available=False, + conflict_type="user", + ) + + group = await session.scalar( + select(MailGroup).where(func.lower(MailGroup.email) == normalized) + ) + if group: + return MailGroupEmailCheck( + email=normalized, + available=False, + conflict_type="group", + ) + + alias = await session.scalar( + select(MailAlias).where( + MailAlias.company_domain == company_domain, + func.lower(MailAlias.alias_address) == normalized, + ) + ) + if alias: + return MailGroupEmailCheck( + email=normalized, + available=False, + conflict_type="alias", + ) + + return MailGroupEmailCheck(email=normalized, available=True) + + async def list_mail_groups( session: AsyncSession, company_domain: str, @@ -81,6 +132,20 @@ async def create_mail_group( if existing: raise ValueError("That group email address is already in use.") + owner = await session.scalar( + select(Account).where( + Account.id == payload.owner_id, + Account.company_domain == company_domain, + Account.member_status != "suspended", + ) + ) + if owner is None: + raise ValueError("Group owner must be an active member of your organization.") + + availability = await check_group_email_available(session, company_domain, email) + if not availability.available: + raise ValueError("That group email address is already in use.") + members = await _validate_member_ids( session, company_domain, @@ -92,6 +157,9 @@ async def create_mail_group( name=payload.name.strip(), email=email, group_type=payload.group_type, + owner_id=owner.id, + send_permission=payload.send_permission, + description=payload.description.strip() if payload.description else None, created_by_id=manager.id, ) session.add(group) diff --git a/backend/app/services/org_member_import.py b/backend/app/services/org_member_import.py new file mode 100644 index 0000000..ef9580e --- /dev/null +++ b/backend/app/services/org_member_import.py @@ -0,0 +1,208 @@ +"""Bulk CSV import for organization members. + +TODO: Move large imports to an async batch job with webhook completion callbacks. +""" + +from __future__ import annotations + +import re + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models import Account +from app.schemas import ( + OrgMemberCreate, + OrgMemberImportRequest, + OrgMemberImportResponse, + OrgMemberImportRowResult, + OrgMemberRead, +) +from app.services.org_members import create_org_member + +MAX_IMPORT_ROWS = 300 +EMAIL_LOCAL_PATTERN = re.compile( + r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$" +) + + +def _password_error(value: str) -> str | None: + if len(value) < 8: + return "Password must be at least 8 characters." + if not ( + re.search(r"[a-z]", value) + and re.search(r"[A-Z]", value) + and re.search(r"\d", value) + ): + return "Password must include uppercase, lowercase, and a number." + return None + + +def _normalize_work_email(raw: str, company_domain: str) -> str: + value = raw.strip().lower() + if "@" in value: + return value + return f"{value}@{company_domain}" + + +async def import_org_members( + session: AsyncSession, + *, + manager: Account, + payload: OrgMemberImportRequest, +) -> OrgMemberImportResponse: + company_domain = manager.company_domain + if company_domain is None: + raise ValueError("Company domain is required.") + + rows = payload.rows + if not rows: + raise ValueError("Add at least one person to import.") + if len(rows) > MAX_IMPORT_ROWS: + raise ValueError(f"You can import up to {MAX_IMPORT_ROWS} people at a time.") + + errors: list[OrgMemberImportRowResult] = [] + normalized_rows: list[tuple[int, OrgMemberCreate]] = [] + seen_emails: set[str] = set() + seen_invites: set[str] = set() + + for index, row in enumerate(rows, start=1): + row_errors: list[str] = [] + first_name = row.first_name.strip() + last_name = row.last_name.strip() + if not first_name: + row_errors.append("First name is required.") + if not last_name: + row_errors.append("Last name is required.") + + work_email = _normalize_work_email(row.work_email, company_domain) + local_part = work_email.split("@", 1)[0] + if not work_email.endswith(f"@{company_domain}"): + row_errors.append(f"Work email must use @{company_domain}.") + elif not EMAIL_LOCAL_PATTERN.match(local_part): + row_errors.append("Work email format is invalid.") + + if work_email in seen_emails: + row_errors.append("Duplicate work email in this file.") + seen_emails.add(work_email) + + password = row.password.strip() + password_issue = _password_error(password) + if password_issue: + row_errors.append(password_issue) + + invitation_email = ( + str(row.invitation_email).lower().strip() + if row.invitation_email + else None + ) + if payload.send_invitation: + if not invitation_email: + row_errors.append( + "Invitation email is required when sending invitations." + ) + elif invitation_email.endswith(f"@{company_domain}"): + row_errors.append( + "Use a personal email for invitations, not your company domain." + ) + elif invitation_email in seen_invites: + row_errors.append("Duplicate invitation email in this file.") + else: + seen_invites.add(invitation_email) + + if row_errors: + errors.append( + OrgMemberImportRowResult( + row=index, + email=work_email, + success=False, + message="; ".join(row_errors), + ) + ) + continue + + normalized_rows.append( + ( + index, + OrgMemberCreate( + first_name=first_name, + last_name=last_name, + email=work_email, + invitation_email=invitation_email, + org_role=payload.org_role, + send_invitation=payload.send_invitation, + force_password_change=payload.force_password_change, + password=password, + ), + ) + ) + + if normalized_rows: + existing = await session.scalars( + select(Account.email).where( + func.lower(Account.email).in_( + [item[1].email.lower() for item in normalized_rows] + ) + ) + ) + existing_emails = {email.lower() for email in existing.all()} + if existing_emails: + filtered: list[tuple[int, OrgMemberCreate]] = [] + for row_number, member_payload in normalized_rows: + if member_payload.email.lower() in existing_emails: + errors.append( + OrgMemberImportRowResult( + row=row_number, + email=member_payload.email, + success=False, + message="That work email is already registered.", + ) + ) + else: + filtered.append((row_number, member_payload)) + normalized_rows = filtered + + if errors and not normalized_rows: + return OrgMemberImportResponse( + created=[], + errors=errors, + created_count=0, + error_count=len(errors), + ) + + created: list[OrgMemberRead] = [] + for row_number, member_payload in normalized_rows: + try: + member = await create_org_member( + session, + manager=manager, + payload=member_payload, + ) + created.append(member) + errors.append( + OrgMemberImportRowResult( + row=row_number, + email=member.email, + success=True, + message="Created", + invitation_sent=member.invitation_sent, + ) + ) + except ValueError as error: + errors.append( + OrgMemberImportRowResult( + row=row_number, + email=member_payload.email, + success=False, + message=str(error), + ) + ) + + created_count = len(created) + error_count = sum(1 for item in errors if not item.success) + return OrgMemberImportResponse( + created=created, + errors=errors, + created_count=created_count, + error_count=error_count, + ) diff --git a/frontend/src/api.js b/frontend/src/api.js index 2b41537..32a7f78 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -52,6 +52,8 @@ export const listOrgMembers = (token) => request("/people", { token }); export const createOrgMember = (payload, token) => request("/people", { method: "POST", payload, token }); +export const importOrgMembers = (payload, token) => + request("/people/import", { method: "POST", payload, token }); export const listOrgSections = (token) => request("/sections", { token }); export const createOrgSection = (payload, token) => @@ -60,10 +62,14 @@ export const listMailGroups = (token) => request("/groups", { token }); export const createMailGroup = (payload, token) => request("/groups", { method: "POST", payload, token }); +export const checkGroupEmail = (email, token) => + request(`/groups/check-email?email=${encodeURIComponent(email)}`, { token }); export const listMailAliases = (token) => request("/aliases", { token }); export const createMailAlias = (payload, token) => request("/aliases", { method: "POST", payload, token }); +export const checkAliasEmail = (email, token) => + request(`/aliases/check-email?email=${encodeURIComponent(email)}`, { token }); export const getOrgSecurity = (token) => request("/org/security", { token }); export const updateOrgSecurity = (payload, token) => diff --git a/frontend/src/components/CompanyAliases.jsx b/frontend/src/components/CompanyAliases.jsx index 4e10613..0e95280 100644 --- a/frontend/src/components/CompanyAliases.jsx +++ b/frontend/src/components/CompanyAliases.jsx @@ -1,4 +1,5 @@ import { + AlertCircle, CheckCircle2, Mail, MoreHorizontal, @@ -7,17 +8,54 @@ import { X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; -import { createMailAlias, listMailAliases, listMailGroups, listOrgMembers } from "../api"; +import { + checkAliasEmail, + createMailAlias, + listMailAliases, + listMailGroups, + listOrgMembers, +} from "../api"; const TYPE_TABS = [ { key: "all", label: "All aliases" }, { key: "domain", label: "Domain aliases" }, ]; +const EMAIL_LOCAL_PATTERN = /^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$/; + function aliasTypeLabel(aliasType) { return aliasType === "group" ? "Group alias" : "User alias"; } +function RequiredLabel({ children }) { + return ( + <> + {children} + + + ); +} + +function SetupField({ label, error, children, hint, status }) { + return ( +
+ {label && {label}} + {children} + {status && ( +

+ {status.kind === "ok" &&

+ )} + {hint && {hint}} + {error && {error}} +
+ ); +} + function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) { const token = sessionStorage.getItem("agmail_access_token"); const [form, setForm] = useState({ @@ -26,29 +64,107 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) { alias_type: "user", is_domain_alias: false, }); + const [destinationSearch, setDestinationSearch] = useState(""); + const [emailCheck, setEmailCheck] = useState({ loading: false, available: null, conflict: null }); const [status, setStatus] = useState({ loading: false, error: "" }); + const fullEmail = useMemo(() => { + const local = form.alias_local.trim().toLowerCase(); + return local ? `${local}@${companyDomain}` : ""; + }, [form.alias_local, companyDomain]); + + useEffect(() => { + if (!fullEmail || !EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase())) { + setEmailCheck({ loading: false, available: null, conflict: null }); + return undefined; + } + + let cancelled = false; + const timer = window.setTimeout(async () => { + setEmailCheck((current) => ({ ...current, loading: true })); + try { + const result = await checkAliasEmail(fullEmail, token); + if (!cancelled) { + setEmailCheck({ + loading: false, + available: result.available, + conflict: result.conflict_type, + }); + } + } catch { + if (!cancelled) { + setEmailCheck({ loading: false, available: null, conflict: null }); + } + } + }, 350); + + return () => { + cancelled = true; + window.clearTimeout(timer); + }; + }, [fullEmail, form.alias_local, token]); + + const filteredDestinations = useMemo(() => { + const query = destinationSearch.trim().toLowerCase(); + if (!query) return destinations; + return destinations.filter((item) => { + const haystack = `${item.label} ${item.email} ${item.kind}`.toLowerCase(); + return haystack.includes(query); + }); + }, [destinationSearch, destinations]); + + const selectedDestination = destinations.find((item) => item.email === form.forwards_to); + function updateField(field, value) { setForm((current) => ({ ...current, [field]: value })); } - function handleDestinationChange(value) { - const selected = destinations.find((item) => item.email === value); + function selectDestination(item) { setForm((current) => ({ ...current, - forwards_to: value, - alias_type: selected?.kind === "group" ? "group" : "user", + forwards_to: item.email, + alias_type: item.kind === "group" ? "group" : "user", })); } + const emailStatus = useMemo(() => { + if (!form.alias_local.trim()) return null; + if (!EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase())) { + return { kind: "warn", text: "Use lowercase letters, numbers, dots, or hyphens." }; + } + if (emailCheck.loading) { + return { kind: "checking", text: `Checking ${fullEmail}…` }; + } + if (emailCheck.available === true) { + return { kind: "ok", text: `${fullEmail} is available` }; + } + if (emailCheck.available === false) { + const conflictLabel = { + user: "a user mailbox", + group: "a group mailbox", + alias: "another alias", + invalid_domain: "an invalid domain", + }[emailCheck.conflict] || "another resource"; + return { kind: "warn", text: `${fullEmail} is already in use by ${conflictLabel}` }; + } + return null; + }, [emailCheck, form.alias_local, fullEmail]); + + const canSubmit = form.alias_local.trim() + && EMAIL_LOCAL_PATTERN.test(form.alias_local.trim().toLowerCase()) + && form.forwards_to + && emailCheck.available === true + && form.forwards_to !== fullEmail; + async function handleSubmit(event) { event.preventDefault(); + if (!canSubmit) return; + setStatus({ loading: true, error: "" }); - const aliasAddress = `${form.alias_local.trim().toLowerCase()}@${companyDomain}`; try { await createMailAlias( { - alias_address: aliasAddress, + alias_address: fullEmail, forwards_to: form.forwards_to, alias_type: form.alias_type, is_domain_alias: form.is_domain_alias, @@ -65,79 +181,136 @@ function CreateAliasPanel({ companyDomain, destinations, onClose, onCreated }) { return (