company flows
This commit is contained in:
parent
6541284a4c
commit
cc3f3f3de5
17 changed files with 1960 additions and 66 deletions
117
backend/app/services/mail_aliases.py
Normal file
117
backend/app/services/mail_aliases.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
"""Email alias management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Account, MailAlias, MailGroup
|
||||
from app.schemas import MailAliasCreate, MailAliasRead
|
||||
|
||||
|
||||
def _alias_read(alias: MailAlias) -> MailAliasRead:
|
||||
return MailAliasRead(
|
||||
id=alias.id,
|
||||
alias_address=alias.alias_address,
|
||||
forwards_to=alias.forwards_to,
|
||||
alias_type=alias.alias_type,
|
||||
is_domain_alias=alias.is_domain_alias,
|
||||
status=alias.status,
|
||||
created_at=alias.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def list_mail_aliases(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
) -> tuple[list[MailAliasRead], dict[str, int]]:
|
||||
result = await session.scalars(
|
||||
select(MailAlias)
|
||||
.where(MailAlias.company_domain == company_domain)
|
||||
.order_by(MailAlias.created_at.desc())
|
||||
)
|
||||
aliases = list(result.all())
|
||||
items = [_alias_read(alias) for alias in aliases]
|
||||
counts = {
|
||||
"all": len(items),
|
||||
"domain": sum(1 for item in items if item.is_domain_alias),
|
||||
}
|
||||
return items, counts
|
||||
|
||||
|
||||
async def _destination_exists(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
address: str,
|
||||
) -> bool:
|
||||
normalized = address.lower()
|
||||
if not normalized.endswith(f"@{company_domain}"):
|
||||
return False
|
||||
|
||||
account = await session.scalar(
|
||||
select(Account).where(func.lower(Account.email) == normalized)
|
||||
)
|
||||
if account:
|
||||
return True
|
||||
|
||||
group = await session.scalar(
|
||||
select(MailGroup).where(func.lower(MailGroup.email) == normalized)
|
||||
)
|
||||
return group is not None
|
||||
|
||||
|
||||
async def create_mail_alias(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
manager: Account,
|
||||
payload: MailAliasCreate,
|
||||
) -> MailAliasRead:
|
||||
company_domain = manager.company_domain
|
||||
if company_domain is None:
|
||||
raise ValueError("Company domain is required.")
|
||||
|
||||
alias_address = str(payload.alias_address).lower()
|
||||
forwards_to = str(payload.forwards_to).lower()
|
||||
|
||||
if not alias_address.endswith(f"@{company_domain}"):
|
||||
raise ValueError(f"Alias address must use @{company_domain}.")
|
||||
if not forwards_to.endswith(f"@{company_domain}"):
|
||||
raise ValueError(f"Forwarding address must use @{company_domain}.")
|
||||
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
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
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.")
|
||||
|
||||
alias = MailAlias(
|
||||
company_domain=company_domain,
|
||||
alias_address=alias_address,
|
||||
forwards_to=forwards_to,
|
||||
alias_type=payload.alias_type,
|
||||
is_domain_alias=payload.is_domain_alias,
|
||||
status="active",
|
||||
created_by_id=manager.id,
|
||||
)
|
||||
session.add(alias)
|
||||
await session.flush()
|
||||
return _alias_read(alias)
|
||||
109
backend/app/services/mail_groups.py
Normal file
109
backend/app/services/mail_groups.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
"""Distribution group management."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _group_read(group: MailGroup) -> MailGroupRead:
|
||||
return MailGroupRead(
|
||||
id=group.id,
|
||||
name=group.name,
|
||||
email=group.email,
|
||||
group_type=group.group_type,
|
||||
member_count=len(group.members),
|
||||
created_at=group.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def list_mail_groups(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
) -> tuple[list[MailGroupRead], dict[str, int]]:
|
||||
result = await session.scalars(
|
||||
select(MailGroup)
|
||||
.options(selectinload(MailGroup.members))
|
||||
.where(MailGroup.company_domain == company_domain)
|
||||
.order_by(MailGroup.created_at.desc())
|
||||
)
|
||||
groups = list(result.all())
|
||||
items = [_group_read(group) for group in groups]
|
||||
counts = {
|
||||
"all": len(items),
|
||||
"distribution": sum(1 for item in items if item.group_type == "distribution"),
|
||||
"dynamic": sum(1 for item in items if item.group_type == "dynamic"),
|
||||
}
|
||||
return items, counts
|
||||
|
||||
|
||||
async def _validate_member_ids(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
member_ids: list,
|
||||
) -> list[Account]:
|
||||
if not member_ids:
|
||||
return []
|
||||
result = await session.scalars(
|
||||
select(Account).where(
|
||||
Account.id.in_(member_ids),
|
||||
Account.company_domain == company_domain,
|
||||
Account.member_status != "suspended",
|
||||
)
|
||||
)
|
||||
members = list(result.all())
|
||||
if len(members) != len(set(member_ids)):
|
||||
raise ValueError("One or more selected members are invalid.")
|
||||
return members
|
||||
|
||||
|
||||
async def create_mail_group(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
manager: Account,
|
||||
payload: MailGroupCreate,
|
||||
) -> MailGroupRead:
|
||||
company_domain = manager.company_domain
|
||||
if company_domain is None:
|
||||
raise ValueError("Company domain is required.")
|
||||
|
||||
email = str(payload.email).lower()
|
||||
if not email.endswith(f"@{company_domain}"):
|
||||
raise ValueError(f"Group email must use @{company_domain}.")
|
||||
|
||||
existing = await session.scalar(
|
||||
select(MailGroup).where(func.lower(MailGroup.email) == email)
|
||||
)
|
||||
if existing:
|
||||
raise ValueError("That group email address is already in use.")
|
||||
|
||||
members = await _validate_member_ids(
|
||||
session,
|
||||
company_domain,
|
||||
payload.member_ids,
|
||||
)
|
||||
|
||||
group = MailGroup(
|
||||
company_domain=company_domain,
|
||||
name=payload.name.strip(),
|
||||
email=email,
|
||||
group_type=payload.group_type,
|
||||
created_by_id=manager.id,
|
||||
)
|
||||
session.add(group)
|
||||
await session.flush()
|
||||
|
||||
for member in members:
|
||||
session.add(
|
||||
MailGroupMember(
|
||||
group_id=group.id,
|
||||
account_id=member.id,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
await session.refresh(group, attribute_names=["members"])
|
||||
return _group_read(group)
|
||||
143
backend/app/services/org_members.py
Normal file
143
backend/app/services/org_members.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Organization member provisioning for company admins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import date
|
||||
from pwdlib import PasswordHash
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Account
|
||||
from app.schemas import OrgMemberCreate, OrgMemberRead
|
||||
from app.services.domain_provisioning import account_is_domain_owner
|
||||
|
||||
password_hasher = PasswordHash.recommended()
|
||||
PLACEHOLDER_BIRTHDATE = date(1990, 1, 1)
|
||||
|
||||
|
||||
async def can_manage_org_members(
|
||||
session: AsyncSession,
|
||||
account: Account,
|
||||
) -> bool:
|
||||
if account.account_type != "company" or not account.company_domain:
|
||||
return False
|
||||
if account.member_status == "suspended":
|
||||
return False
|
||||
if account.org_role == "admin":
|
||||
return True
|
||||
return await account_is_domain_owner(
|
||||
session,
|
||||
account.id,
|
||||
account.company_domain,
|
||||
)
|
||||
|
||||
|
||||
async def member_read(
|
||||
session: AsyncSession,
|
||||
member: Account,
|
||||
) -> OrgMemberRead:
|
||||
is_owner = await account_is_domain_owner(
|
||||
session,
|
||||
member.id,
|
||||
member.company_domain,
|
||||
)
|
||||
role = "owner" if is_owner else member.org_role
|
||||
return OrgMemberRead(
|
||||
id=member.id,
|
||||
email=member.email,
|
||||
first_name=member.first_name,
|
||||
last_name=member.last_name,
|
||||
org_role=role,
|
||||
member_status=member.member_status,
|
||||
is_domain_owner=is_owner,
|
||||
groups=[],
|
||||
created_at=member.created_at,
|
||||
)
|
||||
|
||||
|
||||
async def list_org_members(
|
||||
session: AsyncSession,
|
||||
company_domain: str,
|
||||
) -> tuple[list[OrgMemberRead], dict[str, int]]:
|
||||
result = await session.scalars(
|
||||
select(Account)
|
||||
.where(
|
||||
Account.account_type == "company",
|
||||
Account.company_domain == company_domain,
|
||||
)
|
||||
.order_by(Account.created_at.asc())
|
||||
)
|
||||
members = list(result.all())
|
||||
items = [await member_read(session, member) for member in members]
|
||||
counts = {
|
||||
"all": len(items),
|
||||
"active": sum(1 for item in items if item.member_status == "active"),
|
||||
"invited": sum(1 for item in items if item.member_status == "invited"),
|
||||
"suspended": sum(1 for item in items if item.member_status == "suspended"),
|
||||
}
|
||||
return items, counts
|
||||
|
||||
|
||||
def _placeholder_phone() -> str:
|
||||
digits = "".join(str(secrets.randbelow(10)) for _ in range(10))
|
||||
return f"+1{digits}"
|
||||
|
||||
|
||||
def _email_local_part(email: str) -> str:
|
||||
return email.split("@", 1)[0].lower()
|
||||
|
||||
|
||||
async def create_org_member(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
manager: Account,
|
||||
payload: OrgMemberCreate,
|
||||
) -> OrgMemberRead:
|
||||
company_domain = manager.company_domain
|
||||
if company_domain is None:
|
||||
raise ValueError("Company domain is required.")
|
||||
|
||||
email = str(payload.email).lower()
|
||||
if not email.endswith(f"@{company_domain}"):
|
||||
raise ValueError(f"Email must use your company domain @{company_domain}.")
|
||||
|
||||
if payload.send_invitation:
|
||||
raw_password = secrets.token_urlsafe(24)
|
||||
member_status = "invited"
|
||||
else:
|
||||
if not payload.password:
|
||||
raise ValueError("Password is required when invitation email is disabled.")
|
||||
raw_password = payload.password
|
||||
member_status = "active"
|
||||
|
||||
existing = await session.scalar(
|
||||
select(Account).where(
|
||||
func.lower(Account.email) == email,
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
raise ValueError("That email address is already registered.")
|
||||
|
||||
phone_number = _placeholder_phone()
|
||||
while await session.scalar(
|
||||
select(Account).where(Account.phone_number == phone_number)
|
||||
):
|
||||
phone_number = _placeholder_phone()
|
||||
|
||||
member = Account(
|
||||
account_type="company",
|
||||
email=email,
|
||||
first_name=payload.first_name.strip(),
|
||||
last_name=payload.last_name.strip(),
|
||||
date_of_birth=PLACEHOLDER_BIRTHDATE,
|
||||
phone_number=phone_number,
|
||||
company_domain=company_domain,
|
||||
org_role=payload.org_role,
|
||||
member_status=member_status,
|
||||
created_by_id=manager.id,
|
||||
password_hash=password_hasher.hash(raw_password),
|
||||
)
|
||||
session.add(member)
|
||||
await session.flush()
|
||||
return await member_read(session, member)
|
||||
Loading…
Add table
Add a link
Reference in a new issue