first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""API route modules."""
|
||||
212
backend/app/routers/auth.py
Normal file
212
backend/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import re
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from uuid import UUID
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from pwdlib import PasswordHash
|
||||
from pydantic import EmailStr, TypeAdapter, ValidationError
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_postgres_session
|
||||
from app.models import Account
|
||||
from app.schemas import AccountRegister, AccountRead, AuthResponse, LoginRequest
|
||||
from app.services.domain_provisioning import (
|
||||
get_domain_by_name,
|
||||
provision_mail_domain,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||
password_hash = PasswordHash.recommended()
|
||||
dummy_password_hash = password_hash.hash("Agmail timing protection value")
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
settings = get_settings()
|
||||
email_adapter = TypeAdapter(EmailStr)
|
||||
domain_pattern = re.compile(
|
||||
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
|
||||
r"[a-z]{2,63}$"
|
||||
)
|
||||
|
||||
|
||||
def normalize_phone(value: str) -> str:
|
||||
prefix = "+" if value.strip().startswith("+") else ""
|
||||
return prefix + "".join(character for character in value if character.isdigit())
|
||||
|
||||
|
||||
def validate_password(value: str) -> None:
|
||||
if not (
|
||||
re.search(r"[a-z]", value)
|
||||
and re.search(r"[A-Z]", value)
|
||||
and re.search(r"\d", value)
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Password must include uppercase, lowercase, and a number.",
|
||||
)
|
||||
|
||||
|
||||
def build_email(payload: AccountRegister) -> tuple[str, str | None]:
|
||||
domain = "getall.help"
|
||||
company_domain = None
|
||||
|
||||
if payload.account_type == "company":
|
||||
if not payload.company_domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Company domain is required for company accounts.",
|
||||
)
|
||||
domain = payload.company_domain.strip().lower().removeprefix("@")
|
||||
if not domain_pattern.fullmatch(domain):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Enter a valid company domain, such as example.com.",
|
||||
)
|
||||
company_domain = domain
|
||||
|
||||
email = f"{payload.email_name.lower()}@{domain}"
|
||||
try:
|
||||
return str(email_adapter.validate_python(email)), company_domain
|
||||
except ValidationError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="The resulting email address is invalid.",
|
||||
) from error
|
||||
|
||||
|
||||
def create_access_token(account: Account) -> str:
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=settings.access_token_minutes
|
||||
)
|
||||
return jwt.encode(
|
||||
{"sub": str(account.id), "email": account.email, "exp": expires_at},
|
||||
settings.jwt_secret,
|
||||
algorithm="HS256",
|
||||
)
|
||||
|
||||
|
||||
async def get_current_account(
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> Account:
|
||||
unauthorized = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Your session is invalid or has expired.",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||
raise unauthorized
|
||||
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
credentials.credentials,
|
||||
settings.jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
options={"require": ["sub", "exp"]},
|
||||
)
|
||||
account_id = UUID(claims["sub"])
|
||||
except (jwt.PyJWTError, KeyError, TypeError, ValueError):
|
||||
raise unauthorized
|
||||
|
||||
account = await session.get(Account, account_id)
|
||||
if account is None:
|
||||
raise unauthorized
|
||||
return account
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=AuthResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def register(
|
||||
payload: AccountRegister,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> AuthResponse:
|
||||
if payload.date_of_birth >= date.today():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Date of birth must be in the past.",
|
||||
)
|
||||
validate_password(payload.password)
|
||||
email, company_domain = build_email(payload)
|
||||
phone_number = normalize_phone(payload.phone_number)
|
||||
|
||||
existing = await session.scalar(
|
||||
select(Account).where(
|
||||
or_(Account.email == email, Account.phone_number == phone_number)
|
||||
)
|
||||
)
|
||||
if existing:
|
||||
detail = (
|
||||
"That email address is already registered."
|
||||
if existing.email == email
|
||||
else "That phone number is already registered."
|
||||
)
|
||||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||
|
||||
account = Account(
|
||||
account_type=payload.account_type,
|
||||
email=email,
|
||||
first_name=payload.first_name.strip(),
|
||||
last_name=payload.last_name.strip(),
|
||||
date_of_birth=payload.date_of_birth,
|
||||
phone_number=phone_number,
|
||||
company_domain=company_domain,
|
||||
password_hash=password_hash.hash(payload.password),
|
||||
)
|
||||
session.add(account)
|
||||
try:
|
||||
await session.flush()
|
||||
if company_domain:
|
||||
existing_domain = await get_domain_by_name(session, company_domain)
|
||||
if existing_domain is None:
|
||||
await provision_mail_domain(
|
||||
session,
|
||||
domain_name=company_domain,
|
||||
domain_type="company",
|
||||
account_id=account.id,
|
||||
)
|
||||
await session.commit()
|
||||
except IntegrityError as error:
|
||||
await session.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="An account with those details already exists.",
|
||||
) from error
|
||||
await session.refresh(account)
|
||||
return AuthResponse(
|
||||
access_token=create_access_token(account),
|
||||
account=AccountRead.model_validate(account),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=AuthResponse)
|
||||
async def login(
|
||||
payload: LoginRequest,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> AuthResponse:
|
||||
account = await session.scalar(
|
||||
select(Account).where(Account.email == str(payload.email).lower())
|
||||
)
|
||||
password_is_valid = password_hash.verify(
|
||||
payload.password,
|
||||
account.password_hash if account is not None else dummy_password_hash,
|
||||
)
|
||||
if account is None or not password_is_valid:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect email or password.",
|
||||
)
|
||||
return AuthResponse(
|
||||
access_token=create_access_token(account),
|
||||
account=AccountRead.model_validate(account),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=AccountRead)
|
||||
async def get_me(account: Account = Depends(get_current_account)) -> Account:
|
||||
return account
|
||||
81
backend/app/routers/dns_setup.py
Normal file
81
backend/app/routers/dns_setup.py
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import ipaddress
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_postgres_session
|
||||
from app.deps.dns_setup_auth import require_dns_setup_auth
|
||||
from app.schemas import DnsSetupRequest, DnsSetupResponse
|
||||
from app.services.domain_provisioning import (
|
||||
build_deployment_dns_records,
|
||||
get_domain_by_name,
|
||||
validate_domain,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dns-setup", tags=["dns-setup"])
|
||||
|
||||
|
||||
def validate_mail_server_ip(value: str) -> str:
|
||||
candidate = value.strip()
|
||||
try:
|
||||
return str(ipaddress.ip_address(candidate))
|
||||
except ValueError as error:
|
||||
raise ValueError("Enter a valid IPv4 or IPv6 address.") from error
|
||||
|
||||
|
||||
@router.post("/preview", response_model=DnsSetupResponse)
|
||||
async def preview_dns_setup(
|
||||
payload: DnsSetupRequest,
|
||||
_: None = Depends(require_dns_setup_auth),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> DnsSetupResponse:
|
||||
settings = get_settings()
|
||||
try:
|
||||
domain_name = validate_domain(payload.domain)
|
||||
mail_server_ip = validate_mail_server_ip(payload.mail_server_ip)
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
domain = await get_domain_by_name(session, domain_name)
|
||||
sections = build_deployment_dns_records(
|
||||
domain_name=domain_name,
|
||||
mail_server_ip=mail_server_ip,
|
||||
dkim_selector=settings.dkim_selector,
|
||||
dkim_public_key=domain.dkim_public_key if domain else None,
|
||||
app_public_host=settings.app_public_host,
|
||||
)
|
||||
mail_host = f"mail.{domain_name}"
|
||||
return DnsSetupResponse(
|
||||
domain=domain_name,
|
||||
mail_host=mail_host,
|
||||
app_public_host=settings.app_public_host,
|
||||
mail_server_ip=mail_server_ip,
|
||||
is_platform_domain=domain_name == settings.platform_domain,
|
||||
sections=sections,
|
||||
notes=_build_notes(
|
||||
domain_name,
|
||||
settings.app_public_host,
|
||||
mail_server_ip,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_notes(
|
||||
domain_name: str,
|
||||
app_public_host: str,
|
||||
mail_server_ip: str,
|
||||
) -> list[str]:
|
||||
return [
|
||||
"Add these records in Namecheap Advanced DNS for the zone.",
|
||||
f"Point mail.{domain_name} and {app_public_host} A records to {mail_server_ip}.",
|
||||
f"Keep the root {domain_name} A record on your main website server if it hosts the marketing site.",
|
||||
f"Set {domain_name} MX to mail.{domain_name} (priority 10) so inbound mail reaches Postfix on this host.",
|
||||
"Postfix accepts inbound mail on :25 and delivers outbound mail for the app over the Docker network.",
|
||||
f"Users open the web UI at https://{app_public_host} (Caddy terminates TLS).",
|
||||
f"Ask your VPS provider to set reverse DNS (PTR) for {mail_server_ip} → mail.{domain_name}.",
|
||||
"SPF, DKIM, and DMARC authenticate outbound mail signed by Agmail (selector agmail).",
|
||||
]
|
||||
249
backend/app/routers/domains.py
Normal file
249
backend/app/routers/domains.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.config import get_settings
|
||||
from app.database import get_postgres_session
|
||||
from app.models import Account, MailDomain
|
||||
from app.routers.auth import get_current_account
|
||||
from app.schemas import (
|
||||
DnsVerificationResponse,
|
||||
DomainCreate,
|
||||
MailDomainRead,
|
||||
SmtpCredentialCreate,
|
||||
SmtpCredentialCreated,
|
||||
SmtpCredentialRead,
|
||||
)
|
||||
from app.services.dns_verification import verify_company_dns
|
||||
from app.services.domain_provisioning import (
|
||||
activate_company_domain,
|
||||
create_smtp_credential,
|
||||
ensure_platform_domain,
|
||||
get_account_company_domain,
|
||||
provision_mail_domain,
|
||||
validate_domain,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/domains", tags=["domains"])
|
||||
|
||||
|
||||
async def get_domain_or_404(
|
||||
domain_id: UUID,
|
||||
session: AsyncSession,
|
||||
) -> MailDomain:
|
||||
domain = await session.scalar(
|
||||
select(MailDomain)
|
||||
.options(
|
||||
selectinload(MailDomain.dns_records),
|
||||
selectinload(MailDomain.smtp_credentials),
|
||||
)
|
||||
.where(MailDomain.id == domain_id)
|
||||
)
|
||||
if domain is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Domain not found.",
|
||||
)
|
||||
return domain
|
||||
|
||||
|
||||
@router.get("/me", response_model=MailDomainRead)
|
||||
async def get_my_company_domain(
|
||||
account: Account = Depends(get_current_account),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> MailDomain:
|
||||
if account.account_type != "company" or not account.company_domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No company domain is linked to this account.",
|
||||
)
|
||||
domain = await get_account_company_domain(
|
||||
session,
|
||||
account.id,
|
||||
account.company_domain,
|
||||
)
|
||||
if domain is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company domain has not been provisioned yet.",
|
||||
)
|
||||
return domain
|
||||
|
||||
|
||||
@router.post("/me/verify-dns", response_model=DnsVerificationResponse)
|
||||
async def verify_my_company_dns(
|
||||
account: Account = Depends(get_current_account),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> DnsVerificationResponse:
|
||||
settings = get_settings()
|
||||
if account.account_type != "company" or not account.company_domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="No company domain is linked to this account.",
|
||||
)
|
||||
domain = await get_account_company_domain(
|
||||
session,
|
||||
account.id,
|
||||
account.company_domain,
|
||||
)
|
||||
if domain is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Company domain has not been provisioned yet.",
|
||||
)
|
||||
if domain.status == "active":
|
||||
return DnsVerificationResponse(
|
||||
domain=domain.domain_name,
|
||||
status=domain.status,
|
||||
verified=True,
|
||||
checks=[],
|
||||
)
|
||||
|
||||
checks = verify_company_dns(
|
||||
domain_name=domain.domain_name,
|
||||
shared_mail_host=settings.smtp_public_host,
|
||||
dkim_selector=domain.dkim_selector,
|
||||
dkim_public_key=domain.dkim_public_key,
|
||||
platform_spf_include=settings.platform_spf_include,
|
||||
)
|
||||
verified = all(check["verified"] for check in checks)
|
||||
if verified:
|
||||
await activate_company_domain(session, domain)
|
||||
await session.commit()
|
||||
return DnsVerificationResponse(
|
||||
domain=domain.domain_name,
|
||||
status=domain.status if not verified else "active",
|
||||
verified=verified,
|
||||
checks=checks,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/platform", response_model=MailDomainRead)
|
||||
async def get_platform_domain(
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> MailDomain:
|
||||
domain = await ensure_platform_domain(session)
|
||||
await session.commit()
|
||||
return await get_domain_or_404(domain.id, session)
|
||||
|
||||
|
||||
@router.get("", response_model=list[MailDomainRead])
|
||||
async def list_domains(
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> list[MailDomain]:
|
||||
await ensure_platform_domain(session)
|
||||
await session.commit()
|
||||
result = await session.scalars(
|
||||
select(MailDomain)
|
||||
.options(selectinload(MailDomain.dns_records))
|
||||
.order_by(MailDomain.created_at.desc())
|
||||
)
|
||||
return list(result.all())
|
||||
|
||||
|
||||
@router.post("", response_model=MailDomainRead, status_code=status.HTTP_201_CREATED)
|
||||
async def register_company_domain(
|
||||
payload: DomainCreate,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> MailDomain:
|
||||
settings = get_settings()
|
||||
try:
|
||||
normalized = validate_domain(payload.domain_name)
|
||||
except ValueError as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=str(error),
|
||||
) from error
|
||||
|
||||
if normalized == settings.platform_domain:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Use the platform domain endpoint for getall.help.",
|
||||
)
|
||||
|
||||
existing = await session.scalar(
|
||||
select(MailDomain).where(MailDomain.domain_name == normalized)
|
||||
)
|
||||
if existing:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="That domain is already registered.",
|
||||
)
|
||||
|
||||
domain = await provision_mail_domain(
|
||||
session,
|
||||
domain_name=normalized,
|
||||
domain_type="company",
|
||||
account_id=payload.account_id,
|
||||
)
|
||||
await session.commit()
|
||||
return await get_domain_or_404(domain.id, session)
|
||||
|
||||
|
||||
@router.get("/{domain_id}", response_model=MailDomainRead)
|
||||
async def get_domain(
|
||||
domain_id: UUID,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
) -> MailDomain:
|
||||
return await get_domain_or_404(domain_id, session)
|
||||
|
||||
|
||||
@router.get("/{domain_id}/dns-records", response_model=list)
|
||||
async def list_dns_records(
|
||||
domain_id: UUID,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
):
|
||||
domain = await get_domain_or_404(domain_id, session)
|
||||
return [
|
||||
{
|
||||
"type": record.record_type,
|
||||
"host": record.host,
|
||||
"value": record.value,
|
||||
"purpose": record.purpose,
|
||||
"ttl": record.ttl,
|
||||
}
|
||||
for record in domain.dns_records
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{domain_id}/smtp-credentials", response_model=list[SmtpCredentialRead])
|
||||
async def list_smtp_credentials(
|
||||
domain_id: UUID,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
):
|
||||
domain = await get_domain_or_404(domain_id, session)
|
||||
return domain.smtp_credentials
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{domain_id}/smtp-credentials",
|
||||
response_model=SmtpCredentialCreated,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_domain_smtp_credential(
|
||||
domain_id: UUID,
|
||||
payload: SmtpCredentialCreate,
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
):
|
||||
settings = get_settings()
|
||||
domain = await get_domain_or_404(domain_id, session)
|
||||
credential, plain_password = await create_smtp_credential(
|
||||
session,
|
||||
domain,
|
||||
label=payload.label,
|
||||
)
|
||||
await session.commit()
|
||||
return SmtpCredentialCreated(
|
||||
id=credential.id,
|
||||
username=credential.username,
|
||||
label=credential.label,
|
||||
is_active=credential.is_active,
|
||||
created_at=credential.created_at,
|
||||
password=plain_password,
|
||||
smtp_host=settings.smtp_public_host,
|
||||
smtp_port=settings.smtp_port,
|
||||
use_tls=settings.smtp_use_tls,
|
||||
)
|
||||
464
backend/app/routers/emails.py
Normal file
464
backend/app/routers/emails.py
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
import base64
|
||||
import json
|
||||
import re
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from typing import Annotated
|
||||
|
||||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_mongo_database, get_postgres_session
|
||||
from app.models import Account
|
||||
from app.routers.auth import get_current_account
|
||||
from app.schemas import (
|
||||
EmailCreate,
|
||||
EmailRead,
|
||||
MailAddress,
|
||||
MessagePage,
|
||||
MessageRead,
|
||||
MessageReadState,
|
||||
MessageStarState,
|
||||
MessageSummary,
|
||||
utc_now,
|
||||
)
|
||||
from app.services.domain_provisioning import get_domain_by_name
|
||||
from app.services.mail_sender import deliver_email
|
||||
from app.services.mime_parser import parse_mime
|
||||
from app.services.raw_mime_storage import get_raw_mime_storage
|
||||
|
||||
router = APIRouter(prefix="/emails", tags=["emails"])
|
||||
|
||||
|
||||
def serialize_email(document: dict, viewer_email: str) -> EmailRead:
|
||||
return EmailRead(
|
||||
id=str(document["_id"]),
|
||||
sender=document["sender"],
|
||||
recipients=document["recipients"],
|
||||
cc=document.get("cc", []),
|
||||
bcc=document.get("bcc", []) if document["sender"] == viewer_email else [],
|
||||
subject=document["subject"],
|
||||
body_text=document["body_text"],
|
||||
body_html=document.get("body_html"),
|
||||
status=document["status"],
|
||||
created_at=document["created_at"],
|
||||
)
|
||||
|
||||
|
||||
def sender_domain(address: str) -> str:
|
||||
return address.split("@", 1)[1].lower()
|
||||
|
||||
|
||||
def address(value: str, name: str | None = None) -> dict[str, str]:
|
||||
return {"name": name or value, "address": value.lower()}
|
||||
|
||||
|
||||
def encode_cursor(entry: dict) -> str:
|
||||
value = json.dumps(
|
||||
{"created_at": entry["created_at"].isoformat(), "id": str(entry["_id"])},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
|
||||
|
||||
|
||||
def decode_cursor(value: str) -> tuple[datetime, ObjectId]:
|
||||
try:
|
||||
padded = value + "=" * (-len(value) % 4)
|
||||
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||
return datetime.fromisoformat(payload["created_at"]), ObjectId(payload["id"])
|
||||
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid cursor.",
|
||||
) from error
|
||||
|
||||
|
||||
def summarize(message: dict, entry: dict) -> MessageSummary:
|
||||
sender = message.get("sender", {"name": "Unknown sender", "address": ""})
|
||||
recipients = message.get("to") or [
|
||||
address(value) for value in message.get("recipients", [])
|
||||
]
|
||||
mailbox = "trash" if entry.get("trashed_at") else entry["mailbox"]
|
||||
return MessageSummary(
|
||||
id=str(message["_id"]),
|
||||
mailbox=mailbox,
|
||||
direction=message.get("direction", "outbound"),
|
||||
sender=MailAddress(**sender),
|
||||
recipients=[MailAddress(**item) for item in recipients],
|
||||
subject=message.get("subject", ""),
|
||||
snippet=message.get("snippet", message.get("body_text", "")[:240]),
|
||||
status=message.get("status", "received"),
|
||||
created_at=message.get("received_at") or message["created_at"],
|
||||
is_read=entry.get("is_read", True),
|
||||
is_starred=entry.get("is_starred", False),
|
||||
trashed_at=entry.get("trashed_at"),
|
||||
attachments=message.get("attachments", []),
|
||||
)
|
||||
|
||||
|
||||
async def legacy_email(database, email_id: ObjectId, viewer_email: str) -> dict | None:
|
||||
document = await database.emails.find_one({"_id": email_id})
|
||||
if document is None:
|
||||
return None
|
||||
visible_to = {
|
||||
document["sender"],
|
||||
*document.get("recipients", []),
|
||||
*document.get("cc", []),
|
||||
*document.get("bcc", []),
|
||||
}
|
||||
return document if viewer_email in visible_to else None
|
||||
|
||||
|
||||
@router.post("", response_model=EmailRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_email(
|
||||
payload: EmailCreate,
|
||||
database=Depends(get_mongo_database),
|
||||
session: AsyncSession = Depends(get_postgres_session),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
sender = account.email.lower()
|
||||
recipients = [str(recipient).lower() for recipient in payload.recipients]
|
||||
cc = [str(recipient).lower() for recipient in payload.cc]
|
||||
bcc = [str(recipient).lower() for recipient in payload.bcc]
|
||||
all_recipients = [*recipients, *cc, *bcc]
|
||||
if len(set(all_recipients)) != len(all_recipients):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Each recipient may only appear once across To, Cc, and Bcc.",
|
||||
)
|
||||
domain = await get_domain_by_name(session, sender_domain(sender))
|
||||
if domain is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Sender domain is not registered with agmail.",
|
||||
)
|
||||
if domain.domain_type == "company" and domain.status != "active":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Complete DNS setup for your company domain before sending mail.",
|
||||
)
|
||||
|
||||
created_at = utc_now()
|
||||
document = {
|
||||
"direction": "outbound",
|
||||
"account_id": str(account.id),
|
||||
"sender": address(
|
||||
sender,
|
||||
" ".join(part for part in [account.first_name, account.last_name] if part),
|
||||
),
|
||||
"to": [address(value) for value in recipients],
|
||||
"cc": [address(value) for value in cc],
|
||||
"bcc": [address(value) for value in bcc],
|
||||
"subject": payload.subject,
|
||||
"snippet": re.sub(r"\s+", " ", payload.body_text).strip()[:240],
|
||||
"body_text": payload.body_text,
|
||||
"body_html": payload.body_html,
|
||||
"attachments": [],
|
||||
"created_at": created_at,
|
||||
"status": "queued",
|
||||
}
|
||||
|
||||
try:
|
||||
await deliver_email(
|
||||
sender=sender,
|
||||
recipients=recipients,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
subject=document["subject"],
|
||||
body_text=document["body_text"],
|
||||
body_html=document.get("body_html"),
|
||||
domain=domain,
|
||||
)
|
||||
document["status"] = "accepted"
|
||||
except Exception as error:
|
||||
document["status"] = "failed"
|
||||
document["error"] = str(error)
|
||||
|
||||
result = await database.messages.insert_one(document)
|
||||
document["_id"] = result.inserted_id
|
||||
await database.mailbox_entries.insert_one(
|
||||
{
|
||||
"account_id": str(account.id),
|
||||
"message_id": result.inserted_id,
|
||||
"mailbox": "sent",
|
||||
"direction": "outbound",
|
||||
"sender": document["sender"],
|
||||
"recipients": document["to"],
|
||||
"subject": document["subject"],
|
||||
"snippet": document["snippet"],
|
||||
"attachments": [],
|
||||
"status": document["status"],
|
||||
"is_read": True,
|
||||
"is_starred": False,
|
||||
"event_at": created_at,
|
||||
"created_at": created_at,
|
||||
"trashed_at": None,
|
||||
"deletion_state": None,
|
||||
}
|
||||
)
|
||||
if document["status"] == "failed":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=document.get("error", "Unable to deliver email."),
|
||||
)
|
||||
return EmailRead(
|
||||
id=str(document["_id"]),
|
||||
sender=sender,
|
||||
recipients=recipients,
|
||||
cc=cc,
|
||||
bcc=bcc,
|
||||
subject=payload.subject,
|
||||
body_text=payload.body_text,
|
||||
body_html=payload.body_html,
|
||||
status=document["status"],
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{email_id}", response_model=MessageRead | EmailRead)
|
||||
async def get_email(
|
||||
email_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
if not ObjectId.is_valid(email_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid email ID.",
|
||||
)
|
||||
message_id = ObjectId(email_id)
|
||||
viewer_email = account.email.lower()
|
||||
entry = await database.mailbox_entries.find_one(
|
||||
{"account_id": str(account.id), "message_id": message_id}
|
||||
)
|
||||
document = (
|
||||
await database.messages.find_one({"_id": message_id})
|
||||
if entry is not None
|
||||
else None
|
||||
)
|
||||
if document is None or entry is None:
|
||||
legacy = await legacy_email(database, message_id, viewer_email)
|
||||
if legacy is not None:
|
||||
return serialize_email(legacy, viewer_email)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Email not found.",
|
||||
)
|
||||
body_text = document.get("body_text", "")
|
||||
body_html = document.get("body_html")
|
||||
if document.get("raw_mime"):
|
||||
raw = await get_raw_mime_storage().get(document["raw_mime"])
|
||||
parsed = parse_mime(raw)
|
||||
body_text = parsed["body_text"]
|
||||
body_html = parsed["body_html"]
|
||||
summary = summarize(document, entry)
|
||||
return MessageRead(
|
||||
**summary.model_dump(),
|
||||
cc=document.get("cc", []),
|
||||
bcc=document.get("bcc", []) if document.get("account_id") == str(account.id) else [],
|
||||
reply_to=document.get("reply_to", []),
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
message_id=document.get("message_id"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=MessagePage)
|
||||
async def list_emails(
|
||||
mailbox: Annotated[str, Query(pattern="^(inbox|sent|trash)$")] = "inbox",
|
||||
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
||||
cursor: str | None = None,
|
||||
q: Annotated[str | None, Query(max_length=200)] = None,
|
||||
is_read: bool | None = None,
|
||||
sender: Annotated[str | None, Query(max_length=320)] = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
entry_match: dict = {
|
||||
"account_id": str(account.id),
|
||||
"deletion_state": {"$in": [None, ""]},
|
||||
}
|
||||
if mailbox == "trash":
|
||||
entry_match["trashed_at"] = {"$ne": None}
|
||||
else:
|
||||
entry_match.update({"mailbox": mailbox, "trashed_at": None})
|
||||
if is_read is not None:
|
||||
entry_match["is_read"] = is_read
|
||||
if sender:
|
||||
pattern = f"^{re.escape(sender.strip())}"
|
||||
entry_match["$or"] = [
|
||||
{"sender.name": {"$regex": pattern, "$options": "i"}},
|
||||
{"sender.address": {"$regex": pattern, "$options": "i"}},
|
||||
]
|
||||
if date_from or date_to:
|
||||
event_range: dict = {}
|
||||
if date_from:
|
||||
event_range["$gte"] = datetime.combine(
|
||||
date_from,
|
||||
time.min,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
if date_to:
|
||||
event_range["$lt"] = datetime.combine(
|
||||
date_to + timedelta(days=1),
|
||||
time.min,
|
||||
tzinfo=timezone.utc,
|
||||
)
|
||||
entry_match["event_at"] = event_range
|
||||
if q and q.strip():
|
||||
entry_match["$text"] = {"$search": q.strip()}
|
||||
if cursor:
|
||||
created_at, entry_id = decode_cursor(cursor)
|
||||
cursor_clause = [
|
||||
{"created_at": {"$lt": created_at}},
|
||||
{"created_at": created_at, "_id": {"$lt": entry_id}},
|
||||
]
|
||||
if "$or" in entry_match:
|
||||
existing_or = entry_match.pop("$or")
|
||||
entry_match["$and"] = [{"$or": existing_or}, {"$or": cursor_clause}]
|
||||
else:
|
||||
entry_match["$or"] = cursor_clause
|
||||
pipeline: list[dict] = [
|
||||
{"$match": entry_match},
|
||||
{"$sort": {"created_at": -1, "_id": -1}},
|
||||
{"$limit": limit + 1},
|
||||
{
|
||||
"$lookup": {
|
||||
"from": "messages",
|
||||
"localField": "message_id",
|
||||
"foreignField": "_id",
|
||||
"as": "message",
|
||||
}
|
||||
},
|
||||
{"$unwind": "$message"},
|
||||
]
|
||||
cursor = await database.mailbox_entries.aggregate(pipeline)
|
||||
rows = [row async for row in cursor]
|
||||
has_more = len(rows) > limit
|
||||
rows = rows[:limit]
|
||||
return MessagePage(
|
||||
items=[summarize(row["message"], row) for row in rows],
|
||||
next_cursor=encode_cursor(rows[-1]) if has_more and rows else None,
|
||||
)
|
||||
|
||||
|
||||
async def require_entry(database, account: Account, email_id: str) -> tuple[ObjectId, dict]:
|
||||
if not ObjectId.is_valid(email_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid email ID.",
|
||||
)
|
||||
message_id = ObjectId(email_id)
|
||||
entry = await database.mailbox_entries.find_one(
|
||||
{"account_id": str(account.id), "message_id": message_id}
|
||||
)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email not found.")
|
||||
return message_id, entry
|
||||
|
||||
|
||||
@router.patch("/{email_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def set_email_read(
|
||||
email_id: str,
|
||||
payload: MessageReadState,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
message_id, _ = await require_entry(database, account, email_id)
|
||||
await database.mailbox_entries.update_one(
|
||||
{"account_id": str(account.id), "message_id": message_id},
|
||||
{"$set": {"is_read": payload.is_read}},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{email_id}/star", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def set_email_star(
|
||||
email_id: str,
|
||||
payload: MessageStarState,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
message_id, _ = await require_entry(database, account, email_id)
|
||||
await database.mailbox_entries.update_one(
|
||||
{"account_id": str(account.id), "message_id": message_id},
|
||||
{"$set": {"is_starred": payload.is_starred}},
|
||||
)
|
||||
|
||||
|
||||
async def move_to_trash(database, account: Account, email_id: str) -> None:
|
||||
message_id, _ = await require_entry(database, account, email_id)
|
||||
await database.mailbox_entries.update_one(
|
||||
{"account_id": str(account.id), "message_id": message_id},
|
||||
{"$set": {"trashed_at": utc_now()}},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{email_id}/trash", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def trash_email(
|
||||
email_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
await move_to_trash(database, account, email_id)
|
||||
|
||||
|
||||
@router.delete("/{email_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_email(
|
||||
email_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
await move_to_trash(database, account, email_id)
|
||||
|
||||
|
||||
@router.post("/{email_id}/restore", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def restore_email(
|
||||
email_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
message_id, _ = await require_entry(database, account, email_id)
|
||||
await database.mailbox_entries.update_one(
|
||||
{"account_id": str(account.id), "message_id": message_id},
|
||||
{"$set": {"trashed_at": None}},
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{email_id}/permanent", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def permanently_delete_email(
|
||||
email_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
message_id, entry = await require_entry(database, account, email_id)
|
||||
if entry.get("trashed_at") is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="Email must be in trash before permanent deletion.",
|
||||
)
|
||||
message = await database.messages.find_one({"_id": message_id})
|
||||
other_entries = await database.mailbox_entries.count_documents(
|
||||
{"message_id": message_id, "_id": {"$ne": entry["_id"]}}
|
||||
)
|
||||
await database.mailbox_entries.update_one(
|
||||
{"_id": entry["_id"]},
|
||||
{"$set": {"deletion_state": "deleting"}},
|
||||
)
|
||||
if other_entries == 0 and message and message.get("raw_mime"):
|
||||
await get_raw_mime_storage().delete(message["raw_mime"])
|
||||
await database.mailbox_entries.delete_one({"_id": entry["_id"]})
|
||||
if other_entries == 0:
|
||||
if message and message.get("delivery_key"):
|
||||
await database.message_tombstones.update_one(
|
||||
{"delivery_key": message["delivery_key"]},
|
||||
{
|
||||
"$setOnInsert": {
|
||||
"delivery_key": message["delivery_key"],
|
||||
"deleted_at": utc_now(),
|
||||
}
|
||||
},
|
||||
upsert=True,
|
||||
)
|
||||
await database.messages.delete_one({"_id": message_id})
|
||||
109
backend/app/routers/sticky_notes.py
Normal file
109
backend/app/routers/sticky_notes.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from bson import ObjectId
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pymongo import ReturnDocument
|
||||
|
||||
from app.database import get_mongo_database
|
||||
from app.models import Account
|
||||
from app.routers.auth import get_current_account
|
||||
from app.schemas import StickyNoteCreate, StickyNoteRead, StickyNoteUpdate, utc_now
|
||||
|
||||
router = APIRouter(prefix="/sticky-notes", tags=["sticky-notes"])
|
||||
|
||||
|
||||
def serialize_note(document: dict) -> StickyNoteRead:
|
||||
return StickyNoteRead(
|
||||
id=str(document["_id"]),
|
||||
title=document["title"],
|
||||
body=document.get("body", ""),
|
||||
tags=document.get("tags", []),
|
||||
color=document.get("color", "yellow"),
|
||||
created_at=document["created_at"],
|
||||
updated_at=document["updated_at"],
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[StickyNoteRead])
|
||||
async def list_sticky_notes(
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
cursor = database.sticky_notes.find({"account_id": str(account.id)}).sort(
|
||||
"updated_at", -1
|
||||
)
|
||||
return [serialize_note(document) async for document in cursor]
|
||||
|
||||
|
||||
@router.post("", response_model=StickyNoteRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_sticky_note(
|
||||
payload: StickyNoteCreate,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
now = utc_now()
|
||||
document = {
|
||||
"account_id": str(account.id),
|
||||
"title": payload.title.strip(),
|
||||
"body": payload.body.strip(),
|
||||
"tags": [tag.strip() for tag in payload.tags if tag.strip()],
|
||||
"color": payload.color,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
result = await database.sticky_notes.insert_one(document)
|
||||
document["_id"] = result.inserted_id
|
||||
return serialize_note(document)
|
||||
|
||||
|
||||
@router.patch("/{note_id}", response_model=StickyNoteRead)
|
||||
async def update_sticky_note(
|
||||
note_id: str,
|
||||
payload: StickyNoteUpdate,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
if not ObjectId.is_valid(note_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid note ID.",
|
||||
)
|
||||
updates = payload.model_dump(exclude_unset=True)
|
||||
if "title" in updates:
|
||||
updates["title"] = updates["title"].strip()
|
||||
if "body" in updates:
|
||||
updates["body"] = updates["body"].strip()
|
||||
if "tags" in updates and updates["tags"] is not None:
|
||||
updates["tags"] = [tag.strip() for tag in updates["tags"] if tag.strip()]
|
||||
if not updates:
|
||||
document = await database.sticky_notes.find_one(
|
||||
{"_id": ObjectId(note_id), "account_id": str(account.id)}
|
||||
)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")
|
||||
return serialize_note(document)
|
||||
updates["updated_at"] = utc_now()
|
||||
document = await database.sticky_notes.find_one_and_update(
|
||||
{"_id": ObjectId(note_id), "account_id": str(account.id)},
|
||||
{"$set": updates},
|
||||
return_document=ReturnDocument.AFTER,
|
||||
)
|
||||
if document is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")
|
||||
return serialize_note(document)
|
||||
|
||||
|
||||
@router.delete("/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_sticky_note(
|
||||
note_id: str,
|
||||
database=Depends(get_mongo_database),
|
||||
account: Account = Depends(get_current_account),
|
||||
):
|
||||
if not ObjectId.is_valid(note_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Invalid note ID.",
|
||||
)
|
||||
result = await database.sticky_notes.delete_one(
|
||||
{"_id": ObjectId(note_id), "account_id": str(account.id)}
|
||||
)
|
||||
if result.deleted_count == 0:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")
|
||||
Loading…
Add table
Add a link
Reference in a new issue