first commit

This commit is contained in:
debu-2 2026-10-03 21:14:44 +05:30
commit cce9a8c9ba
73 changed files with 8665 additions and 0 deletions

View file

@ -0,0 +1 @@
"""API route modules."""

212
backend/app/routers/auth.py Normal file
View 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

View 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).",
]

View 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,
)

View 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})

View 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.")