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

5
backend/.dockerignore Normal file
View file

@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
.pytest_cache/
.venv/
.env

15
backend/Dockerfile Normal file
View file

@ -0,0 +1,15 @@
FROM python:3.13-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app ./app
EXPOSE 8000 2525
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

1
backend/app/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Agmail API package."""

66
backend/app/config.py Normal file
View file

@ -0,0 +1,66 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "Agmail API"
environment: str = "development"
api_prefix: str = "/api/v1"
cors_origins: list[str] = ["http://localhost:5173"]
database_url: str = (
"postgresql+asyncpg://agmail:agmail_dev_password"
"@localhost:5432/agmail_profiles"
)
mongo_url: str = (
"mongodb://agmail:agmail_dev_password@localhost:27017"
"/?authSource=admin"
)
mongo_database: str = "agmail_messages"
jwt_secret: str = "replace-this-development-secret"
access_token_minutes: int = 60
platform_domain: str = "getall.help"
platform_spf_include: str = "_spf.getall.help"
app_public_host: str = "email.getall.help"
dkim_selector: str = "agmail"
# Outbound via self-hosted Postfix (Docker service `smtp` by default)
smtp_host: str = "smtp"
smtp_port: int = 25
smtp_public_host: str = "mail.getall.help"
smtp_username: str = ""
smtp_password: str = ""
smtp_use_tls: bool = False
smtp_timeout: int = 30
smtp_health_required: bool = False
relay_domains_file: str = "/data/relay_domains/domains.list"
inbound_smtp_enabled: bool = True
inbound_smtp_host: str = "127.0.0.1"
inbound_smtp_port: int = 2525
inbound_allowed_cidrs: list[str] = [
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
]
inbound_max_bytes: int = 25 * 1024 * 1024
gcs_raw_mime_bucket: str = ""
gcs_raw_mime_prefix: str = "raw"
gcs_project: str | None = None
dns_setup_username: str = "smtp-credentia"
dns_setup_password: str = "Nishtha#01"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
)
@lru_cache
def get_settings() -> Settings:
return Settings()

27
backend/app/database.py Normal file
View file

@ -0,0 +1,27 @@
from collections.abc import AsyncIterator
from pymongo import AsyncMongoClient
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.config import get_settings
settings = get_settings()
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
mongo_client: AsyncMongoClient = AsyncMongoClient(settings.mongo_url)
mongo_db = mongo_client[settings.mongo_database]
async def get_postgres_session() -> AsyncIterator[AsyncSession]:
async with SessionFactory() as session:
yield session
def get_mongo_database():
return mongo_db

View file

View file

@ -0,0 +1,28 @@
import secrets
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from app.config import get_settings
security = HTTPBasic()
async def require_dns_setup_auth(
credentials: HTTPBasicCredentials = Depends(security),
) -> None:
settings = get_settings()
username_ok = secrets.compare_digest(
credentials.username,
settings.dns_setup_username,
)
password_ok = secrets.compare_digest(
credentials.password,
settings.dns_setup_password,
)
if not (username_ok and password_ok):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid DNS setup credentials.",
headers={"WWW-Authenticate": "Basic"},
)

185
backend/app/main.py Normal file
View file

@ -0,0 +1,185 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Response, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from app.config import get_settings
from app.database import SessionFactory, engine, mongo_client, mongo_db
from app.models import Base
from app.routers import auth, dns_setup, domains, emails, sticky_notes
from app.services.domain_provisioning import ensure_platform_domain
from app.services.inbound_smtp import InboundSMTPServer
settings = get_settings()
inbound_server = InboundSMTPServer()
async def migrate_legacy_user_tables(connection) -> None:
"""Promote the former auth table to users without discarding old profiles."""
tables = (
await connection.execute(
text(
"SELECT to_regclass('public.accounts'), "
"to_regclass('public.users'), "
"to_regclass('public.legacy_users')"
)
)
).one()
accounts_table, users_table, legacy_users_table = tables
if accounts_table is None:
return
if users_table is not None:
if legacy_users_table is not None:
raise RuntimeError(
"Cannot migrate accounts: both users and legacy_users already exist."
)
await connection.execute(text("ALTER TABLE users RENAME TO legacy_users"))
await connection.execute(text("ALTER TABLE accounts RENAME TO users"))
@asynccontextmanager
async def lifespan(_: FastAPI):
async with engine.begin() as connection:
await migrate_legacy_user_tables(connection)
await connection.run_sync(Base.metadata.create_all)
async with SessionFactory() as session:
await ensure_platform_domain(session)
await session.commit()
await mongo_db.emails.create_index([("recipients", 1), ("created_at", -1)])
await mongo_db.emails.create_index([("cc", 1), ("created_at", -1)])
await mongo_db.emails.create_index([("bcc", 1), ("created_at", -1)])
await mongo_db.emails.create_index([("sender", 1), ("created_at", -1)])
await mongo_db.emails.create_index([("account_id", 1), ("created_at", -1)])
await mongo_db.messages.create_index(
[("delivery_key", 1)],
unique=True,
sparse=True,
)
await mongo_db.message_tombstones.create_index(
[("delivery_key", 1)],
unique=True,
)
await mongo_db.messages.create_index(
[("subject", "text"), ("snippet", "text"), ("sender.name", "text")]
)
await mongo_db.mailbox_entries.create_index(
[
("account_id", 1),
("mailbox", 1),
("trashed_at", 1),
("deletion_state", 1),
("created_at", -1),
("_id", -1),
]
)
await mongo_db.mailbox_entries.create_index(
[("account_id", 1), ("is_read", 1), ("created_at", -1), ("_id", -1)]
)
await mongo_db.mailbox_entries.create_index(
[("account_id", 1), ("message_id", 1), ("mailbox", 1)],
unique=True,
)
await mongo_db.mailbox_entries.create_index(
[("account_id", 1), ("is_starred", 1), ("created_at", -1), ("_id", -1)]
)
await mongo_db.sticky_notes.create_index(
[("account_id", 1), ("updated_at", -1)]
)
await mongo_db.mailbox_entries.create_index(
[
("account_id", 1),
("sender.name", "text"),
("sender.address", "text"),
("subject", "text"),
("snippet", "text"),
],
name="mailbox_search",
)
if settings.inbound_smtp_enabled:
await inbound_server.start()
try:
yield
finally:
await inbound_server.stop()
await engine.dispose()
await mongo_client.close()
app = FastAPI(
title=settings.app_name,
version="0.1.0",
description="The API for Agmail — email for people and their AI agents.",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(emails.router, prefix=settings.api_prefix)
app.include_router(sticky_notes.router, prefix=settings.api_prefix)
app.include_router(auth.router, prefix=settings.api_prefix)
app.include_router(domains.router, prefix=settings.api_prefix)
app.include_router(dns_setup.router, prefix=settings.api_prefix)
@app.get("/")
async def root():
return {
"name": settings.app_name,
"docs": "/docs",
"version": "0.1.0",
}
@app.get("/health")
async def health(response: Response):
checks = {
"postgres": False,
"mongodb": False,
"smtp": False,
"inbound_smtp": (
not settings.inbound_smtp_enabled
or (
inbound_server.server is not None
and inbound_server.server.is_serving()
)
),
}
try:
async with SessionFactory() as session:
await session.execute(text("SELECT 1"))
checks["postgres"] = True
except Exception:
pass
try:
await mongo_db.command("ping")
checks["mongodb"] = True
except Exception:
pass
try:
import socket
with socket.create_connection(
(settings.smtp_host, settings.smtp_port),
timeout=3,
):
checks["smtp"] = True
except Exception:
pass
healthy = checks["postgres"] and checks["mongodb"]
if settings.inbound_smtp_enabled:
healthy = healthy and checks["inbound_smtp"]
if settings.smtp_health_required:
healthy = healthy and checks["smtp"]
if not healthy:
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
return {"status": "healthy" if healthy else "degraded", "checks": checks}

129
backend/app/models.py Normal file
View file

@ -0,0 +1,129 @@
import uuid
from datetime import date, datetime
from sqlalchemy import (
Boolean,
CheckConstraint,
Date,
DateTime,
ForeignKey,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase):
pass
class Account(Base):
__tablename__ = "users"
__table_args__ = (
CheckConstraint(
"account_type IN ('individual', 'company')",
name="ck_users_account_type",
),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
account_type: Mapped[str] = mapped_column(String(20))
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
first_name: Mapped[str] = mapped_column(String(80))
last_name: Mapped[str] = mapped_column(String(80))
date_of_birth: Mapped[date] = mapped_column(Date)
phone_number: Mapped[str] = mapped_column(String(24), unique=True)
company_domain: Mapped[str | None] = mapped_column(String(253), nullable=True)
password_hash: Mapped[str] = mapped_column(String(255))
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
mail_domains: Mapped[list["MailDomain"]] = relationship(back_populates="account")
class MailDomain(Base):
__tablename__ = "mail_domains"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
domain_name: Mapped[str] = mapped_column(String(253), unique=True, index=True)
domain_type: Mapped[str] = mapped_column(String(20))
account_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("users.id"),
nullable=True,
)
mail_host: Mapped[str] = mapped_column(String(253))
dkim_selector: Mapped[str] = mapped_column(String(63))
dkim_private_key: Mapped[str] = mapped_column(Text)
dkim_public_key: Mapped[str] = mapped_column(Text)
status: Mapped[str] = mapped_column(String(20), default="pending_dns")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
account: Mapped[Account | None] = relationship(back_populates="mail_domains")
dns_records: Mapped[list["DomainDnsRecord"]] = relationship(
back_populates="domain",
cascade="all, delete-orphan",
)
smtp_credentials: Mapped[list["SmtpCredential"]] = relationship(
back_populates="domain",
cascade="all, delete-orphan",
)
class DomainDnsRecord(Base):
__tablename__ = "domain_dns_records"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("mail_domains.id"),
index=True,
)
record_type: Mapped[str] = mapped_column(String(10))
host: Mapped[str] = mapped_column(String(253))
value: Mapped[str] = mapped_column(Text)
purpose: Mapped[str] = mapped_column(String(20))
ttl: Mapped[int] = mapped_column(Integer, default=3600)
domain: Mapped[MailDomain] = relationship(back_populates="dns_records")
class SmtpCredential(Base):
__tablename__ = "smtp_credentials"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
domain_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("mail_domains.id"),
index=True,
)
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(255))
label: Mapped[str] = mapped_column(String(120))
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
)
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")

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

229
backend/app/schemas.py Normal file
View file

@ -0,0 +1,229 @@
from datetime import datetime, timezone
from datetime import date
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class AccountRegister(BaseModel):
account_type: Literal["individual", "company"]
email_name: str = Field(
min_length=1,
max_length=64,
pattern=r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$",
)
company_domain: str | None = Field(default=None, max_length=253)
first_name: str = Field(min_length=1, max_length=80)
last_name: str = Field(min_length=1, max_length=80)
date_of_birth: date
phone_number: str = Field(
min_length=7,
max_length=24,
pattern=r"^\+?[0-9][0-9 ()-]{5,22}[0-9]$",
)
password: str = Field(min_length=8, max_length=128)
class AccountRead(BaseModel):
id: UUID
account_type: str
email: EmailStr
first_name: str
last_name: str
date_of_birth: date
phone_number: str
company_domain: str | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class LoginRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=1, max_length=128)
class AuthResponse(BaseModel):
access_token: str
token_type: Literal["bearer"] = "bearer"
account: AccountRead
class EmailCreate(BaseModel):
recipients: list[EmailStr] = Field(min_length=1)
cc: list[EmailStr] = Field(default_factory=list)
bcc: list[EmailStr] = Field(default_factory=list)
subject: str = Field(default="", max_length=998, pattern=r"^[^\r\n]*$")
body_text: str = ""
body_html: str | None = None
class EmailRead(EmailCreate):
id: str
sender: EmailStr
status: str
created_at: datetime
class MailAddress(BaseModel):
name: str
address: str
class AttachmentRead(BaseModel):
filename: str
content_type: str
size: int
content_id: str | None = None
disposition: str = "attachment"
class MessageSummary(BaseModel):
id: str
mailbox: Literal["inbox", "sent", "trash"]
direction: Literal["inbound", "outbound"]
sender: MailAddress
recipients: list[MailAddress]
subject: str
snippet: str
status: str
created_at: datetime
is_read: bool
is_starred: bool = False
trashed_at: datetime | None = None
attachments: list[AttachmentRead] = Field(default_factory=list)
class MessageRead(MessageSummary):
cc: list[MailAddress] = Field(default_factory=list)
bcc: list[MailAddress] = Field(default_factory=list)
reply_to: list[MailAddress] = Field(default_factory=list)
body_text: str = ""
body_html: str | None = None
message_id: str | None = None
class MessageReadState(BaseModel):
is_read: bool
class MessageStarState(BaseModel):
is_starred: bool
class StickyNoteCreate(BaseModel):
title: str = Field(min_length=1, max_length=120)
body: str = Field(default="", max_length=2000)
tags: list[str] = Field(default_factory=list, max_length=5)
color: Literal["yellow", "green", "blue", "pink"] = "yellow"
class StickyNoteUpdate(BaseModel):
title: str | None = Field(default=None, min_length=1, max_length=120)
body: str | None = Field(default=None, max_length=2000)
tags: list[str] | None = Field(default=None, max_length=5)
color: Literal["yellow", "green", "blue", "pink"] | None = None
class StickyNoteRead(StickyNoteCreate):
id: str
created_at: datetime
updated_at: datetime
class MessagePage(BaseModel):
items: list[MessageSummary]
next_cursor: str | None = None
class DomainCreate(BaseModel):
domain_name: str = Field(min_length=3, max_length=253)
account_id: UUID | None = None
class DnsRecordRead(BaseModel):
id: UUID
record_type: str
host: str
value: str
purpose: str
ttl: int
model_config = ConfigDict(from_attributes=True)
class DnsVerificationCheck(BaseModel):
purpose: str
verified: bool
message: str
class DnsVerificationResponse(BaseModel):
domain: str
status: str
verified: bool
checks: list[DnsVerificationCheck]
class MailDomainRead(BaseModel):
id: UUID
domain_name: str
domain_type: str
account_id: UUID | None
mail_host: str
dkim_selector: str
status: str
created_at: datetime
dns_records: list[DnsRecordRead] = []
model_config = ConfigDict(from_attributes=True)
class SmtpCredentialCreate(BaseModel):
label: str = Field(default="default", min_length=1, max_length=120)
class SmtpCredentialRead(BaseModel):
id: UUID
username: str
label: str
is_active: bool
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class SmtpCredentialCreated(SmtpCredentialRead):
password: str
smtp_host: str
smtp_port: int
use_tls: bool
class DnsSetupRequest(BaseModel):
domain: str = Field(min_length=3, max_length=253)
mail_server_ip: str = Field(min_length=3, max_length=45)
class DnsSetupRecord(BaseModel):
record_type: str
host: str
value: str
purpose: str
ttl: int
priority: int | None = None
class DnsSetupResponse(BaseModel):
domain: str
mail_host: str
app_public_host: str
mail_server_ip: str
is_platform_domain: bool
sections: dict[str, list[DnsSetupRecord]]
notes: list[str] = Field(default_factory=list)
def utc_now() -> datetime:
return datetime.now(timezone.utc)

View file

@ -0,0 +1 @@
"""Backend service modules."""

View file

@ -0,0 +1,176 @@
"""Live DNS checks for company domain onboarding."""
from __future__ import annotations
import dns.exception
import dns.resolver
def _normalize_txt(value: str) -> str:
return " ".join(value.split()).strip().lower()
def _lookup_txt(name: str) -> list[str]:
try:
answers = dns.resolver.resolve(name, "TXT")
except (
dns.resolver.NXDOMAIN,
dns.resolver.NoAnswer,
dns.resolver.NoNameservers,
dns.exception.Timeout,
dns.resolver.LifetimeTimeout,
):
return []
records: list[str] = []
for answer in answers:
parts = []
for chunk in answer.strings:
if isinstance(chunk, bytes):
parts.append(chunk.decode("utf-8", errors="replace"))
else:
parts.append(str(chunk))
records.append("".join(parts))
return records
def _lookup_mx(domain_name: str) -> list[tuple[int, str]]:
try:
answers = dns.resolver.resolve(domain_name, "MX")
except (
dns.resolver.NXDOMAIN,
dns.resolver.NoAnswer,
dns.resolver.NoNameservers,
dns.exception.Timeout,
dns.resolver.LifetimeTimeout,
):
return []
hosts: list[tuple[int, str]] = []
for answer in answers:
host = str(answer.exchange).rstrip(".").lower()
hosts.append((int(answer.preference), host))
return hosts
def verify_mx(domain_name: str, expected_host: str) -> tuple[bool, str]:
expected = expected_host.rstrip(".").lower()
hosts = _lookup_mx(domain_name)
if not hosts:
return False, f"No MX records found for {domain_name}."
hostnames = [host for _, host in hosts]
if expected in hostnames:
return True, f"MX points to {expected_host}."
return False, f"Expected MX host {expected_host}; found {', '.join(hostnames)}."
def verify_spf(domain_name: str, expected_include: str) -> tuple[bool, str]:
include_token = expected_include.rstrip(".").lower()
records = [_normalize_txt(record) for record in _lookup_txt(domain_name)]
spf_records = [record for record in records if record.startswith("v=spf1")]
if not spf_records:
return False, f"No SPF TXT record found at {domain_name}."
needle = f"include:{include_token}"
for record in spf_records:
if needle in record:
return True, f"SPF includes {expected_include}."
return False, f"SPF record must include {expected_include}."
def _lookup_a(name: str) -> list[str]:
try:
answers = dns.resolver.resolve(name, "A")
except (
dns.resolver.NXDOMAIN,
dns.resolver.NoAnswer,
dns.resolver.NoNameservers,
dns.exception.Timeout,
dns.resolver.LifetimeTimeout,
):
return []
return [str(answer).strip() for answer in answers]
def verify_platform_spf(platform_spf_host: str, mail_host: str) -> tuple[bool, str]:
"""Ensure the shared SPF include target exists and authorizes the mail host."""
records = [_normalize_txt(record) for record in _lookup_txt(platform_spf_host)]
spf_records = [record for record in records if record.startswith("v=spf1")]
if not spf_records:
return (
False,
f"No SPF TXT at {platform_spf_host}. On getall.help add Host _spf with "
f"v=spf1 ip4:<mail-server-ip> ~all.",
)
spf_value = spf_records[0]
mail_ips = _lookup_a(mail_host.rstrip("."))
for ip in mail_ips:
if f"ip4:{ip}" in spf_value or f"ip6:{ip}" in spf_value:
return True, f"{platform_spf_host} authorizes {mail_host} ({ip})."
if "ip4:" in spf_value or "ip6:" in spf_value or " a " in f" {spf_value} ":
return True, f"{platform_spf_host} SPF record is present."
return (
False,
f"{platform_spf_host} exists but does not appear to authorize the mail server.",
)
def verify_dkim(
domain_name: str,
selector: str,
expected_public_key: str,
) -> tuple[bool, str]:
name = f"{selector}._domainkey.{domain_name}"
records = [_normalize_txt(record) for record in _lookup_txt(name)]
if not records:
return False, f"No DKIM TXT record found at {name}."
key_token = expected_public_key.strip().lower()
for record in records:
if key_token in record.replace(" ", ""):
return True, "DKIM public key matches."
return False, f"DKIM TXT at {selector}._domainkey is missing the expected public key."
def verify_dmarc(domain_name: str, expected_value: str) -> tuple[bool, str]:
name = f"_dmarc.{domain_name}"
records = [_normalize_txt(record) for record in _lookup_txt(name)]
if not records:
return False, f"No DMARC TXT record found at {name}."
expected = _normalize_txt(expected_value)
for record in records:
if record == expected or record.startswith("v=dmarc1"):
if "v=dmarc1" in record:
return True, "DMARC record is present."
return False, "DMARC TXT record is missing or incomplete."
def verify_company_dns(
*,
domain_name: str,
shared_mail_host: str,
dkim_selector: str,
dkim_public_key: str,
platform_spf_include: str,
) -> list[dict]:
dmarc_expected = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
checks = [
("mx", *verify_mx(domain_name, shared_mail_host)),
("spf", *verify_spf(domain_name, platform_spf_include)),
(
"platform_spf",
*verify_platform_spf(platform_spf_include, shared_mail_host),
),
(
"dkim",
*verify_dkim(domain_name, dkim_selector, dkim_public_key),
),
("dmarc", *verify_dmarc(domain_name, dmarc_expected)),
]
return [
{
"purpose": purpose,
"verified": verified,
"message": message,
}
for purpose, verified, message in checks
]

View file

@ -0,0 +1,380 @@
import ipaddress
import re
import secrets
import string
from pathlib import Path
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from pwdlib import PasswordHash
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.models import DomainDnsRecord, MailDomain, SmtpCredential
password_hasher = PasswordHash.recommended()
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_domain(value: str) -> str:
return value.strip().lower().removeprefix("@")
def validate_domain(value: str) -> str:
domain = normalize_domain(value)
if not domain_pattern.fullmatch(domain):
raise ValueError("Enter a valid domain name, such as example.com.")
return domain
def generate_dkim_keys() -> tuple[str, str]:
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
).decode()
public_pem = (
private_key.public_key()
.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
.decode()
)
public_key = "".join(
line
for line in public_pem.splitlines()
if not line.startswith("-----")
)
return private_pem, public_key
def namecheap_host(host: str, domain_name: str) -> str:
"""Strip the zone suffix so records match Namecheap's Host field."""
if host in {"@", "*"}:
return host
suffix = f".{domain_name}"
if host.endswith(suffix):
return host[: -len(suffix)]
return host
def spf_ip_mechanism(mail_server_ip: str | None) -> str | None:
if not mail_server_ip or mail_server_ip == "YOUR_MAIL_SERVER_IP":
return None
try:
address = ipaddress.ip_address(mail_server_ip.strip())
except ValueError:
return None
return f"ip6:{address}" if address.version == 6 else f"ip4:{address}"
def build_spf_value(mail_server_ip: str | None = None) -> str:
parts = ["v=spf1", "mx", "a"]
ip_mechanism = spf_ip_mechanism(mail_server_ip)
if ip_mechanism:
parts.append(ip_mechanism)
parts.append("~all")
return " ".join(parts)
def build_shared_mx_spf_value(platform_spf_include: str) -> str:
return f"v=spf1 include:{platform_spf_include} ~all"
def build_company_dns_records(
domain_name: str,
shared_mail_host: str,
dkim_selector: str,
dkim_public_key: str,
platform_spf_include: str,
) -> list[dict]:
"""Zoho-style records: shared MX host, SPF include, per-domain DKIM/DMARC."""
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
return [
{
"record_type": "MX",
"host": namecheap_host("@", domain_name),
"value": shared_mail_host,
"priority": 10,
"purpose": "mx",
"ttl": 3600,
},
{
"record_type": "TXT",
"host": namecheap_host("@", domain_name),
"value": build_shared_mx_spf_value(platform_spf_include),
"purpose": "spf",
"ttl": 3600,
},
{
"record_type": "TXT",
"host": namecheap_host(f"{dkim_selector}._domainkey", domain_name),
"value": dkim_value,
"purpose": "dkim",
"ttl": 3600,
},
{
"record_type": "TXT",
"host": namecheap_host("_dmarc", domain_name),
"value": dmarc_value,
"purpose": "dmarc",
"ttl": 3600,
},
]
def build_dns_records(
domain_name: str,
mail_host: str,
dkim_selector: str,
dkim_public_key: str,
mail_server_ip: str | None = None,
) -> list[dict]:
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
mail_subdomain = mail_host.removesuffix(f".{domain_name}")
return [
{
"record_type": "MX",
"host": namecheap_host("@", domain_name),
"value": mail_host,
"priority": 10,
"purpose": "mx",
"ttl": 3600,
},
{
"record_type": "A",
"host": namecheap_host(mail_subdomain, domain_name),
"value": mail_server_ip or "YOUR_MAIL_SERVER_IP",
"purpose": "mail_host",
"ttl": 3600,
},
{
"record_type": "TXT",
"host": namecheap_host("@", domain_name),
"value": build_spf_value(mail_server_ip),
"purpose": "spf",
"ttl": 3600,
},
{
"record_type": "TXT",
"host": namecheap_host(f"{dkim_selector}._domainkey", domain_name),
"value": dkim_value,
"purpose": "dkim",
"ttl": 3600,
},
{
"record_type": "TXT",
"host": namecheap_host("_dmarc", domain_name),
"value": dmarc_value,
"purpose": "dmarc",
"ttl": 3600,
},
]
def build_deployment_dns_records(
*,
domain_name: str,
mail_server_ip: str,
dkim_selector: str,
dkim_public_key: str | None,
app_public_host: str | None = None,
) -> dict[str, list[dict]]:
"""Split records into inbound delivery vs outbound authentication."""
mail_host = f"mail.{domain_name}"
dkim_key = dkim_public_key or (
"REGISTER_DOMAIN_TO_GENERATE_DKIM_PUBLIC_KEY"
)
records = build_dns_records(
domain_name,
mail_host,
dkim_selector,
dkim_key,
mail_server_ip=mail_server_ip,
)
by_purpose = {record["purpose"]: record for record in records}
inbound = [by_purpose["mx"], by_purpose["mail_host"]]
if app_public_host:
inbound.append(
{
"record_type": "A",
"host": namecheap_host(app_public_host, domain_name),
"value": mail_server_ip,
"purpose": "app_host",
"ttl": 3600,
}
)
outbound = [by_purpose["spf"], by_purpose["dkim"], by_purpose["dmarc"]]
ip_mechanism = spf_ip_mechanism(mail_server_ip)
if ip_mechanism:
outbound.insert(
0,
{
"record_type": "TXT",
"host": namecheap_host("_spf", domain_name),
"value": f"v=spf1 {ip_mechanism} ~all",
"purpose": "platform_spf",
"ttl": 3600,
},
)
return {"inbound": inbound, "outbound": outbound}
def generate_smtp_username(domain_name: str) -> str:
slug = domain_name.replace(".", "-")
token = secrets.token_hex(4)
return f"smtp-{slug}-{token}"
def generate_smtp_password(length: int = 24) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def record_for_db(record: dict) -> dict:
allowed = {"record_type", "host", "value", "purpose", "ttl"}
return {key: record[key] for key in allowed if key in record}
async def get_domain_by_name(
session: AsyncSession,
domain_name: str,
) -> MailDomain | None:
return await session.scalar(
select(MailDomain).where(MailDomain.domain_name == domain_name)
)
async def provision_mail_domain(
session: AsyncSession,
*,
domain_name: str,
domain_type: str,
account_id=None,
) -> MailDomain:
settings = get_settings()
normalized = validate_domain(domain_name)
existing = await get_domain_by_name(session, normalized)
if existing:
return existing
mail_host = f"mail.{normalized}"
dkim_selector = settings.dkim_selector
private_key, public_key = generate_dkim_keys()
if domain_type == "company":
mail_host = settings.smtp_public_host
dns_records = build_company_dns_records(
normalized,
settings.smtp_public_host,
dkim_selector,
public_key,
settings.platform_spf_include,
)
else:
dns_records = build_dns_records(
normalized,
mail_host,
dkim_selector,
public_key,
)
domain = MailDomain(
domain_name=normalized,
domain_type=domain_type,
account_id=account_id,
mail_host=mail_host,
dkim_selector=dkim_selector,
dkim_private_key=private_key,
dkim_public_key=public_key,
status="pending_dns",
)
session.add(domain)
await session.flush()
for record in dns_records:
session.add(DomainDnsRecord(domain_id=domain.id, **record_for_db(record)))
await session.flush()
return domain
def register_relay_domain(domain_name: str) -> None:
"""Append a verified company domain for Postfix inbound relay."""
settings = get_settings()
normalized = validate_domain(domain_name)
path = Path(settings.relay_domains_file)
path.parent.mkdir(parents=True, exist_ok=True)
existing = {
line.strip().lower()
for line in path.read_text(encoding="utf-8").splitlines()
if line.strip()
} if path.exists() else set()
if normalized in existing:
return
with path.open("a", encoding="utf-8") as handle:
handle.write(f"{normalized}\n")
async def activate_company_domain(session: AsyncSession, domain: MailDomain) -> MailDomain:
domain.status = "active"
register_relay_domain(domain.domain_name)
await session.flush()
return domain
async def get_account_company_domain(
session: AsyncSession,
account_id,
company_domain_name: str | None,
) -> MailDomain | None:
if not company_domain_name:
return None
return await session.scalar(
select(MailDomain)
.options(selectinload(MailDomain.dns_records))
.where(
MailDomain.domain_name == company_domain_name,
MailDomain.account_id == account_id,
)
)
async def create_smtp_credential(
session: AsyncSession,
domain: MailDomain,
*,
label: str,
) -> tuple[SmtpCredential, str]:
username = generate_smtp_username(domain.domain_name)
plain_password = generate_smtp_password()
credential = SmtpCredential(
domain_id=domain.id,
username=username,
password_hash=password_hasher.hash(plain_password),
label=label,
is_active=True,
)
session.add(credential)
await session.flush()
return credential, plain_password
async def ensure_platform_domain(session: AsyncSession) -> MailDomain:
settings = get_settings()
domain = await get_domain_by_name(session, settings.platform_domain)
if domain:
return domain
return await provision_mail_domain(
session,
domain_name=settings.platform_domain,
domain_type="platform",
)

View file

@ -0,0 +1,159 @@
import asyncio
import ipaddress
import logging
from datetime import datetime, timezone
from aiosmtpd.smtp import SMTP
from sqlalchemy import func, select
from app.config import get_settings
from app.database import SessionFactory, mongo_db
from app.models import Account
from app.services.mime_parser import parse_mime
from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage
logger = logging.getLogger(__name__)
def peer_is_allowed(session) -> bool:
if session is None or not getattr(session, "peer", None):
return True
try:
peer = ipaddress.ip_address(session.peer[0])
return any(
peer in ipaddress.ip_network(cidr)
for cidr in get_settings().inbound_allowed_cidrs
)
except ValueError:
return False
class InboundHandler:
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
if not peer_is_allowed(session):
logger.warning("Rejected inbound SMTP peer %s", session.peer[0])
return "550 5.7.1 Delivery from this host is not authorized"
normalized = address.strip().lower()
async with SessionFactory() as database_session:
account = await database_session.scalar(
select(Account).where(func.lower(Account.email) == normalized)
)
if account is None:
return "550 5.1.1 Recipient address rejected: user unknown"
envelope.rcpt_tos.append(normalized)
envelope.rcpt_options.extend(rcpt_options)
if not hasattr(envelope, "account_ids"):
envelope.account_ids = {}
envelope.account_ids[normalized] = str(account.id)
return "250 2.1.5 Recipient OK"
async def handle_DATA(self, server, session, envelope):
settings = get_settings()
raw = envelope.original_content
if len(raw) > settings.inbound_max_bytes:
return "552 5.3.4 Message size exceeds fixed maximum message size"
recipients = list(dict.fromkeys(address.lower() for address in envelope.rcpt_tos))
if not recipients:
return "554 5.5.1 No valid recipients"
delivery_key = delivery_digest(raw, recipients)
try:
tombstones = getattr(mongo_db, "message_tombstones", None)
if tombstones is not None and await tombstones.find_one(
{"delivery_key": delivery_key},
{"_id": 1},
):
return f"250 2.0.0 Message previously accepted as {delivery_key}"
parsed = parse_mime(raw)
body_text = parsed.pop("body_text")
body_html = parsed.pop("body_html")
stored = await get_raw_mime_storage().put(raw, delivery_key)
now = datetime.now(timezone.utc)
message = {
**parsed,
"direction": "inbound",
"envelope_sender": envelope.mail_from.lower(),
"envelope_recipients": recipients,
"delivery_key": delivery_key,
"raw_mime": {
"bucket": stored.bucket,
"name": stored.name,
"generation": stored.generation,
"size": stored.size,
"crc32c": stored.crc32c,
},
"received_at": now,
"created_at": now,
"has_text_body": bool(body_text),
"has_html_body": bool(body_html),
}
await mongo_db.messages.update_one(
{"delivery_key": delivery_key},
{"$setOnInsert": message},
upsert=True,
)
persisted = await mongo_db.messages.find_one(
{"delivery_key": delivery_key},
{"_id": 1},
)
if persisted is None:
raise RuntimeError("Inbound message was not persisted.")
for recipient in recipients:
account_id = envelope.account_ids[recipient]
await mongo_db.mailbox_entries.update_one(
{
"account_id": account_id,
"message_id": persisted["_id"],
"mailbox": "inbox",
},
{
"$setOnInsert": {
"account_id": account_id,
"message_id": persisted["_id"],
"mailbox": "inbox",
"direction": "inbound",
"sender": parsed["sender"],
"recipients": parsed["to"],
"subject": parsed["subject"],
"snippet": parsed["snippet"],
"attachments": parsed["attachments"],
"status": "received",
"is_read": False,
"is_starred": False,
"event_at": now,
"created_at": now,
"trashed_at": None,
"deletion_state": None,
}
},
upsert=True,
)
except Exception:
logger.exception("Temporary failure accepting inbound message")
return "451 4.3.0 Temporary server failure; please retry"
return f"250 2.0.0 Message accepted as {delivery_key}"
class InboundSMTPServer:
def __init__(self):
self.server: asyncio.Server | None = None
async def start(self) -> None:
settings = get_settings()
loop = asyncio.get_running_loop()
self.server = await loop.create_server(
lambda: SMTP(
InboundHandler(),
data_size_limit=settings.inbound_max_bytes,
enable_SMTPUTF8=True,
decode_data=False,
),
host=settings.inbound_smtp_host,
port=settings.inbound_smtp_port,
)
async def stop(self) -> None:
if self.server is not None:
self.server.close()
await self.server.wait_closed()
self.server = None

View file

@ -0,0 +1,107 @@
import email.policy
import email.utils
from email.message import EmailMessage
import aiosmtplib
import dkim
from app.config import get_settings
from app.models import MailDomain
SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998)
def build_message(
*,
sender: str,
recipients: list[str],
cc: list[str],
subject: str,
body_text: str,
body_html: str | None,
) -> EmailMessage:
message = EmailMessage(policy=SMTP_POLICY)
message["From"] = sender
message["To"] = ", ".join(recipients)
if cc:
message["Cc"] = ", ".join(cc)
message["Subject"] = subject
message["Date"] = email.utils.formatdate(localtime=True)
message["Message-ID"] = email.utils.make_msgid(domain=sender.split("@")[-1])
message.set_content(body_text)
if body_html:
message.add_alternative(body_html, subtype="html")
return message
def sign_message(message: EmailMessage, domain: MailDomain) -> bytes:
raw_message = message.as_bytes(policy=SMTP_POLICY)
signed_headers = [
b"from",
b"to",
b"subject",
b"date",
b"message-id",
b"mime-version",
b"content-type",
]
if message.get("Cc"):
signed_headers.insert(3, b"cc")
signature = dkim.sign(
raw_message,
domain.dkim_selector.encode(),
domain.domain_name.encode(),
domain.dkim_private_key.encode(),
include_headers=signed_headers,
canonicalize=(b"relaxed", b"relaxed"),
)
if not signature.endswith(b"\r\n"):
signature += b"\r\n"
return signature + raw_message
async def send_via_smtp(
message_bytes: bytes,
*,
sender: str,
recipients: list[str],
) -> None:
settings = get_settings()
await aiosmtplib.send(
message_bytes,
sender=sender,
recipients=recipients,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_username or None,
password=settings.smtp_password or None,
start_tls=settings.smtp_use_tls,
timeout=settings.smtp_timeout,
)
async def deliver_email(
*,
sender: str,
recipients: list[str],
cc: list[str],
bcc: list[str],
subject: str,
body_text: str,
body_html: str | None,
domain: MailDomain,
) -> None:
message = build_message(
sender=sender,
recipients=recipients,
cc=cc,
subject=subject,
body_text=body_text,
body_html=body_html,
)
signed = sign_message(message, domain)
await send_via_smtp(
signed,
sender=sender,
recipients=[*recipients, *cc, *bcc],
)

View file

@ -0,0 +1,90 @@
import re
from datetime import timezone
from email import policy
from email.parser import BytesParser
from email.utils import getaddresses, parsedate_to_datetime
from html import unescape
def _addresses(values: list[str]) -> list[dict[str, str]]:
return [
{
"name": name.strip() or address.split("@", 1)[0],
"address": address.lower(),
}
for name, address in getaddresses(values)
if address
]
def _text(part) -> str:
try:
value = part.get_content()
except (LookupError, UnicodeError):
payload = part.get_payload(decode=True) or b""
value = payload.decode("utf-8", errors="replace")
return value if isinstance(value, str) else ""
def _snippet(text: str, html: str | None, limit: int = 240) -> str:
source = text
if not source and html:
source = re.sub(r"<[^>]+>", " ", html)
source = unescape(source)
return re.sub(r"\s+", " ", source).strip()[:limit]
def parse_mime(raw: bytes) -> dict:
message = BytesParser(policy=policy.default).parsebytes(raw)
sender = _addresses(message.get_all("from", []))
from_value = sender[0] if sender else {"name": "Unknown sender", "address": ""}
text_parts: list[str] = []
html_parts: list[str] = []
attachments: list[dict] = []
for part in message.walk():
if part.is_multipart():
continue
disposition = part.get_content_disposition()
filename = part.get_filename()
content_type = part.get_content_type()
payload = part.get_payload(decode=True) or b""
if disposition == "attachment" or filename:
attachments.append(
{
"filename": filename or "attachment",
"content_type": content_type,
"size": len(payload),
"content_id": (part.get("Content-ID") or "").strip("<>") or None,
"disposition": disposition or "attachment",
}
)
elif content_type == "text/plain":
text_parts.append(_text(part))
elif content_type == "text/html":
html_parts.append(_text(part))
date = None
if message.get("Date"):
try:
date = parsedate_to_datetime(message["Date"])
if date.tzinfo is None:
date = date.replace(tzinfo=timezone.utc)
date = date.astimezone(timezone.utc)
except (TypeError, ValueError, OverflowError):
pass
body_text = "\n".join(text_parts).strip()
body_html = "\n".join(html_parts).strip() or None
return {
"message_id": (message.get("Message-ID") or "").strip() or None,
"sender": from_value,
"to": _addresses(message.get_all("to", [])),
"cc": _addresses(message.get_all("cc", [])),
"reply_to": _addresses(message.get_all("reply-to", [])),
"subject": str(message.get("Subject") or ""),
"sent_at": date,
"body_text": body_text,
"body_html": body_html,
"snippet": _snippet(body_text, body_html),
"attachments": attachments,
}

View file

@ -0,0 +1,101 @@
import asyncio
import hashlib
from dataclasses import dataclass
import google_crc32c
from google.api_core.exceptions import NotFound, PreconditionFailed
from google.cloud import storage
from app.config import get_settings
@dataclass(frozen=True)
class StoredMime:
bucket: str
name: str
generation: int
size: int
crc32c: str
class RawMimeStorage:
def __init__(self, bucket: str, prefix: str = "raw", project: str | None = None):
self.bucket_name = bucket
self.prefix = prefix.strip("/")
self.client = storage.Client(project=project)
async def put(self, raw: bytes, delivery_key: str) -> StoredMime:
return await asyncio.to_thread(self._put, raw, delivery_key)
def _put(self, raw: bytes, delivery_key: str) -> StoredMime:
name = f"{self.prefix}/{delivery_key[:2]}/{delivery_key}.eml"
blob = self.client.bucket(self.bucket_name).blob(name)
checksum = google_crc32c.Checksum(raw).hexdigest().decode("ascii")
try:
blob.upload_from_string(
raw,
content_type="message/rfc822",
checksum="crc32c",
if_generation_match=0,
)
blob.reload()
except PreconditionFailed:
blob.reload()
return StoredMime(
bucket=self.bucket_name,
name=name,
generation=int(blob.generation),
size=len(raw),
crc32c=checksum,
)
async def get(self, reference: dict) -> bytes:
return await asyncio.to_thread(self._get, reference)
def _get(self, reference: dict) -> bytes:
generation = int(reference["generation"])
blob = self.client.bucket(reference["bucket"]).blob(
reference["name"],
generation=generation,
)
return blob.download_as_bytes(if_generation_match=generation)
async def delete(self, reference: dict) -> None:
await asyncio.to_thread(self._delete, reference)
def _delete(self, reference: dict) -> None:
generation = int(reference["generation"])
blob = self.client.bucket(reference["bucket"]).blob(
reference["name"],
generation=generation,
)
try:
blob.delete(if_generation_match=generation)
except NotFound:
# A prior delete may have removed GCS before Mongo acknowledged it.
pass
def delivery_digest(raw: bytes, recipients: list[str]) -> str:
digest = hashlib.sha256()
digest.update(raw)
digest.update(b"\0")
digest.update("\0".join(sorted(set(recipients))).encode())
return digest.hexdigest()
_storage: RawMimeStorage | None = None
def get_raw_mime_storage() -> RawMimeStorage:
global _storage
if _storage is None:
settings = get_settings()
if not settings.gcs_raw_mime_bucket:
raise RuntimeError("GCS_RAW_MIME_BUCKET is required for inbound mail.")
_storage = RawMimeStorage(
settings.gcs_raw_mime_bucket,
settings.gcs_raw_mime_prefix,
settings.gcs_project,
)
return _storage

19
backend/requirements.txt Normal file
View file

@ -0,0 +1,19 @@
fastapi>=0.116
uvicorn[standard]>=0.35
sqlalchemy[asyncio]>=2.0
asyncpg>=0.30
pymongo>=4.13
pydantic-settings>=2.10
email-validator>=2.2
# Argon2id password hashing; intentionally avoids bcrypt backend/version coupling.
pwdlib[argon2]>=0.3,<1.0
pyjwt>=2.10
aiosmtplib>=4.0
dkimpy>=1.1
cryptography>=44.0
aiosmtpd>=1.4.6
google-cloud-storage>=3.0
google-crc32c>=1.7
dnspython>=2.7
pytest>=8.0
pytest-asyncio>=0.25

View file

@ -0,0 +1,87 @@
"""Tests for company shared-MX DNS onboarding."""
from unittest.mock import patch
from app.services.dns_verification import verify_company_dns
from app.services.domain_provisioning import (
build_company_dns_records,
build_shared_mx_spf_value,
register_relay_domain,
)
def test_build_company_dns_records_use_shared_mx_and_spf_include():
records = build_company_dns_records(
"acme.com",
"mail.getall.help",
"agmail",
"abc123public",
"_spf.getall.help",
)
purposes = [record["purpose"] for record in records]
assert purposes == ["mx", "spf", "dkim", "dmarc"]
mx = next(record for record in records if record["purpose"] == "mx")
spf = next(record for record in records if record["purpose"] == "spf")
dkim = next(record for record in records if record["purpose"] == "dkim")
assert mx["host"] == "@"
assert mx["value"] == "mail.getall.help"
assert mx["priority"] == 10
assert spf["value"] == "v=spf1 include:_spf.getall.help ~all"
assert dkim["host"] == "agmail._domainkey"
assert "abc123public" in dkim["value"]
assert not any(record.get("purpose") == "mail_host" for record in records)
def test_build_shared_mx_spf_value():
assert build_shared_mx_spf_value("_spf.getall.help") == (
"v=spf1 include:_spf.getall.help ~all"
)
def test_register_relay_domain_appends_once(tmp_path, monkeypatch):
relay_file = tmp_path / "domains.list"
monkeypatch.setenv("RELAY_DOMAINS_FILE", str(relay_file))
from app.config import get_settings
get_settings.cache_clear()
register_relay_domain("Acme.COM")
register_relay_domain("acme.com")
assert relay_file.read_text(encoding="utf-8") == "acme.com\n"
get_settings.cache_clear()
@patch("app.services.dns_verification._lookup_a")
@patch("app.services.dns_verification._lookup_mx")
@patch("app.services.dns_verification._lookup_txt")
def test_verify_company_dns_all_pass(mock_txt, mock_mx, mock_a):
mock_mx.return_value = [(10, "mail.getall.help")]
mock_a.return_value = ["38.180.106.242"]
mock_txt.side_effect = lambda name: {
"acme.com": ['v=spf1 include:_spf.getall.help ~all'],
"_spf.getall.help": ["v=spf1 ip4:38.180.106.242 ~all"],
"agmail._domainkey.acme.com": ["v=DKIM1; k=rsa; p=abc123"],
"_dmarc.acme.com": ["v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"],
}.get(name, [])
checks = verify_company_dns(
domain_name="acme.com",
shared_mail_host="mail.getall.help",
dkim_selector="agmail",
dkim_public_key="abc123",
platform_spf_include="_spf.getall.help",
)
assert all(check["verified"] for check in checks)
assert [check["purpose"] for check in checks] == [
"mx",
"spf",
"platform_spf",
"dkim",
"dmarc",
]

View file

@ -0,0 +1,76 @@
"""Tests for deployment DNS record generation."""
from app.services.domain_provisioning import (
build_deployment_dns_records,
build_dns_records,
build_spf_value,
namecheap_host,
)
def test_namecheap_host_strips_zone_suffix():
assert namecheap_host("mail.getall.help", "getall.help") == "mail"
assert namecheap_host("@", "getall.help") == "@"
assert (
namecheap_host("agmail._domainkey.getall.help", "getall.help")
== "agmail._domainkey"
)
def test_build_dns_records_uses_namecheap_hosts_and_ip():
records = build_dns_records(
"getall.help",
"mail.getall.help",
"agmail",
"abc123",
mail_server_ip="203.0.113.10",
)
mx = next(record for record in records if record["purpose"] == "mx")
a_record = next(record for record in records if record["purpose"] == "mail_host")
spf = next(record for record in records if record["purpose"] == "spf")
assert mx["host"] == "@"
assert mx["value"] == "mail.getall.help"
assert mx["priority"] == 10
assert a_record["host"] == "mail"
assert a_record["value"] == "203.0.113.10"
assert spf["value"] == "v=spf1 mx a ip4:203.0.113.10 ~all"
assert "amazonses" not in spf["value"]
def test_build_spf_value_supports_ipv6():
assert build_spf_value("2001:db8::1") == "v=spf1 mx a ip6:2001:db8::1 ~all"
def test_deployment_sections_split_inbound_and_outbound():
sections = build_deployment_dns_records(
domain_name="getall.help",
mail_server_ip="203.0.113.10",
dkim_selector="agmail",
dkim_public_key="test-public-key",
app_public_host="email.getall.help",
)
inbound_purposes = [record["purpose"] for record in sections["inbound"]]
outbound_purposes = [record["purpose"] for record in sections["outbound"]]
assert inbound_purposes == ["mx", "mail_host", "app_host"]
assert outbound_purposes == ["platform_spf", "spf", "dkim", "dmarc"]
assert "ses_outbound" not in sections
app_host = next(
record for record in sections["inbound"] if record["purpose"] == "app_host"
)
assert app_host["host"] == "email"
assert app_host["value"] == "203.0.113.10"
dmarc = next(
record for record in sections["outbound"] if record["purpose"] == "dmarc"
)
assert dmarc["value"] == "v=DMARC1; p=quarantine; rua=mailto:dmarc@getall.help"
dkim = next(
record for record in sections["outbound"] if record["purpose"] == "dkim"
)
assert dkim["host"] == "agmail._domainkey"
assert "test-public-key" in dkim["value"]

View file

@ -0,0 +1,49 @@
from datetime import datetime, timezone
from bson import ObjectId
from app.routers.emails import decode_cursor, encode_cursor, summarize
def test_cursor_round_trip_preserves_sort_key():
entry = {
"_id": ObjectId(),
"created_at": datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc),
}
created_at, entry_id = decode_cursor(encode_cursor(entry))
assert created_at == entry["created_at"]
assert entry_id == entry["_id"]
def test_summary_uses_account_mailbox_read_state():
now = datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc)
message_id = ObjectId()
message = {
"_id": message_id,
"direction": "inbound",
"sender": {"name": "Sender", "address": "sender@example.com"},
"to": [{"name": "user", "address": "user@getall.help"}],
"subject": "Hello",
"snippet": "A short preview",
"status": "received",
"created_at": now,
"received_at": now,
"attachments": [],
}
entry = {
"_id": ObjectId(),
"message_id": message_id,
"mailbox": "inbox",
"is_read": False,
"trashed_at": None,
"created_at": now,
}
result = summarize(message, entry)
assert result.id == str(message_id)
assert result.is_read is False
assert result.mailbox == "inbox"
assert result.snippet == "A short preview"

View file

@ -0,0 +1,143 @@
from types import SimpleNamespace
import pytest
from bson import ObjectId
from app.services import inbound_smtp
from app.services.raw_mime_storage import StoredMime
RAW = (
b"From: Sender <sender@example.com>\r\n"
b"To: user@getall.help\r\n"
b"Subject: Hello\r\n\r\n"
b"Retry safe body\r\n"
)
class FakeMessages:
def __init__(self):
self.id = ObjectId()
self.updates = 0
async def update_one(self, *args, **kwargs):
self.updates += 1
async def find_one(self, *args, **kwargs):
return {"_id": self.id}
class FakeEntries:
def __init__(self):
self.updates = 0
async def update_one(self, *args, **kwargs):
self.updates += 1
class FakeStorage:
async def put(self, raw, delivery_key):
return StoredMime("mail", f"raw/{delivery_key}.eml", 7, len(raw), "crc")
class FakeSession:
def __init__(self, account):
self.account = account
async def __aenter__(self):
return self
async def __aexit__(self, *args):
pass
async def scalar(self, query):
return self.account
def envelope(raw=RAW):
return SimpleNamespace(
original_content=raw,
rcpt_tos=["user@getall.help"],
mail_from="sender@example.com",
account_ids={"user@getall.help": "account-1"},
)
@pytest.mark.asyncio
async def test_rcpt_rejects_unknown_account(monkeypatch):
monkeypatch.setattr(inbound_smtp, "SessionFactory", lambda: FakeSession(None))
target = SimpleNamespace(rcpt_tos=[], rcpt_options=[])
response = await inbound_smtp.InboundHandler().handle_RCPT(
None,
None,
target,
"missing@getall.help",
[],
)
assert response.startswith("550 5.1.1")
assert target.rcpt_tos == []
@pytest.mark.asyncio
async def test_rcpt_accepts_account_and_tracks_owner(monkeypatch):
account = SimpleNamespace(id="account-1")
monkeypatch.setattr(inbound_smtp, "SessionFactory", lambda: FakeSession(account))
target = SimpleNamespace(rcpt_tos=[], rcpt_options=[])
response = await inbound_smtp.InboundHandler().handle_RCPT(
None,
None,
target,
"User@GetAll.Help",
[],
)
assert response.startswith("250 2.1.5")
assert target.rcpt_tos == ["user@getall.help"]
assert target.account_ids == {"user@getall.help": "account-1"}
@pytest.mark.asyncio
async def test_data_returns_success_only_after_idempotent_records(monkeypatch):
messages = FakeMessages()
entries = FakeEntries()
monkeypatch.setattr(
inbound_smtp,
"mongo_db",
SimpleNamespace(messages=messages, mailbox_entries=entries),
)
monkeypatch.setattr(inbound_smtp, "get_raw_mime_storage", lambda: FakeStorage())
response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope())
assert response.startswith("250 2.0.0")
assert messages.updates == 1
assert entries.updates == 1
@pytest.mark.asyncio
async def test_data_returns_retryable_status_on_storage_failure(monkeypatch):
class BrokenStorage:
async def put(self, raw, delivery_key):
raise TimeoutError("GCS unavailable")
monkeypatch.setattr(inbound_smtp, "get_raw_mime_storage", lambda: BrokenStorage())
response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope())
assert response.startswith("451 4.3.0")
@pytest.mark.asyncio
async def test_data_rejects_oversized_message(monkeypatch):
monkeypatch.setattr(
inbound_smtp,
"get_settings",
lambda: SimpleNamespace(inbound_max_bytes=10),
)
response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope())
assert response.startswith("552 5.3.4")

View file

@ -0,0 +1,29 @@
from types import SimpleNamespace
from unittest.mock import patch
from app.services.mail_sender import build_message, sign_message
def test_dkim_sign_uses_selector_before_domain():
message = build_message(
sender="user@ritoesis.com",
recipients=["recipient@example.com"],
cc=[],
subject="Test",
body_text="Hello",
body_html=None,
)
domain = SimpleNamespace(
domain_name="ritoesis.com",
dkim_selector="agmail",
dkim_private_key="private-key",
)
with patch(
"app.services.mail_sender.dkim.sign",
return_value=b"DKIM-Signature: test\r\n",
) as signer:
sign_message(message, domain)
assert signer.call_args.args[1] == b"agmail"
assert signer.call_args.args[2] == b"ritoesis.com"

View file

@ -0,0 +1,28 @@
from app.services.mime_parser import parse_mime
def test_parse_mime_extracts_fallback_sender_bodies_and_attachments():
raw = (
b"From: sender@example.com\r\n"
b"To: User <user@getall.help>\r\n"
b"Subject: Production test\r\n"
b"Message-ID: <one@example.com>\r\n"
b"MIME-Version: 1.0\r\n"
b'Content-Type: multipart/mixed; boundary="b"\r\n\r\n'
b"--b\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello world\r\n"
b"--b\r\nContent-Type: application/octet-stream\r\n"
b'Content-Disposition: attachment; filename="sample.bin"\r\n\r\nabc\r\n'
b"--b--\r\n"
)
parsed = parse_mime(raw)
assert parsed["sender"] == {
"name": "sender",
"address": "sender@example.com",
}
assert parsed["subject"] == "Production test"
assert parsed["snippet"] == "Hello world"
assert parsed["body_text"] == "Hello world"
assert parsed["attachments"][0]["filename"] == "sample.bin"
assert parsed["attachments"][0]["size"] == 3