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