commit cce9a8c9ba653003b62316e42101dbb30c75d8cc Author: debu-2 Date: Sat Oct 3 21:14:44 2026 +0530 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..560e1b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +__pycache__/ +*.py[cod] +.pytest_cache/ +.env +.env.git +*.env* +*.log +.DS_Store +*midyear-karma-470610-i1-da5334d20acc.json \ No newline at end of file diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..414f1a9 --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,244 @@ +# Deployment guide + +Agmail runs as one Docker Compose stack on a **mail VPS** (port 25 open): Caddy +(HTTPS), frontend, backend, PostgreSQL, MongoDB, and Postfix (inbound + outbound). + +The **main website** at `getall.help` can stay on another host (e.g. GCP). +Only the email stack lives on the mail VPS: + +| Hostname | Points to | Purpose | +|----------|-----------|---------| +| `getall.help` | GCP / main site IP | Marketing / main app (unchanged) | +| `email.getall.help` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend) | +| `mail.getall.help` | **Mail VPS public IP** | Postfix :25 (inbound + outbound) | +| `@getall.help` MX | `mail.getall.help` | Delivers mail to the VPS | + +User addresses remain **`user@getall.help`**. + +## Architecture + +```text +Browser ──HTTPS──► email.getall.help (Caddy :443) + └──► frontend ──► backend ──SMTP──► smtp:25 (Postfix outbound) + +Internet ──SMTP:25──► mail.getall.help (Postfix) + ├── inbound → backend:2525 → GCS + MongoDB + └── outbound ← backend (Docker mynetworks) +``` + +## Quick start + +```bash +cp .env.example .env +# Edit .env — see Environment section below +docker compose up -d --build +``` + +Open **https://email.getall.help** after DNS and TLS are ready. + +| Service | Host port | Purpose | +|----------|-----------|---------| +| caddy | 80, 443 | Public HTTPS entrypoint | +| frontend | 127.0.0.1:8080 | Local debug only | +| backend | 127.0.0.1:8000 | API / health (local) | +| smtp | 25 | Inbound + outbound Postfix | + +```bash +curl -fsS http://127.0.0.1:8000/health +docker compose ps +docker compose logs -f caddy +``` + +## Environment (`.env` at repo root) + +### Required + +```env +JWT_SECRET= +POSTGRES_PASSWORD="" +MONGO_ROOT_PASSWORD="" +GCS_RAW_MIME_BUCKET= +GCS_PROJECT= +GCP_CREDENTIALS_FILE=./midyear-karma-470610-i1-da5334d20acc.json +CADDY_ACME_EMAIL=admin@getall.help +CORS_ORIGINS=["https://email.getall.help"] +``` + +Quote values that contain `#`. + +`GCP_CREDENTIALS_FILE` is **relative to `deploy/app/`**, not the repo root. + +Outbound uses the Docker Postfix service by default (`SMTP_HOST=smtp`, +`SMTP_PORT=25`, `SMTP_USE_TLS=false`). No Amazon SES credentials are required. + +### Split-domain settings (mail VPS) + +```env +# User mailboxes are still @getall.help +PLATFORM_DOMAIN=getall.help + +# Web UI on this host (Caddy terminates TLS here) +APP_PUBLIC_HOST=email.getall.help +CADDY_ACME_EMAIL=admin@getall.help + +# Postfix hostname (inbound + outbound) +MAIL_HOSTNAME=mail.getall.help +MAIL_DOMAIN=getall.help +INBOUND_DOMAINS=getall.help +SMTP_PUBLIC_HOST=mail.getall.help +SMTP_HOST=smtp +SMTP_PORT=25 +SMTP_USE_TLS=false + +# Same-host Docker defaults (usually leave as-is) +BACKEND_INGEST_HOST=backend +BACKEND_INGEST_PORT=2525 +INBOUND_ALLOWED_CIDRS=["172.16.0.0/12","127.0.0.0/8"] +INBOUND_SMTP_BIND_IP=127.0.0.1 +``` + +## Namecheap DNS changes + +Update DNS as follows (`` = public IP of the mail server). Generate the +exact rows at **https://email.getall.help/smtp-credentials**: + +### Inbound + +| Type | Host | Value | Notes | +|------|------|-------|-------| +| **A** | `@` | main site IP | **Keep** — marketing site | +| **A** | `email` | `` | Web UI | +| **A** | `mail` | `` | Postfix | +| **MX** | `@` | `10 mail.getall.help` | Priority 10 | + +### Outbound authentication + +| Type | Host | Value | Notes | +|------|------|-------|-------| +| **TXT** | `@` | `v=spf1 mx a ip4: ~all` | SPF for `@getall.help` mail | +| **TXT** | `_spf` | `v=spf1 ip4: ~all` | **Required** — company domains SPF-include this | +| **TXT** | `agmail._domainkey` | app DKIM public key | From `/smtp-credentials` | +| **TXT** | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@getall.help` | One record only | + +Remove any old Amazon SES Easy DKIM CNAMEs and `include:amazonses.com` from SPF. + +### Firewall / security group + +| Port | Source | Purpose | +|------|--------|---------| +| 443 | `0.0.0.0/0` | HTTPS web UI | +| 80 | `0.0.0.0/0` | HTTP → HTTPS redirect (Caddy) | +| 25 | `0.0.0.0/0` | Inbound mail (+ outbound egress) | + +Do **not** expose 5432, 27017, 8000, or 2525 publicly. + +Confirm **egress** on port 25 is allowed (required for delivery to Gmail/Outlook): + +```bash +nc -vz -w 5 gmail-smtp-in.l.google.com 25 +``` + +### Reverse DNS (PTR) — required for Gmail/Outlook + +Major receivers expect your VPS IP reverse DNS to match `mail.getall.help`. + +```bash +dig +short -x YOUR_VPS_IP +# Good: mail.getall.help. +``` + +Set PTR in your VPS provider panel (is\*hosting: client area → network / +rDNS, or open a support ticket) to `mail.getall.help`. + +### STARTTLS on Postfix (port 25) + +Gmail/Zoho use STARTTLS. Postfix generates a cert on first boot. After rebuild: + +```bash +printf 'EHLO test\r\nSTARTTLS\r\n' | nc mail.getall.help 25 +# expect: 220 Ready to start TLS (not 454 TLS not available) +``` + +## Caddy / TLS + +Caddy obtains Let's Encrypt certificates automatically when: + +1. `APP_PUBLIC_HOST=email.getall.help` resolves to this server's public IP +2. Ports 80 and 443 are open +3. `CADDY_ACME_EMAIL` is set in `.env` + +Config: `deploy/caddy/Caddyfile` → reverse-proxies to `frontend:80`. + +## Smoke test + +```bash +# HTTPS UI +curl -fsSI https://email.getall.help/health + +# Inbound mail (account must exist) +swaks \ + --server mail.getall.help:25 \ + --from sender@example.com \ + --to existing-user@getall.help \ + --header "Subject: inbound smoke test" \ + --body "Verify delivery" + +# Outbound queue after sending from the UI +docker exec agmail-smtp postqueue -p +curl -fsS http://127.0.0.1:8000/health +``` + +## Operations + +```bash +docker compose logs -f caddy backend smtp +docker compose restart caddy +docker exec agmail-smtp postqueue -p +``` + +Persistent volumes: `postgres_data`, `mongo_data`, `smtp_queue`, `caddy_data`. + +## TLS troubleshooting (`SSL_ERROR_INTERNAL_ERROR_ALERT`) + +This means Caddy is listening on 443 but **does not have a valid certificate** +(Let's Encrypt issuance failed or left bad state in `caddy_data`). + +On the mail server: + +```bash +cd /opt/apps/agentic_email + +# 1. Confirm .env has a real contact email (use your Gmail if unsure) +grep CADDY_ACME_EMAIL .env +grep APP_PUBLIC_HOST .env +# APP_PUBLIC_HOST=email.getall.help +# CADDY_ACME_EMAIL=you@gmail.com + +# 2. Check Caddy logs for ACME errors +docker compose logs caddy --tail 80 + +# 3. Reset Caddy cert storage and recreate +docker compose stop caddy +docker volume rm agentic_email_caddy_data 2>/dev/null || docker volume rm email_caddy_data +docker compose up -d --build caddy +docker compose logs -f caddy +``` + +Wait 30–60 seconds, then test: + +```bash +curl -fsSI https://email.getall.help/ +``` + +Requirements for Let's Encrypt: + +- `email.getall.help` A record → this server's public IP +- Firewall allows **inbound TCP 80 and 443** from `0.0.0.0/0` +- `CADDY_ACME_EMAIL` set in `.env` + +If logs show rate limits, wait an hour or use a staging CA temporarily. + +## API reference + +- `GET /health` — service checks (also proxied at `/health` on the web host) +- `POST /api/v1/dns-setup/preview` — DNS generator (HTTP Basic Auth) diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..101bf45 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,5 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.venv/ +.env diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..5d6be6f --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.13-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +EXPOSE 8000 2525 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..5fd35c2 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""Agmail API package.""" diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..a15cb91 --- /dev/null +++ b/backend/app/config.py @@ -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() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..c06dbef --- /dev/null +++ b/backend/app/database.py @@ -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 diff --git a/backend/app/deps/__init__.py b/backend/app/deps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/deps/dns_setup_auth.py b/backend/app/deps/dns_setup_auth.py new file mode 100644 index 0000000..dfdbeb2 --- /dev/null +++ b/backend/app/deps/dns_setup_auth.py @@ -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"}, + ) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..666a0e3 --- /dev/null +++ b/backend/app/main.py @@ -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} diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..c5bf07c --- /dev/null +++ b/backend/app/models.py @@ -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") diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..fb0a2f8 --- /dev/null +++ b/backend/app/routers/__init__.py @@ -0,0 +1 @@ +"""API route modules.""" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py new file mode 100644 index 0000000..eb95060 --- /dev/null +++ b/backend/app/routers/auth.py @@ -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 diff --git a/backend/app/routers/dns_setup.py b/backend/app/routers/dns_setup.py new file mode 100644 index 0000000..600ad75 --- /dev/null +++ b/backend/app/routers/dns_setup.py @@ -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).", + ] diff --git a/backend/app/routers/domains.py b/backend/app/routers/domains.py new file mode 100644 index 0000000..b6ab315 --- /dev/null +++ b/backend/app/routers/domains.py @@ -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, + ) diff --git a/backend/app/routers/emails.py b/backend/app/routers/emails.py new file mode 100644 index 0000000..119c3a1 --- /dev/null +++ b/backend/app/routers/emails.py @@ -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}) diff --git a/backend/app/routers/sticky_notes.py b/backend/app/routers/sticky_notes.py new file mode 100644 index 0000000..e2d11f8 --- /dev/null +++ b/backend/app/routers/sticky_notes.py @@ -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.") diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..4637e44 --- /dev/null +++ b/backend/app/schemas.py @@ -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) diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..32136f4 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""Backend service modules.""" diff --git a/backend/app/services/dns_verification.py b/backend/app/services/dns_verification.py new file mode 100644 index 0000000..cd27243 --- /dev/null +++ b/backend/app/services/dns_verification.py @@ -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: ~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 + ] diff --git a/backend/app/services/domain_provisioning.py b/backend/app/services/domain_provisioning.py new file mode 100644 index 0000000..9dec802 --- /dev/null +++ b/backend/app/services/domain_provisioning.py @@ -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", + ) diff --git a/backend/app/services/inbound_smtp.py b/backend/app/services/inbound_smtp.py new file mode 100644 index 0000000..5468b3e --- /dev/null +++ b/backend/app/services/inbound_smtp.py @@ -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 diff --git a/backend/app/services/mail_sender.py b/backend/app/services/mail_sender.py new file mode 100644 index 0000000..b5f1716 --- /dev/null +++ b/backend/app/services/mail_sender.py @@ -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], + ) diff --git a/backend/app/services/mime_parser.py b/backend/app/services/mime_parser.py new file mode 100644 index 0000000..d76d61a --- /dev/null +++ b/backend/app/services/mime_parser.py @@ -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, + } diff --git a/backend/app/services/raw_mime_storage.py b/backend/app/services/raw_mime_storage.py new file mode 100644 index 0000000..bbd25d6 --- /dev/null +++ b/backend/app/services/raw_mime_storage.py @@ -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 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..fd87616 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,19 @@ +fastapi>=0.116 +uvicorn[standard]>=0.35 +sqlalchemy[asyncio]>=2.0 +asyncpg>=0.30 +pymongo>=4.13 +pydantic-settings>=2.10 +email-validator>=2.2 +# Argon2id password hashing; intentionally avoids bcrypt backend/version coupling. +pwdlib[argon2]>=0.3,<1.0 +pyjwt>=2.10 +aiosmtplib>=4.0 +dkimpy>=1.1 +cryptography>=44.0 +aiosmtpd>=1.4.6 +google-cloud-storage>=3.0 +google-crc32c>=1.7 +dnspython>=2.7 +pytest>=8.0 +pytest-asyncio>=0.25 diff --git a/backend/tests/test_company_dns.py b/backend/tests/test_company_dns.py new file mode 100644 index 0000000..28d5b86 --- /dev/null +++ b/backend/tests/test_company_dns.py @@ -0,0 +1,87 @@ +"""Tests for company shared-MX DNS onboarding.""" + +from unittest.mock import patch + +from app.services.dns_verification import verify_company_dns +from app.services.domain_provisioning import ( + build_company_dns_records, + build_shared_mx_spf_value, + register_relay_domain, +) + + +def test_build_company_dns_records_use_shared_mx_and_spf_include(): + records = build_company_dns_records( + "acme.com", + "mail.getall.help", + "agmail", + "abc123public", + "_spf.getall.help", + ) + purposes = [record["purpose"] for record in records] + assert purposes == ["mx", "spf", "dkim", "dmarc"] + + mx = next(record for record in records if record["purpose"] == "mx") + spf = next(record for record in records if record["purpose"] == "spf") + dkim = next(record for record in records if record["purpose"] == "dkim") + + assert mx["host"] == "@" + assert mx["value"] == "mail.getall.help" + assert mx["priority"] == 10 + assert spf["value"] == "v=spf1 include:_spf.getall.help ~all" + assert dkim["host"] == "agmail._domainkey" + assert "abc123public" in dkim["value"] + assert not any(record.get("purpose") == "mail_host" for record in records) + + +def test_build_shared_mx_spf_value(): + assert build_shared_mx_spf_value("_spf.getall.help") == ( + "v=spf1 include:_spf.getall.help ~all" + ) + + +def test_register_relay_domain_appends_once(tmp_path, monkeypatch): + relay_file = tmp_path / "domains.list" + monkeypatch.setenv("RELAY_DOMAINS_FILE", str(relay_file)) + + from app.config import get_settings + + get_settings.cache_clear() + + register_relay_domain("Acme.COM") + register_relay_domain("acme.com") + + assert relay_file.read_text(encoding="utf-8") == "acme.com\n" + + get_settings.cache_clear() + + +@patch("app.services.dns_verification._lookup_a") +@patch("app.services.dns_verification._lookup_mx") +@patch("app.services.dns_verification._lookup_txt") +def test_verify_company_dns_all_pass(mock_txt, mock_mx, mock_a): + mock_mx.return_value = [(10, "mail.getall.help")] + mock_a.return_value = ["38.180.106.242"] + mock_txt.side_effect = lambda name: { + "acme.com": ['v=spf1 include:_spf.getall.help ~all'], + "_spf.getall.help": ["v=spf1 ip4:38.180.106.242 ~all"], + "agmail._domainkey.acme.com": ["v=DKIM1; k=rsa; p=abc123"], + "_dmarc.acme.com": ["v=DMARC1; p=quarantine; rua=mailto:dmarc@acme.com"], + }.get(name, []) + + checks = verify_company_dns( + domain_name="acme.com", + shared_mail_host="mail.getall.help", + dkim_selector="agmail", + dkim_public_key="abc123", + platform_spf_include="_spf.getall.help", + ) + + assert all(check["verified"] for check in checks) + assert [check["purpose"] for check in checks] == [ + "mx", + "spf", + "platform_spf", + "dkim", + "dmarc", + ] diff --git a/backend/tests/test_dns_setup.py b/backend/tests/test_dns_setup.py new file mode 100644 index 0000000..e12438f --- /dev/null +++ b/backend/tests/test_dns_setup.py @@ -0,0 +1,76 @@ +"""Tests for deployment DNS record generation.""" + +from app.services.domain_provisioning import ( + build_deployment_dns_records, + build_dns_records, + build_spf_value, + namecheap_host, +) + + +def test_namecheap_host_strips_zone_suffix(): + assert namecheap_host("mail.getall.help", "getall.help") == "mail" + assert namecheap_host("@", "getall.help") == "@" + assert ( + namecheap_host("agmail._domainkey.getall.help", "getall.help") + == "agmail._domainkey" + ) + + +def test_build_dns_records_uses_namecheap_hosts_and_ip(): + records = build_dns_records( + "getall.help", + "mail.getall.help", + "agmail", + "abc123", + mail_server_ip="203.0.113.10", + ) + mx = next(record for record in records if record["purpose"] == "mx") + a_record = next(record for record in records if record["purpose"] == "mail_host") + spf = next(record for record in records if record["purpose"] == "spf") + + assert mx["host"] == "@" + assert mx["value"] == "mail.getall.help" + assert mx["priority"] == 10 + assert a_record["host"] == "mail" + assert a_record["value"] == "203.0.113.10" + assert spf["value"] == "v=spf1 mx a ip4:203.0.113.10 ~all" + assert "amazonses" not in spf["value"] + + +def test_build_spf_value_supports_ipv6(): + assert build_spf_value("2001:db8::1") == "v=spf1 mx a ip6:2001:db8::1 ~all" + + +def test_deployment_sections_split_inbound_and_outbound(): + sections = build_deployment_dns_records( + domain_name="getall.help", + mail_server_ip="203.0.113.10", + dkim_selector="agmail", + dkim_public_key="test-public-key", + app_public_host="email.getall.help", + ) + + inbound_purposes = [record["purpose"] for record in sections["inbound"]] + outbound_purposes = [record["purpose"] for record in sections["outbound"]] + + assert inbound_purposes == ["mx", "mail_host", "app_host"] + assert outbound_purposes == ["platform_spf", "spf", "dkim", "dmarc"] + assert "ses_outbound" not in sections + + app_host = next( + record for record in sections["inbound"] if record["purpose"] == "app_host" + ) + assert app_host["host"] == "email" + assert app_host["value"] == "203.0.113.10" + + dmarc = next( + record for record in sections["outbound"] if record["purpose"] == "dmarc" + ) + assert dmarc["value"] == "v=DMARC1; p=quarantine; rua=mailto:dmarc@getall.help" + + dkim = next( + record for record in sections["outbound"] if record["purpose"] == "dkim" + ) + assert dkim["host"] == "agmail._domainkey" + assert "test-public-key" in dkim["value"] diff --git a/backend/tests/test_email_helpers.py b/backend/tests/test_email_helpers.py new file mode 100644 index 0000000..af84767 --- /dev/null +++ b/backend/tests/test_email_helpers.py @@ -0,0 +1,49 @@ +from datetime import datetime, timezone + +from bson import ObjectId + +from app.routers.emails import decode_cursor, encode_cursor, summarize + + +def test_cursor_round_trip_preserves_sort_key(): + entry = { + "_id": ObjectId(), + "created_at": datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc), + } + + created_at, entry_id = decode_cursor(encode_cursor(entry)) + + assert created_at == entry["created_at"] + assert entry_id == entry["_id"] + + +def test_summary_uses_account_mailbox_read_state(): + now = datetime(2026, 8, 9, 12, 30, tzinfo=timezone.utc) + message_id = ObjectId() + message = { + "_id": message_id, + "direction": "inbound", + "sender": {"name": "Sender", "address": "sender@example.com"}, + "to": [{"name": "user", "address": "user@getall.help"}], + "subject": "Hello", + "snippet": "A short preview", + "status": "received", + "created_at": now, + "received_at": now, + "attachments": [], + } + entry = { + "_id": ObjectId(), + "message_id": message_id, + "mailbox": "inbox", + "is_read": False, + "trashed_at": None, + "created_at": now, + } + + result = summarize(message, entry) + + assert result.id == str(message_id) + assert result.is_read is False + assert result.mailbox == "inbox" + assert result.snippet == "A short preview" diff --git a/backend/tests/test_inbound_smtp.py b/backend/tests/test_inbound_smtp.py new file mode 100644 index 0000000..6f25798 --- /dev/null +++ b/backend/tests/test_inbound_smtp.py @@ -0,0 +1,143 @@ +from types import SimpleNamespace + +import pytest +from bson import ObjectId + +from app.services import inbound_smtp +from app.services.raw_mime_storage import StoredMime + + +RAW = ( + b"From: Sender \r\n" + b"To: user@getall.help\r\n" + b"Subject: Hello\r\n\r\n" + b"Retry safe body\r\n" +) + + +class FakeMessages: + def __init__(self): + self.id = ObjectId() + self.updates = 0 + + async def update_one(self, *args, **kwargs): + self.updates += 1 + + async def find_one(self, *args, **kwargs): + return {"_id": self.id} + + +class FakeEntries: + def __init__(self): + self.updates = 0 + + async def update_one(self, *args, **kwargs): + self.updates += 1 + + +class FakeStorage: + async def put(self, raw, delivery_key): + return StoredMime("mail", f"raw/{delivery_key}.eml", 7, len(raw), "crc") + + +class FakeSession: + def __init__(self, account): + self.account = account + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + async def scalar(self, query): + return self.account + + +def envelope(raw=RAW): + return SimpleNamespace( + original_content=raw, + rcpt_tos=["user@getall.help"], + mail_from="sender@example.com", + account_ids={"user@getall.help": "account-1"}, + ) + + +@pytest.mark.asyncio +async def test_rcpt_rejects_unknown_account(monkeypatch): + monkeypatch.setattr(inbound_smtp, "SessionFactory", lambda: FakeSession(None)) + target = SimpleNamespace(rcpt_tos=[], rcpt_options=[]) + + response = await inbound_smtp.InboundHandler().handle_RCPT( + None, + None, + target, + "missing@getall.help", + [], + ) + + assert response.startswith("550 5.1.1") + assert target.rcpt_tos == [] + + +@pytest.mark.asyncio +async def test_rcpt_accepts_account_and_tracks_owner(monkeypatch): + account = SimpleNamespace(id="account-1") + monkeypatch.setattr(inbound_smtp, "SessionFactory", lambda: FakeSession(account)) + target = SimpleNamespace(rcpt_tos=[], rcpt_options=[]) + + response = await inbound_smtp.InboundHandler().handle_RCPT( + None, + None, + target, + "User@GetAll.Help", + [], + ) + + assert response.startswith("250 2.1.5") + assert target.rcpt_tos == ["user@getall.help"] + assert target.account_ids == {"user@getall.help": "account-1"} + + +@pytest.mark.asyncio +async def test_data_returns_success_only_after_idempotent_records(monkeypatch): + messages = FakeMessages() + entries = FakeEntries() + monkeypatch.setattr( + inbound_smtp, + "mongo_db", + SimpleNamespace(messages=messages, mailbox_entries=entries), + ) + monkeypatch.setattr(inbound_smtp, "get_raw_mime_storage", lambda: FakeStorage()) + + response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope()) + + assert response.startswith("250 2.0.0") + assert messages.updates == 1 + assert entries.updates == 1 + + +@pytest.mark.asyncio +async def test_data_returns_retryable_status_on_storage_failure(monkeypatch): + class BrokenStorage: + async def put(self, raw, delivery_key): + raise TimeoutError("GCS unavailable") + + monkeypatch.setattr(inbound_smtp, "get_raw_mime_storage", lambda: BrokenStorage()) + + response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope()) + + assert response.startswith("451 4.3.0") + + +@pytest.mark.asyncio +async def test_data_rejects_oversized_message(monkeypatch): + monkeypatch.setattr( + inbound_smtp, + "get_settings", + lambda: SimpleNamespace(inbound_max_bytes=10), + ) + + response = await inbound_smtp.InboundHandler().handle_DATA(None, None, envelope()) + + assert response.startswith("552 5.3.4") diff --git a/backend/tests/test_mail_sender.py b/backend/tests/test_mail_sender.py new file mode 100644 index 0000000..03f5995 --- /dev/null +++ b/backend/tests/test_mail_sender.py @@ -0,0 +1,29 @@ +from types import SimpleNamespace +from unittest.mock import patch + +from app.services.mail_sender import build_message, sign_message + + +def test_dkim_sign_uses_selector_before_domain(): + message = build_message( + sender="user@ritoesis.com", + recipients=["recipient@example.com"], + cc=[], + subject="Test", + body_text="Hello", + body_html=None, + ) + domain = SimpleNamespace( + domain_name="ritoesis.com", + dkim_selector="agmail", + dkim_private_key="private-key", + ) + + with patch( + "app.services.mail_sender.dkim.sign", + return_value=b"DKIM-Signature: test\r\n", + ) as signer: + sign_message(message, domain) + + assert signer.call_args.args[1] == b"agmail" + assert signer.call_args.args[2] == b"ritoesis.com" diff --git a/backend/tests/test_mime_parser.py b/backend/tests/test_mime_parser.py new file mode 100644 index 0000000..3a9e7a9 --- /dev/null +++ b/backend/tests/test_mime_parser.py @@ -0,0 +1,28 @@ +from app.services.mime_parser import parse_mime + + +def test_parse_mime_extracts_fallback_sender_bodies_and_attachments(): + raw = ( + b"From: sender@example.com\r\n" + b"To: User \r\n" + b"Subject: Production test\r\n" + b"Message-ID: \r\n" + b"MIME-Version: 1.0\r\n" + b'Content-Type: multipart/mixed; boundary="b"\r\n\r\n' + b"--b\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nHello world\r\n" + b"--b\r\nContent-Type: application/octet-stream\r\n" + b'Content-Disposition: attachment; filename="sample.bin"\r\n\r\nabc\r\n' + b"--b--\r\n" + ) + + parsed = parse_mime(raw) + + assert parsed["sender"] == { + "name": "sender", + "address": "sender@example.com", + } + assert parsed["subject"] == "Production test" + assert parsed["snippet"] == "Hello world" + assert parsed["body_text"] == "Hello world" + assert parsed["attachments"][0]["filename"] == "sample.bin" + assert parsed["attachments"][0]["size"] == 3 diff --git a/deploy/app/az_2_letter_color_map.json b/deploy/app/az_2_letter_color_map.json new file mode 100644 index 0000000..247faa0 --- /dev/null +++ b/deploy/app/az_2_letter_color_map.json @@ -0,0 +1,678 @@ +{ + "AA": "#3E0C0C", + "AB": "#3F100C", + "AC": "#40150B", + "AD": "#41190B", + "AE": "#421E0B", + "AF": "#43230A", + "AG": "#44280A", + "AH": "#452E0A", + "AI": "#463409", + "AJ": "#473A09", + "AK": "#484008", + "AL": "#494708", + "AM": "#464908", + "AN": "#414A07", + "AO": "#3B4B07", + "AP": "#364C07", + "AQ": "#304E06", + "AR": "#2A4F06", + "AS": "#245005", + "AT": "#1E5105", + "AU": "#175204", + "AV": "#105304", + "AW": "#085404", + "AX": "#035505", + "AY": "#03560C", + "AZ": "#025713", + "BA": "#40180C", + "BB": "#401D0C", + "BC": "#41210B", + "BD": "#42260B", + "BE": "#432C0B", + "BF": "#44310A", + "BG": "#45370A", + "BH": "#463D0A", + "BI": "#474309", + "BJ": "#474809", + "BK": "#424909", + "BL": "#3D4A08", + "BM": "#384B08", + "BN": "#324C08", + "BO": "#2D4D07", + "BP": "#274E07", + "BQ": "#204F06", + "BR": "#1A5006", + "BS": "#135105", + "BT": "#0C5205", + "BU": "#055305", + "BV": "#04540B", + "BW": "#045512", + "BX": "#035619", + "BY": "#035820", + "BZ": "#025928", + "CA": "#41250C", + "CB": "#422A0C", + "CC": "#432F0C", + "CD": "#44340B", + "CE": "#453A0B", + "CF": "#46400B", + "CG": "#47460A", + "CH": "#43480A", + "CI": "#3E490A", + "CJ": "#394A09", + "CK": "#344B09", + "CL": "#2F4C08", + "CM": "#294D08", + "CN": "#234E08", + "CO": "#1D4F07", + "CP": "#175007", + "CQ": "#105106", + "CR": "#095206", + "CS": "#065309", + "CT": "#055410", + "CU": "#055517", + "CV": "#04561E", + "CW": "#045725", + "CX": "#03582D", + "CY": "#035935", + "CZ": "#025A3D", + "DA": "#42320D", + "DB": "#43370C", + "DC": "#443D0C", + "DD": "#45430C", + "DE": "#44460B", + "DF": "#3F470B", + "DG": "#3B480B", + "DH": "#36490A", + "DI": "#314A0A", + "DJ": "#2B4B09", + "DK": "#264C09", + "DL": "#204D09", + "DM": "#1A4E08", + "DN": "#134F08", + "DO": "#0D5007", + "DP": "#075108", + "DQ": "#07520E", + "DR": "#065315", + "DS": "#06541C", + "DT": "#055523", + "DU": "#05572A", + "DV": "#045832", + "DW": "#045939", + "DX": "#035A42", + "DY": "#035B4A", + "DZ": "#025C53", + "EA": "#44400D", + "EB": "#44450D", + "EC": "#40460C", + "ED": "#3C470C", + "EE": "#37480C", + "EF": "#32490B", + "EG": "#2D4A0B", + "EH": "#284B0A", + "EI": "#224C0A", + "EJ": "#1D4D0A", + "EK": "#174E09", + "EL": "#104F09", + "EM": "#0A5008", + "EN": "#08510D", + "EO": "#085213", + "EP": "#07531A", + "EQ": "#075420", + "ER": "#065527", + "ES": "#06562F", + "ET": "#055736", + "EU": "#05583E", + "EV": "#045946", + "EW": "#045A4E", + "EX": "#035B57", + "EY": "#03595D", + "EZ": "#02525E", + "FA": "#3D450D", + "FB": "#38460D", + "FC": "#34470D", + "FD": "#2F480C", + "FE": "#2A490C", + "FF": "#254A0B", + "FG": "#1F4B0B", + "FH": "#1A4C0B", + "FI": "#144D0A", + "FJ": "#0D4E0A", + "FK": "#094F0C", + "FL": "#095012", + "FM": "#095118", + "FN": "#08521E", + "FO": "#085325", + "FP": "#07542C", + "FQ": "#075633", + "FR": "#06573B", + "FS": "#065842", + "FT": "#05594A", + "FU": "#055A53", + "FV": "#045A5B", + "FW": "#04545C", + "FX": "#034D5D", + "FY": "#03465E", + "FZ": "#023E5F", + "GA": "#31470D", + "GB": "#2C480D", + "GC": "#27490D", + "GD": "#224A0C", + "GE": "#1D4B0C", + "GF": "#174C0C", + "GG": "#114D0B", + "GH": "#0B4E0B", + "GI": "#0A4F11", + "GJ": "#0A5016", + "GK": "#0A511D", + "GL": "#095223", + "GM": "#09532A", + "GN": "#085430", + "GO": "#085538", + "GP": "#07563F", + "GQ": "#075747", + "GR": "#06584F", + "GS": "#065957", + "GT": "#06555A", + "GU": "#054F5B", + "GV": "#05485D", + "GW": "#04415E", + "GX": "#04395F", + "GY": "#033160", + "GZ": "#022961", + "HA": "#24480E", + "HB": "#1F490D", + "HC": "#1A4A0D", + "HD": "#144B0D", + "HE": "#0E4C0C", + "HF": "#0C4D10", + "HG": "#0B4E15", + "HH": "#0B4F1B", + "HI": "#0B5021", + "HJ": "#0A5127", + "HK": "#0A522E", + "HL": "#095335", + "HM": "#09543C", + "HN": "#085643", + "HO": "#08574B", + "HP": "#085853", + "HQ": "#075759", + "HR": "#07505A", + "HS": "#064A5B", + "HT": "#06435C", + "HU": "#053C5D", + "HV": "#05345E", + "HW": "#042D5F", + "HX": "#042560", + "HY": "#031C62", + "HZ": "#031463", + "IA": "#174A0E", + "IB": "#124B0E", + "IC": "#0D4C0F", + "ID": "#0D4D14", + "IE": "#0C4E19", + "IF": "#0C4F1F", + "IG": "#0C5025", + "IH": "#0B512B", + "II": "#0B5232", + "IJ": "#0A5339", + "IK": "#0A5440", + "IL": "#0A5547", + "IM": "#09564F", + "IN": "#095756", + "IO": "#085258", + "IP": "#084C59", + "IQ": "#07455A", + "IR": "#073E5B", + "IS": "#06375D", + "IT": "#06305E", + "IU": "#05285F", + "IV": "#052060", + "IW": "#041861", + "IX": "#041062", + "IY": "#030763", + "IZ": "#070364", + "JA": "#0E4B13", + "JB": "#0E4C18", + "JC": "#0E4D1E", + "JD": "#0D4E23", + "JE": "#0D4F29", + "JF": "#0C502F", + "JG": "#0C5136", + "JH": "#0B523D", + "JI": "#0B5344", + "JJ": "#0B544B", + "JK": "#0A5552", + "JL": "#0A5357", + "JM": "#094D58", + "JN": "#094759", + "JO": "#08405A", + "JP": "#083A5B", + "JQ": "#07335C", + "JR": "#072B5D", + "JS": "#06245E", + "JT": "#061C5F", + "JU": "#051460", + "JV": "#050B62", + "JW": "#060463", + "JX": "#0E0464", + "JY": "#160365", + "JZ": "#1F0366", + "KA": "#0F4D22", + "KB": "#0E4E27", + "KC": "#0E4F2D", + "KD": "#0D5033", + "KE": "#0D513A", + "KF": "#0D5240", + "KG": "#0C5347", + "KH": "#0C544E", + "KI": "#0B5455", + "KJ": "#0B4E56", + "KK": "#0A4957", + "KL": "#0A4258", + "KM": "#093C59", + "KN": "#09355A", + "KO": "#082E5B", + "KP": "#08275C", + "KQ": "#07205E", + "KR": "#07185F", + "KS": "#061060", + "KT": "#060761", + "KU": "#0C0562", + "KV": "#140563", + "KW": "#1C0464", + "KX": "#250465", + "KY": "#2D0367", + "KZ": "#360368", + "LA": "#0F4E31", + "LB": "#0E4F37", + "LC": "#0E503D", + "LD": "#0E5144", + "LE": "#0D524B", + "LF": "#0D5352", + "LG": "#0C4F54", + "LH": "#0C4A55", + "LI": "#0B4456", + "LJ": "#0B3E58", + "LK": "#0B3859", + "LL": "#0A315A", + "LM": "#0A2A5B", + "LN": "#09235C", + "LO": "#091C5D", + "LP": "#08145E", + "LQ": "#080C5F", + "LR": "#0A0760", + "LS": "#120761", + "LT": "#1A0663", + "LU": "#220564", + "LV": "#2A0565", + "LW": "#330466", + "LX": "#3C0467", + "LY": "#450368", + "LZ": "#4F0369", + "MA": "#0F5041", + "MB": "#0F5147", + "MC": "#0E524E", + "MD": "#0E5053", + "ME": "#0D4B54", + "MF": "#0D4655", + "MG": "#0D4056", + "MH": "#0C3A57", + "MI": "#0C3458", + "MJ": "#0B2D59", + "MK": "#0B265A", + "ML": "#0A1F5B", + "MM": "#0A185C", + "MN": "#09105D", + "MO": "#09095F", + "MP": "#100860", + "MQ": "#180861", + "MR": "#1F0762", + "MS": "#280763", + "MT": "#300664", + "MU": "#390665", + "MV": "#410566", + "MW": "#4B0468", + "MX": "#540469", + "MY": "#5E036A", + "MZ": "#68036B", + "NA": "#0F5151", + "NB": "#0F4C52", + "NC": "#0F4753", + "ND": "#0E4254", + "NE": "#0E3C55", + "NF": "#0D3656", + "NG": "#0D3057", + "NH": "#0C2958", + "NI": "#0C235A", + "NJ": "#0B1C5B", + "NK": "#0B145C", + "NL": "#0A0D5D", + "NM": "#0F0A5E", + "NN": "#16095F", + "NO": "#1D0960", + "NP": "#250861", + "NQ": "#2D0862", + "NR": "#350764", + "NS": "#3E0765", + "NT": "#470666", + "NU": "#500667", + "NV": "#590568", + "NW": "#630569", + "NX": "#6A0468", + "NY": "#6C0360", + "NZ": "#6D0357", + "OA": "#104353", + "OB": "#0F3E54", + "OC": "#0F3855", + "OD": "#0E3256", + "OE": "#0E2C57", + "OF": "#0D2658", + "OG": "#0D1F59", + "OH": "#0D185A", + "OI": "#0C115B", + "OJ": "#0D0C5C", + "OK": "#140B5D", + "OL": "#1B0B5E", + "OM": "#230A5F", + "ON": "#2A0A61", + "OO": "#320962", + "OP": "#3A0863", + "OQ": "#430864", + "OR": "#4C0765", + "OS": "#550766", + "OT": "#5E0667", + "OU": "#680669", + "OV": "#6A0562", + "OW": "#6B055A", + "OX": "#6C0451", + "OY": "#6D0349", + "OZ": "#6F0340", + "PA": "#103554", + "PB": "#102F55", + "PC": "#0F2956", + "PD": "#0F2357", + "PE": "#0E1C58", + "PF": "#0E1559", + "PG": "#0D0E5A", + "PH": "#130D5B", + "PI": "#1A0C5D", + "PJ": "#210C5E", + "PK": "#280B5F", + "PL": "#2F0B60", + "PM": "#370A61", + "PN": "#3F0A62", + "PO": "#480963", + "PP": "#510964", + "PQ": "#5A0866", + "PR": "#630867", + "PS": "#680763", + "PT": "#69065C", + "PU": "#6A0654", + "PV": "#6B054C", + "PW": "#6D0543", + "PX": "#6E043A", + "PY": "#6F0331", + "PZ": "#700327", + "QA": "#102655", + "QB": "#101F56", + "QC": "#0F1958", + "QD": "#0F1259", + "QE": "#120E5A", + "QF": "#180E5B", + "QG": "#1F0D5C", + "QH": "#260D5D", + "QI": "#2D0C5E", + "QJ": "#340C5F", + "QK": "#3C0B60", + "QL": "#440B61", + "QM": "#4D0A63", + "QN": "#550A64", + "QO": "#5E0965", + "QP": "#660965", + "QQ": "#67085D", + "QR": "#680856", + "QS": "#6A074E", + "QT": "#6B0646", + "QU": "#6C063D", + "QV": "#6D0535", + "QW": "#6E052C", + "QX": "#700422", + "QY": "#710419", + "QZ": "#72030E", + "RA": "#111657", + "RB": "#111058", + "RC": "#171059", + "RD": "#1D0F5A", + "RE": "#240F5B", + "RF": "#2B0E5C", + "RG": "#320E5D", + "RH": "#390D5F", + "RI": "#410D60", + "RJ": "#490C61", + "RK": "#510C62", + "RL": "#5A0B63", + "RM": "#620B64", + "RN": "#650A5F", + "RO": "#660958", + "RP": "#680950", + "RQ": "#690849", + "RR": "#6A0841", + "RS": "#6B0738", + "RT": "#6C0730", + "RU": "#6E0627", + "RV": "#6F051D", + "RW": "#700514", + "RX": "#71040A", + "RY": "#720804", + "RZ": "#741103", + "SA": "#1C1158", + "SB": "#221059", + "SC": "#29105B", + "SD": "#2F0F5C", + "SE": "#360F5D", + "SF": "#3E0E5E", + "SG": "#450E5F", + "SH": "#4D0D60", + "SI": "#550D61", + "SJ": "#5E0C62", + "SK": "#630C60", + "SL": "#650B5A", + "SM": "#660B52", + "SN": "#670A4B", + "SO": "#680A43", + "SP": "#69093B", + "SQ": "#6A0833", + "SR": "#6C082B", + "SS": "#6D0722", + "ST": "#6E0718", + "SU": "#6F060F", + "SV": "#700605", + "SW": "#720F05", + "SX": "#470F03", + "SY": "#481502", + "SZ": "#491B02", + "TA": "#2D115A", + "TB": "#34115B", + "TC": "#3B105C", + "TD": "#42105D", + "TE": "#490F5E", + "TF": "#510F5F", + "TG": "#590E60", + "TH": "#620E61", + "TI": "#630D5B", + "TJ": "#640D54", + "TK": "#650C4D", + "TL": "#660B46", + "TM": "#670B3E", + "TN": "#680A37", + "TO": "#6A0A2E", + "TP": "#6B0926", + "TQ": "#6C091D", + "TR": "#6D0814", + "TS": "#6E070A", + "TT": "#700D07", + "TU": "#460E04", + "TV": "#471303", + "TW": "#481903", + "TX": "#492003", + "TY": "#4A2602", + "TZ": "#4B2D02", + "UA": "#3F115B", + "UB": "#46115C", + "UC": "#4D105D", + "UD": "#55105F", + "UE": "#5D0F60", + "UF": "#610F5C", + "UG": "#620E56", + "UH": "#630E4F", + "UI": "#640D48", + "UJ": "#650D41", + "UK": "#670C3A", + "UL": "#680C32", + "UM": "#690B2A", + "UN": "#6A0A21", + "UO": "#6B0A19", + "UP": "#6C090F", + "UQ": "#6E0B09", + "UR": "#6F1408", + "US": "#461205", + "UT": "#471804", + "UU": "#481E04", + "UV": "#492404", + "UW": "#4A2A03", + "UX": "#4B3103", + "UY": "#4C3802", + "UZ": "#4D3F02", + "VA": "#51125D", + "VB": "#59115E", + "VC": "#5F115D", + "VD": "#601057", + "VE": "#611051", + "VF": "#620F4A", + "VG": "#630F44", + "VH": "#650E3C", + "VI": "#660D35", + "VJ": "#670D2D", + "VK": "#680C25", + "VL": "#690C1D", + "VM": "#6A0B14", + "VN": "#6C0B0B", + "VO": "#6D120A", + "VP": "#451006", + "VQ": "#451606", + "VR": "#461C05", + "VS": "#472205", + "VT": "#482804", + "VU": "#492E04", + "VV": "#4A3504", + "VW": "#4B3C03", + "VX": "#4C4303", + "VY": "#4D4B02", + "VZ": "#4B4E02", + "WA": "#5E1258", + "WB": "#5F1152", + "WC": "#60114C", + "WD": "#621046", + "WE": "#63103F", + "WF": "#640F38", + "WG": "#650F31", + "WH": "#660E29", + "WI": "#670E21", + "WJ": "#680D19", + "WK": "#6A0D10", + "WL": "#6B100C", + "WM": "#430F07", + "WN": "#441407", + "WO": "#451A06", + "WP": "#462006", + "WQ": "#472606", + "WR": "#482C05", + "WS": "#493205", + "WT": "#4A3905", + "WU": "#4B4004", + "WV": "#4C4704", + "WW": "#4C4D03", + "WX": "#464E03", + "WY": "#404F02", + "WZ": "#3A5002", + "XA": "#601248", + "XB": "#611241", + "XC": "#62113B", + "XD": "#631134", + "XE": "#64102D", + "XF": "#651025", + "XG": "#660F1D", + "XH": "#680E15", + "XI": "#690F0E", + "XJ": "#420E08", + "XK": "#431308", + "XL": "#441808", + "XM": "#451E07", + "XN": "#462307", + "XO": "#472907", + "XP": "#482F06", + "XQ": "#493606", + "XR": "#4A3C05", + "XS": "#4B4305", + "XT": "#4C4A05", + "XU": "#484D04", + "XV": "#424E04", + "XW": "#3C4F03", + "XX": "#365003", + "XY": "#305103", + "XZ": "#295202", + "YA": "#611237", + "YB": "#621230", + "YC": "#631129", + "YD": "#651121", + "YE": "#66101A", + "YF": "#671012", + "YG": "#410D09", + "YH": "#421209", + "YI": "#431709", + "YJ": "#431C08", + "YK": "#442108", + "YL": "#452708", + "YM": "#462D07", + "YN": "#473307", + "YO": "#483907", + "YP": "#494006", + "YQ": "#4A4706", + "YR": "#494B06", + "YS": "#434C05", + "YT": "#3E4D05", + "YU": "#384E04", + "YV": "#324F04", + "YW": "#2C5003", + "YX": "#255103", + "YY": "#1E5203", + "YZ": "#175402", + "ZA": "#631325", + "ZB": "#64121E", + "ZC": "#651216", + "ZD": "#3F0C0B", + "ZE": "#40110A", + "ZF": "#41160A", + "ZG": "#421B0A", + "ZH": "#432009", + "ZI": "#442509", + "ZJ": "#452B09", + "ZK": "#463008", + "ZL": "#473608", + "ZM": "#483D08", + "ZN": "#494307", + "ZO": "#4A4A07", + "ZP": "#454B06", + "ZQ": "#3F4C06", + "ZR": "#3A4D06", + "ZS": "#344E05", + "ZT": "#2E4F05", + "ZU": "#285004", + "ZV": "#215104", + "ZW": "#1A5204", + "ZX": "#135303", + "ZY": "#0C5403", + "ZZ": "#045502" +} \ No newline at end of file diff --git a/deploy/app/docker-compose.yml b/deploy/app/docker-compose.yml new file mode 100644 index 0000000..a285cea --- /dev/null +++ b/deploy/app/docker-compose.yml @@ -0,0 +1,132 @@ +# App stack: frontend, backend, PostgreSQL, MongoDB +# Included by root docker-compose.yml — deploy from the repository root. + +services: + postgres: + image: postgres:17-alpine + container_name: agmail-postgres + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-agmail_profiles} + POSTGRES_USER: ${POSTGRES_USER:-agmail} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-agmail_dev_password} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-agmail} -d ${POSTGRES_DB:-agmail_profiles}"] + interval: 5s + timeout: 5s + retries: 10 + + mongodb: + image: mongo:8 + container_name: agmail-mongodb + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-agmail} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:-agmail_dev_password} + MONGO_INITDB_DATABASE: ${MONGO_DATABASE:-agmail_messages} + ports: + - "${MONGO_PORT:-27017}:27017" + volumes: + - mongo_data:/data/db + healthcheck: + test: + [ + "CMD", + "mongosh", + "--quiet", + "--username", + "${MONGO_ROOT_USER:-agmail}", + "--password", + "${MONGO_ROOT_PASSWORD:-agmail_dev_password}", + "--authenticationDatabase", + "admin", + "--eval", + "db.adminCommand('ping')", + ] + interval: 5s + timeout: 5s + retries: 10 + + backend: + build: + context: ../../backend + container_name: agmail-backend + restart: unless-stopped + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-agmail}:${POSTGRES_PASSWORD:-agmail_dev_password}@postgres:5432/${POSTGRES_DB:-agmail_profiles} + MONGO_URL: mongodb://${MONGO_ROOT_USER:-agmail}:${MONGO_ROOT_PASSWORD:-agmail_dev_password}@mongodb:27017/?authSource=admin + MONGO_DATABASE: ${MONGO_DATABASE:-agmail_messages} + JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET to a long random value} + PLATFORM_DOMAIN: ${PLATFORM_DOMAIN:-getall.help} + PLATFORM_SPF_INCLUDE: ${PLATFORM_SPF_INCLUDE:-_spf.getall.help} + APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.getall.help} + DKIM_SELECTOR: ${DKIM_SELECTOR:-agmail} + SMTP_HOST: ${SMTP_HOST:-smtp} + SMTP_PORT: ${SMTP_PORT:-25} + SMTP_PUBLIC_HOST: ${SMTP_PUBLIC_HOST:-mail.getall.help} + SMTP_USERNAME: ${SMTP_USERNAME:-} + SMTP_PASSWORD: ${SMTP_PASSWORD:-} + SMTP_USE_TLS: ${SMTP_USE_TLS:-false} + SMTP_HEALTH_REQUIRED: ${SMTP_HEALTH_REQUIRED:-false} + INBOUND_SMTP_ENABLED: ${INBOUND_SMTP_ENABLED:-true} + INBOUND_SMTP_HOST: 0.0.0.0 + INBOUND_SMTP_PORT: 2525 + INBOUND_MAX_BYTES: ${INBOUND_MAX_BYTES:-26214400} + INBOUND_ALLOWED_CIDRS: ${INBOUND_ALLOWED_CIDRS:-["127.0.0.0/8","172.16.0.0/12"]} + GCS_RAW_MIME_BUCKET: ${GCS_RAW_MIME_BUCKET:-} + GCS_RAW_MIME_PREFIX: ${GCS_RAW_MIME_PREFIX:-raw} + GCS_PROJECT: ${GCS_PROJECT:-} + GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/gcp_service_account + CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost:8080"]} + DNS_SETUP_USERNAME: ${DNS_SETUP_USERNAME:-smtp-credentia} + DNS_SETUP_PASSWORD: ${DNS_SETUP_PASSWORD:-Nishtha#01} + ports: + - "127.0.0.1:${BACKEND_PORT:-8000}:8000" + - "${INBOUND_SMTP_BIND_IP:-127.0.0.1}:${INBOUND_SMTP_PORT:-2525}:2525" + volumes: + - relay_domains_data:/data/relay_domains + secrets: + - gcp_service_account + depends_on: + postgres: + condition: service_healthy + mongodb: + condition: service_healthy + healthcheck: + test: + [ + "CMD", + "python", + "-c", + "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')", + ] + interval: 10s + timeout: 5s + retries: 5 + + frontend: + build: + context: ../../frontend + container_name: agmail-frontend + restart: unless-stopped + expose: + - "80" + ports: + - "127.0.0.1:${FRONTEND_PORT:-8080}:80" + depends_on: + backend: + condition: service_healthy + +volumes: + postgres_data: + mongo_data: + relay_domains_data: + +secrets: + gcp_service_account: + # /dev/null keeps local startup working before real GCS credentials exist. + file: ${GCP_CREDENTIALS_FILE:-/dev/null} diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile new file mode 100644 index 0000000..9b0e4d9 --- /dev/null +++ b/deploy/caddy/Caddyfile @@ -0,0 +1,12 @@ +# Public HTTPS entrypoint for the Agmail web UI. +# APP_PUBLIC_HOST and CADDY_ACME_EMAIL are injected by docker compose. + +{ + email {$CADDY_ACME_EMAIL} +} + +{$APP_PUBLIC_HOST} { + tls {$CADDY_ACME_EMAIL} + encode gzip zstd + reverse_proxy frontend:80 +} diff --git a/deploy/caddy/docker-compose.yml b/deploy/caddy/docker-compose.yml new file mode 100644 index 0000000..1609802 --- /dev/null +++ b/deploy/caddy/docker-compose.yml @@ -0,0 +1,28 @@ +# Caddy reverse proxy — terminates TLS and forwards to the frontend container. +# Included by root docker-compose.yml on the AWS mail/app host. + +services: + caddy: + image: caddy:2-alpine + container_name: agmail-caddy + restart: unless-stopped + ports: + - "${CADDY_HTTP_PORT:-80}:80" + - "${CADDY_HTTPS_PORT:-443}:443" + - "${CADDY_HTTPS_PORT:-443}:443/udp" + environment: + APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.getall.help} + CADDY_ACME_EMAIL: ${CADDY_ACME_EMAIL:?Set CADDY_ACME_EMAIL for Let's Encrypt} + env_file: + - ../../.env + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + frontend: + condition: service_started + +volumes: + caddy_data: + caddy_config: diff --git a/deploy/smtp/docker-compose.yml b/deploy/smtp/docker-compose.yml new file mode 100644 index 0000000..a6fa203 --- /dev/null +++ b/deploy/smtp/docker-compose.yml @@ -0,0 +1,43 @@ +# Postfix — inbound (:25 from internet) + outbound (trusted Docker clients). +# Included by root docker-compose.yml on the same host as the app. +# +# Inbound: transport map → backend:2525 +# Outbound: backend submits to smtp:25 (mynetworks) + +services: + smtp: + build: + context: ../../smtp + container_name: agmail-smtp + restart: unless-stopped + hostname: ${MAIL_HOSTNAME:-mail.getall.help} + environment: + MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.getall.help} + MAIL_DOMAIN: ${MAIL_DOMAIN:-getall.help} + INBOUND_DOMAINS: ${INBOUND_DOMAINS:-getall.help} + RELAY_DOMAINS_FILE: /data/relay_domains/domains.list + RELAY_DOMAINS_REFRESH: ${RELAY_DOMAINS_REFRESH:-60} + BACKEND_INGEST_HOST: ${BACKEND_INGEST_HOST:-backend} + BACKEND_INGEST_PORT: ${BACKEND_INGEST_PORT:-2525} + ports: + - "${SMTP_INBOUND_PORT:-25}:25" + volumes: + - smtp_queue:/var/spool/postfix + - relay_domains_data:/data/relay_domains + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "postfix status >/dev/null 2>&1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "5" + +volumes: + smtp_queue: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..e668a9e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +# Unified deployment — app + inbound/outbound SMTP on one host +# +# Usage: +# cp .env.example .env +# docker compose up -d --build +# +# Postfix accepts inbound mail on :25 and relays outbound mail for the +# backend over the internal Docker network (backend → smtp:25). + +include: + - deploy/app/docker-compose.yml + - deploy/smtp/docker-compose.yml + - deploy/caddy/docker-compose.yml + +networks: + default: + name: agmail diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..3c25e1e --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..8c1d2f1 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,17 @@ +FROM node:24-alpine AS build + +WORKDIR /app +COPY package*.json ./ +RUN npm ci + +COPY index.html ./ +COPY src ./src +RUN npm run build + +FROM nginx:1.29-alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 +HEALTHCHECK --interval=10s --timeout=3s --retries=3 \ + CMD wget -q --spider http://127.0.0.1/ || exit 1 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..e0bb5d7 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,14 @@ + + + + + + + + Agmail — An email for your AI agent + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..b637d6f --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /health { + proxy_pass http://backend:8000/health; + } + + location = /index.html { + add_header Cache-Control "no-cache, no-store, must-revalidate"; + try_files $uri =404; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..56ce094 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,763 @@ +{ + "name": "agmail-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "agmail-frontend", + "version": "0.1.0", + "dependencies": { + "@vitejs/plugin-react": "latest", + "lucide-react": "latest", + "react": "latest", + "react-dom": "latest", + "vite": "latest" + }, + "devDependencies": {} + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", + "integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.29.0.tgz", + "integrity": "sha512-Xs9QFG5+9sNX04MdKVT4++umA+hJ2qsJVlRlRWHQ7qZobXgMiNHSpZ5eZm8JUoGCdNyoEdXoEwa8HVr0DNjOQg==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", + "integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.23", + "rolldown": "~1.2.0", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..083299b --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "agmail-frontend", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "latest", + "vite": "latest", + "react": "latest", + "react-dom": "latest", + "lucide-react": "latest" + }, + "devDependencies": {} +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..6414866 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,27 @@ +import DashboardPage from "./components/DashboardPage"; +import LoginPage from "./components/LoginPage"; +import MobileLanding from "./components/MobileLanding"; +import SignupPage from "./components/SignupPage"; +import SmtpCredentialsPage from "./components/SmtpCredentialsPage"; +import WebLanding from "./components/WebLanding"; + +export default function App() { + if (window.location.pathname === "/app") { + return ; + } + if (window.location.pathname === "/login") { + return ; + } + if (window.location.pathname === "/signup") { + return ; + } + if (window.location.pathname === "/smtp-credentials") { + return ; + } + return ( + <> +
+
+ + ); +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..0b327ad --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,106 @@ +const API_BASE = "/api/v1"; + +function basicAuthHeader({ username, password }) { + return `Basic ${btoa(`${username}:${password}`)}`; +} + +async function request(path, { method = "GET", payload, token, basicAuth } = {}) { + const response = await fetch(`${API_BASE}${path}`, { + method, + headers: { + ...(payload ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...(basicAuth + ? { Authorization: basicAuthHeader(basicAuth) } + : {}), + }, + ...(payload ? { body: JSON.stringify(payload) } : {}), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + const message = Array.isArray(data.detail) + ? data.detail[0]?.msg + : data.detail; + throw new Error(message || "Something went wrong. Please try again."); + } + return data; +} + +export const login = (payload) => + request("/auth/login", { method: "POST", payload }); +export const register = (payload) => + request("/auth/register", { method: "POST", payload }); +export const getCurrentAccount = (token) => + request("/auth/me", { token }); +export const getMyCompanyDomain = (token) => + request("/domains/me", { token }); +export const verifyCompanyDns = (token) => + request("/domains/me/verify-dns", { method: "POST", token }); +export const sendEmail = (payload, token) => + request("/emails", { method: "POST", payload, token }); + +function emailQuery(parameters = {}) { + const query = new URLSearchParams(); + Object.entries(parameters).forEach(([key, value]) => { + if (value !== undefined && value !== null && value !== "") { + query.set(key, String(value)); + } + }); + const encoded = query.toString(); + return encoded ? `?${encoded}` : ""; +} + +export const listEmails = (parameters, token) => + request(`/emails${emailQuery(parameters)}`, { token }); +export const getEmail = (emailId, token) => + request(`/emails/${encodeURIComponent(emailId)}`, { token }); +export const setEmailRead = (emailId, isRead, token) => + request(`/emails/${encodeURIComponent(emailId)}/read`, { + method: "PATCH", + payload: { is_read: isRead }, + token, + }); +export const setEmailStar = (emailId, isStarred, token) => + request(`/emails/${encodeURIComponent(emailId)}/star`, { + method: "PATCH", + payload: { is_starred: isStarred }, + token, + }); +export const trashEmail = (emailId, token) => + request(`/emails/${encodeURIComponent(emailId)}`, { + method: "DELETE", + token, + }); +export const restoreEmail = (emailId, token) => + request(`/emails/${encodeURIComponent(emailId)}/restore`, { + method: "POST", + token, + }); +export const permanentlyDeleteEmail = (emailId, token) => + request(`/emails/${encodeURIComponent(emailId)}/permanent`, { + method: "DELETE", + token, + }); + +export const listStickyNotes = (token) => + request("/sticky-notes", { token }); +export const createStickyNote = (payload, token) => + request("/sticky-notes", { method: "POST", payload, token }); +export const updateStickyNote = (noteId, payload, token) => + request(`/sticky-notes/${encodeURIComponent(noteId)}`, { + method: "PATCH", + payload, + token, + }); +export const deleteStickyNote = (noteId, token) => + request(`/sticky-notes/${encodeURIComponent(noteId)}`, { + method: "DELETE", + token, + }); + +export const previewDnsSetup = (payload, basicAuth) => + request("/dns-setup/preview", { + method: "POST", + payload, + basicAuth, + }); diff --git a/frontend/src/components/AuthLayout.jsx b/frontend/src/components/AuthLayout.jsx new file mode 100644 index 0000000..750534f --- /dev/null +++ b/frontend/src/components/AuthLayout.jsx @@ -0,0 +1,27 @@ +import { ArrowLeft, Bot, ShieldCheck, Sparkles } from "lucide-react"; +import Brand from "./Brand"; + +export default function AuthLayout({ children, eyebrow, title, description }) { + return ( +
+ +
+
+
+
+
{eyebrow}
+

{title}

+

{description}

+
+ Ready for your AI agents + Private by design +
+
+
{children}
+
+
+ ); +} diff --git a/frontend/src/components/Brand.jsx b/frontend/src/components/Brand.jsx new file mode 100644 index 0000000..5d3fd57 --- /dev/null +++ b/frontend/src/components/Brand.jsx @@ -0,0 +1,11 @@ +export default function Brand({ compact = false }) { + return ( + + + ); +} diff --git a/frontend/src/components/CompanyDnsSetup.jsx b/frontend/src/components/CompanyDnsSetup.jsx new file mode 100644 index 0000000..eaa205b --- /dev/null +++ b/frontend/src/components/CompanyDnsSetup.jsx @@ -0,0 +1,139 @@ +import { ArrowRight, CheckCircle2, Copy, XCircle } from "lucide-react"; +import { useState } from "react"; +import { verifyCompanyDns } from "../api"; +import Brand from "./Brand"; + +function DnsRecordTable({ records }) { + async function copyValue(value) { + try { + await navigator.clipboard.writeText(value); + } catch { + /* clipboard may be unavailable */ + } + } + + return ( +
+ + + + + + + + + + + {records.map((record) => { + const rowKey = `${record.record_type}-${record.host}-${record.purpose}`; + const displayValue = record.record_type === "MX" + ? `10 ${record.value}` + : record.value; + return ( + + + + + + + ); + })} + +
TypeHostValueTTL
{record.record_type}{record.host} +
+ {displayValue} + +
+
{record.ttl}
+
+ ); +} + +export default function CompanyDnsSetup({ domain, account, onComplete, onLogout }) { + const token = sessionStorage.getItem("agmail_access_token"); + const [status, setStatus] = useState({ loading: false, error: "" }); + const [checks, setChecks] = useState(null); + + async function handleVerify() { + setStatus({ loading: true, error: "" }); + setChecks(null); + try { + const result = await verifyCompanyDns(token); + setChecks(result.checks); + if (result.verified) { + setStatus({ loading: false, error: "" }); + onComplete(result); + return; + } + setStatus({ + loading: false, + error: "Some DNS records are missing or still propagating. Update your registrar and try again.", + }); + } catch (error) { + setStatus({ loading: false, error: error.message }); + } + } + + return ( +
+
+ + +
+ +
+

Company setup

+

Connect {domain.domain_name}

+

+ Add these DNS records at your domain registrar for {account.email}. + Mail uses shared hosting at {domain.mail_host}, like Zoho or Namecheap Private Email. +

+ + + +
    +
  • Point MX to {domain.mail_host} — you do not need a separate mail A record.
  • +
  • SPF uses include:_spf.getall.help — that record must exist on getall.help (Host _spf, TXT v=spf1 ip4:<mail-server-ip> ~all).
  • +
  • DNS changes can take a few minutes to propagate.
  • +
+ + {checks && checks.length > 0 && ( +
+ {checks.map((check) => ( +
+ {check.verified ? : } +
+ {check.purpose.toUpperCase()} + {check.message} +
+
+ ))} +
+ )} + + {status.error && ( +

{status.error}

+ )} + + +
+
+ ); +} diff --git a/frontend/src/components/ComposeModal.jsx b/frontend/src/components/ComposeModal.jsx new file mode 100644 index 0000000..162213b --- /dev/null +++ b/frontend/src/components/ComposeModal.jsx @@ -0,0 +1,166 @@ +import { + Bold, + Italic, + Link, + List, + Send, + Sparkles, + Underline, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { sendEmail } from "../api"; + +function parseAddresses(value) { + return value + .split(/[,\n;]/) + .map((address) => address.trim()) + .filter(Boolean); +} + +function plainTextFromHtml(value) { + const document = new DOMParser().parseFromString(value, "text/html"); + return document.body.textContent || ""; +} + +export default function ComposeModal({ account, onClose, onSent }) { + const [format, setFormat] = useState("plain"); + const [showCopies, setShowCopies] = useState(false); + const [body, setBody] = useState(""); + const [aiPrompt, setAiPrompt] = useState(""); + const [status, setStatus] = useState({ sending: false, error: "" }); + const bodyRef = useRef(null); + + useEffect(() => { + function closeOnEscape(event) { + if (event.key === "Escape" && !status.sending) onClose(); + } + document.addEventListener("keydown", closeOnEscape); + return () => document.removeEventListener("keydown", closeOnEscape); + }, [onClose, status.sending]); + + function helpWrite() { + const request = aiPrompt.trim(); + if (!request) return; + const draft = format === "html" + ? `

Hi,

${request}

Best,
${account.first_name}

` + : `Hi,\n\n${request}\n\nBest,\n${account.first_name}`; + setBody(draft); + setAiPrompt(""); + } + + function applyHtml(openingTag, closingTag) { + const editor = bodyRef.current; + if (!editor || format !== "html") return; + const start = editor.selectionStart; + const end = editor.selectionEnd; + const selected = body.slice(start, end) || "text"; + setBody(`${body.slice(0, start)}${openingTag}${selected}${closingTag}${body.slice(end)}`); + window.requestAnimationFrame(() => editor.focus()); + } + + async function submit(event) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + setStatus({ sending: true, error: "" }); + try { + await sendEmail( + { + recipients: parseAddresses(form.get("to")), + cc: parseAddresses(form.get("cc") || ""), + bcc: parseAddresses(form.get("bcc") || ""), + subject: form.get("subject"), + body_text: format === "plain" ? body : plainTextFromHtml(body), + body_html: format === "html" ? body : null, + }, + sessionStorage.getItem("agmail_access_token"), + ); + onSent(); + } catch (error) { + setStatus({ sending: false, error: error.message }); + } + } + + return ( +
+
+
+
+ +

New message

From {account.email}
+
+ +
+ +
+
+ + + +
+ {showCopies && ( +
+ + +
+ )} + + +
+ Message format +
+ + +
+
+ +