first commit

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

11
.gitignore vendored Normal file
View file

@ -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

244
DEPLOYMENT.md Normal file
View file

@ -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=<openssl rand -hex 32>
POSTGRES_PASSWORD="<strong password>"
MONGO_ROOT_PASSWORD="<strong password>"
GCS_RAW_MIME_BUCKET=<private bucket>
GCS_PROJECT=<gcp project id>
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 (`<VPS_IP>` = 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` | `<VPS_IP>` | Web UI |
| **A** | `mail` | `<VPS_IP>` | Postfix |
| **MX** | `@` | `10 mail.getall.help` | Priority 10 |
### Outbound authentication
| Type | Host | Value | Notes |
|------|------|-------|-------|
| **TXT** | `@` | `v=spf1 mx a ip4:<VPS_IP> ~all` | SPF for `@getall.help` mail |
| **TXT** | `_spf` | `v=spf1 ip4:<VPS_IP> ~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)

5
backend/.dockerignore Normal file
View file

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

15
backend/Dockerfile Normal file
View file

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

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

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

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

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

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

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

View file

View file

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

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

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

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

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

View file

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

212
backend/app/routers/auth.py Normal file
View file

@ -0,0 +1,212 @@
import re
from datetime import date, datetime, timedelta, timezone
from uuid import UUID
import jwt
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pwdlib import PasswordHash
from pydantic import EmailStr, TypeAdapter, ValidationError
from sqlalchemy import or_, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_postgres_session
from app.models import Account
from app.schemas import AccountRegister, AccountRead, AuthResponse, LoginRequest
from app.services.domain_provisioning import (
get_domain_by_name,
provision_mail_domain,
)
router = APIRouter(prefix="/auth", tags=["authentication"])
password_hash = PasswordHash.recommended()
dummy_password_hash = password_hash.hash("Agmail timing protection value")
bearer_scheme = HTTPBearer(auto_error=False)
settings = get_settings()
email_adapter = TypeAdapter(EmailStr)
domain_pattern = re.compile(
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
r"[a-z]{2,63}$"
)
def normalize_phone(value: str) -> str:
prefix = "+" if value.strip().startswith("+") else ""
return prefix + "".join(character for character in value if character.isdigit())
def validate_password(value: str) -> None:
if not (
re.search(r"[a-z]", value)
and re.search(r"[A-Z]", value)
and re.search(r"\d", value)
):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Password must include uppercase, lowercase, and a number.",
)
def build_email(payload: AccountRegister) -> tuple[str, str | None]:
domain = "getall.help"
company_domain = None
if payload.account_type == "company":
if not payload.company_domain:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Company domain is required for company accounts.",
)
domain = payload.company_domain.strip().lower().removeprefix("@")
if not domain_pattern.fullmatch(domain):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Enter a valid company domain, such as example.com.",
)
company_domain = domain
email = f"{payload.email_name.lower()}@{domain}"
try:
return str(email_adapter.validate_python(email)), company_domain
except ValidationError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="The resulting email address is invalid.",
) from error
def create_access_token(account: Account) -> str:
expires_at = datetime.now(timezone.utc) + timedelta(
minutes=settings.access_token_minutes
)
return jwt.encode(
{"sub": str(account.id), "email": account.email, "exp": expires_at},
settings.jwt_secret,
algorithm="HS256",
)
async def get_current_account(
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
session: AsyncSession = Depends(get_postgres_session),
) -> Account:
unauthorized = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Your session is invalid or has expired.",
headers={"WWW-Authenticate": "Bearer"},
)
if credentials is None or credentials.scheme.lower() != "bearer":
raise unauthorized
try:
claims = jwt.decode(
credentials.credentials,
settings.jwt_secret,
algorithms=["HS256"],
options={"require": ["sub", "exp"]},
)
account_id = UUID(claims["sub"])
except (jwt.PyJWTError, KeyError, TypeError, ValueError):
raise unauthorized
account = await session.get(Account, account_id)
if account is None:
raise unauthorized
return account
@router.post(
"/register",
response_model=AuthResponse,
status_code=status.HTTP_201_CREATED,
)
async def register(
payload: AccountRegister,
session: AsyncSession = Depends(get_postgres_session),
) -> AuthResponse:
if payload.date_of_birth >= date.today():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Date of birth must be in the past.",
)
validate_password(payload.password)
email, company_domain = build_email(payload)
phone_number = normalize_phone(payload.phone_number)
existing = await session.scalar(
select(Account).where(
or_(Account.email == email, Account.phone_number == phone_number)
)
)
if existing:
detail = (
"That email address is already registered."
if existing.email == email
else "That phone number is already registered."
)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
account = Account(
account_type=payload.account_type,
email=email,
first_name=payload.first_name.strip(),
last_name=payload.last_name.strip(),
date_of_birth=payload.date_of_birth,
phone_number=phone_number,
company_domain=company_domain,
password_hash=password_hash.hash(payload.password),
)
session.add(account)
try:
await session.flush()
if company_domain:
existing_domain = await get_domain_by_name(session, company_domain)
if existing_domain is None:
await provision_mail_domain(
session,
domain_name=company_domain,
domain_type="company",
account_id=account.id,
)
await session.commit()
except IntegrityError as error:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="An account with those details already exists.",
) from error
await session.refresh(account)
return AuthResponse(
access_token=create_access_token(account),
account=AccountRead.model_validate(account),
)
@router.post("/login", response_model=AuthResponse)
async def login(
payload: LoginRequest,
session: AsyncSession = Depends(get_postgres_session),
) -> AuthResponse:
account = await session.scalar(
select(Account).where(Account.email == str(payload.email).lower())
)
password_is_valid = password_hash.verify(
payload.password,
account.password_hash if account is not None else dummy_password_hash,
)
if account is None or not password_is_valid:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password.",
)
return AuthResponse(
access_token=create_access_token(account),
account=AccountRead.model_validate(account),
)
@router.get("/me", response_model=AccountRead)
async def get_me(account: Account = Depends(get_current_account)) -> Account:
return account

View file

@ -0,0 +1,81 @@
import ipaddress
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.database import get_postgres_session
from app.deps.dns_setup_auth import require_dns_setup_auth
from app.schemas import DnsSetupRequest, DnsSetupResponse
from app.services.domain_provisioning import (
build_deployment_dns_records,
get_domain_by_name,
validate_domain,
)
router = APIRouter(prefix="/dns-setup", tags=["dns-setup"])
def validate_mail_server_ip(value: str) -> str:
candidate = value.strip()
try:
return str(ipaddress.ip_address(candidate))
except ValueError as error:
raise ValueError("Enter a valid IPv4 or IPv6 address.") from error
@router.post("/preview", response_model=DnsSetupResponse)
async def preview_dns_setup(
payload: DnsSetupRequest,
_: None = Depends(require_dns_setup_auth),
session: AsyncSession = Depends(get_postgres_session),
) -> DnsSetupResponse:
settings = get_settings()
try:
domain_name = validate_domain(payload.domain)
mail_server_ip = validate_mail_server_ip(payload.mail_server_ip)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error
domain = await get_domain_by_name(session, domain_name)
sections = build_deployment_dns_records(
domain_name=domain_name,
mail_server_ip=mail_server_ip,
dkim_selector=settings.dkim_selector,
dkim_public_key=domain.dkim_public_key if domain else None,
app_public_host=settings.app_public_host,
)
mail_host = f"mail.{domain_name}"
return DnsSetupResponse(
domain=domain_name,
mail_host=mail_host,
app_public_host=settings.app_public_host,
mail_server_ip=mail_server_ip,
is_platform_domain=domain_name == settings.platform_domain,
sections=sections,
notes=_build_notes(
domain_name,
settings.app_public_host,
mail_server_ip,
),
)
def _build_notes(
domain_name: str,
app_public_host: str,
mail_server_ip: str,
) -> list[str]:
return [
"Add these records in Namecheap Advanced DNS for the zone.",
f"Point mail.{domain_name} and {app_public_host} A records to {mail_server_ip}.",
f"Keep the root {domain_name} A record on your main website server if it hosts the marketing site.",
f"Set {domain_name} MX to mail.{domain_name} (priority 10) so inbound mail reaches Postfix on this host.",
"Postfix accepts inbound mail on :25 and delivers outbound mail for the app over the Docker network.",
f"Users open the web UI at https://{app_public_host} (Caddy terminates TLS).",
f"Ask your VPS provider to set reverse DNS (PTR) for {mail_server_ip} → mail.{domain_name}.",
"SPF, DKIM, and DMARC authenticate outbound mail signed by Agmail (selector agmail).",
]

View file

@ -0,0 +1,249 @@
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.config import get_settings
from app.database import get_postgres_session
from app.models import Account, MailDomain
from app.routers.auth import get_current_account
from app.schemas import (
DnsVerificationResponse,
DomainCreate,
MailDomainRead,
SmtpCredentialCreate,
SmtpCredentialCreated,
SmtpCredentialRead,
)
from app.services.dns_verification import verify_company_dns
from app.services.domain_provisioning import (
activate_company_domain,
create_smtp_credential,
ensure_platform_domain,
get_account_company_domain,
provision_mail_domain,
validate_domain,
)
router = APIRouter(prefix="/domains", tags=["domains"])
async def get_domain_or_404(
domain_id: UUID,
session: AsyncSession,
) -> MailDomain:
domain = await session.scalar(
select(MailDomain)
.options(
selectinload(MailDomain.dns_records),
selectinload(MailDomain.smtp_credentials),
)
.where(MailDomain.id == domain_id)
)
if domain is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Domain not found.",
)
return domain
@router.get("/me", response_model=MailDomainRead)
async def get_my_company_domain(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> MailDomain:
if account.account_type != "company" or not account.company_domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No company domain is linked to this account.",
)
domain = await get_account_company_domain(
session,
account.id,
account.company_domain,
)
if domain is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company domain has not been provisioned yet.",
)
return domain
@router.post("/me/verify-dns", response_model=DnsVerificationResponse)
async def verify_my_company_dns(
account: Account = Depends(get_current_account),
session: AsyncSession = Depends(get_postgres_session),
) -> DnsVerificationResponse:
settings = get_settings()
if account.account_type != "company" or not account.company_domain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No company domain is linked to this account.",
)
domain = await get_account_company_domain(
session,
account.id,
account.company_domain,
)
if domain is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Company domain has not been provisioned yet.",
)
if domain.status == "active":
return DnsVerificationResponse(
domain=domain.domain_name,
status=domain.status,
verified=True,
checks=[],
)
checks = verify_company_dns(
domain_name=domain.domain_name,
shared_mail_host=settings.smtp_public_host,
dkim_selector=domain.dkim_selector,
dkim_public_key=domain.dkim_public_key,
platform_spf_include=settings.platform_spf_include,
)
verified = all(check["verified"] for check in checks)
if verified:
await activate_company_domain(session, domain)
await session.commit()
return DnsVerificationResponse(
domain=domain.domain_name,
status=domain.status if not verified else "active",
verified=verified,
checks=checks,
)
@router.get("/platform", response_model=MailDomainRead)
async def get_platform_domain(
session: AsyncSession = Depends(get_postgres_session),
) -> MailDomain:
domain = await ensure_platform_domain(session)
await session.commit()
return await get_domain_or_404(domain.id, session)
@router.get("", response_model=list[MailDomainRead])
async def list_domains(
session: AsyncSession = Depends(get_postgres_session),
) -> list[MailDomain]:
await ensure_platform_domain(session)
await session.commit()
result = await session.scalars(
select(MailDomain)
.options(selectinload(MailDomain.dns_records))
.order_by(MailDomain.created_at.desc())
)
return list(result.all())
@router.post("", response_model=MailDomainRead, status_code=status.HTTP_201_CREATED)
async def register_company_domain(
payload: DomainCreate,
session: AsyncSession = Depends(get_postgres_session),
) -> MailDomain:
settings = get_settings()
try:
normalized = validate_domain(payload.domain_name)
except ValueError as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(error),
) from error
if normalized == settings.platform_domain:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Use the platform domain endpoint for getall.help.",
)
existing = await session.scalar(
select(MailDomain).where(MailDomain.domain_name == normalized)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="That domain is already registered.",
)
domain = await provision_mail_domain(
session,
domain_name=normalized,
domain_type="company",
account_id=payload.account_id,
)
await session.commit()
return await get_domain_or_404(domain.id, session)
@router.get("/{domain_id}", response_model=MailDomainRead)
async def get_domain(
domain_id: UUID,
session: AsyncSession = Depends(get_postgres_session),
) -> MailDomain:
return await get_domain_or_404(domain_id, session)
@router.get("/{domain_id}/dns-records", response_model=list)
async def list_dns_records(
domain_id: UUID,
session: AsyncSession = Depends(get_postgres_session),
):
domain = await get_domain_or_404(domain_id, session)
return [
{
"type": record.record_type,
"host": record.host,
"value": record.value,
"purpose": record.purpose,
"ttl": record.ttl,
}
for record in domain.dns_records
]
@router.get("/{domain_id}/smtp-credentials", response_model=list[SmtpCredentialRead])
async def list_smtp_credentials(
domain_id: UUID,
session: AsyncSession = Depends(get_postgres_session),
):
domain = await get_domain_or_404(domain_id, session)
return domain.smtp_credentials
@router.post(
"/{domain_id}/smtp-credentials",
response_model=SmtpCredentialCreated,
status_code=status.HTTP_201_CREATED,
)
async def create_domain_smtp_credential(
domain_id: UUID,
payload: SmtpCredentialCreate,
session: AsyncSession = Depends(get_postgres_session),
):
settings = get_settings()
domain = await get_domain_or_404(domain_id, session)
credential, plain_password = await create_smtp_credential(
session,
domain,
label=payload.label,
)
await session.commit()
return SmtpCredentialCreated(
id=credential.id,
username=credential.username,
label=credential.label,
is_active=credential.is_active,
created_at=credential.created_at,
password=plain_password,
smtp_host=settings.smtp_public_host,
smtp_port=settings.smtp_port,
use_tls=settings.smtp_use_tls,
)

View file

@ -0,0 +1,464 @@
import base64
import json
import re
from datetime import date, datetime, time, timedelta, timezone
from typing import Annotated
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_mongo_database, get_postgres_session
from app.models import Account
from app.routers.auth import get_current_account
from app.schemas import (
EmailCreate,
EmailRead,
MailAddress,
MessagePage,
MessageRead,
MessageReadState,
MessageStarState,
MessageSummary,
utc_now,
)
from app.services.domain_provisioning import get_domain_by_name
from app.services.mail_sender import deliver_email
from app.services.mime_parser import parse_mime
from app.services.raw_mime_storage import get_raw_mime_storage
router = APIRouter(prefix="/emails", tags=["emails"])
def serialize_email(document: dict, viewer_email: str) -> EmailRead:
return EmailRead(
id=str(document["_id"]),
sender=document["sender"],
recipients=document["recipients"],
cc=document.get("cc", []),
bcc=document.get("bcc", []) if document["sender"] == viewer_email else [],
subject=document["subject"],
body_text=document["body_text"],
body_html=document.get("body_html"),
status=document["status"],
created_at=document["created_at"],
)
def sender_domain(address: str) -> str:
return address.split("@", 1)[1].lower()
def address(value: str, name: str | None = None) -> dict[str, str]:
return {"name": name or value, "address": value.lower()}
def encode_cursor(entry: dict) -> str:
value = json.dumps(
{"created_at": entry["created_at"].isoformat(), "id": str(entry["_id"])},
separators=(",", ":"),
)
return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
def decode_cursor(value: str) -> tuple[datetime, ObjectId]:
try:
padded = value + "=" * (-len(value) % 4)
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
return datetime.fromisoformat(payload["created_at"]), ObjectId(payload["id"])
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid cursor.",
) from error
def summarize(message: dict, entry: dict) -> MessageSummary:
sender = message.get("sender", {"name": "Unknown sender", "address": ""})
recipients = message.get("to") or [
address(value) for value in message.get("recipients", [])
]
mailbox = "trash" if entry.get("trashed_at") else entry["mailbox"]
return MessageSummary(
id=str(message["_id"]),
mailbox=mailbox,
direction=message.get("direction", "outbound"),
sender=MailAddress(**sender),
recipients=[MailAddress(**item) for item in recipients],
subject=message.get("subject", ""),
snippet=message.get("snippet", message.get("body_text", "")[:240]),
status=message.get("status", "received"),
created_at=message.get("received_at") or message["created_at"],
is_read=entry.get("is_read", True),
is_starred=entry.get("is_starred", False),
trashed_at=entry.get("trashed_at"),
attachments=message.get("attachments", []),
)
async def legacy_email(database, email_id: ObjectId, viewer_email: str) -> dict | None:
document = await database.emails.find_one({"_id": email_id})
if document is None:
return None
visible_to = {
document["sender"],
*document.get("recipients", []),
*document.get("cc", []),
*document.get("bcc", []),
}
return document if viewer_email in visible_to else None
@router.post("", response_model=EmailRead, status_code=status.HTTP_201_CREATED)
async def create_email(
payload: EmailCreate,
database=Depends(get_mongo_database),
session: AsyncSession = Depends(get_postgres_session),
account: Account = Depends(get_current_account),
):
sender = account.email.lower()
recipients = [str(recipient).lower() for recipient in payload.recipients]
cc = [str(recipient).lower() for recipient in payload.cc]
bcc = [str(recipient).lower() for recipient in payload.bcc]
all_recipients = [*recipients, *cc, *bcc]
if len(set(all_recipients)) != len(all_recipients):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Each recipient may only appear once across To, Cc, and Bcc.",
)
domain = await get_domain_by_name(session, sender_domain(sender))
if domain is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Sender domain is not registered with agmail.",
)
if domain.domain_type == "company" and domain.status != "active":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Complete DNS setup for your company domain before sending mail.",
)
created_at = utc_now()
document = {
"direction": "outbound",
"account_id": str(account.id),
"sender": address(
sender,
" ".join(part for part in [account.first_name, account.last_name] if part),
),
"to": [address(value) for value in recipients],
"cc": [address(value) for value in cc],
"bcc": [address(value) for value in bcc],
"subject": payload.subject,
"snippet": re.sub(r"\s+", " ", payload.body_text).strip()[:240],
"body_text": payload.body_text,
"body_html": payload.body_html,
"attachments": [],
"created_at": created_at,
"status": "queued",
}
try:
await deliver_email(
sender=sender,
recipients=recipients,
cc=cc,
bcc=bcc,
subject=document["subject"],
body_text=document["body_text"],
body_html=document.get("body_html"),
domain=domain,
)
document["status"] = "accepted"
except Exception as error:
document["status"] = "failed"
document["error"] = str(error)
result = await database.messages.insert_one(document)
document["_id"] = result.inserted_id
await database.mailbox_entries.insert_one(
{
"account_id": str(account.id),
"message_id": result.inserted_id,
"mailbox": "sent",
"direction": "outbound",
"sender": document["sender"],
"recipients": document["to"],
"subject": document["subject"],
"snippet": document["snippet"],
"attachments": [],
"status": document["status"],
"is_read": True,
"is_starred": False,
"event_at": created_at,
"created_at": created_at,
"trashed_at": None,
"deletion_state": None,
}
)
if document["status"] == "failed":
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=document.get("error", "Unable to deliver email."),
)
return EmailRead(
id=str(document["_id"]),
sender=sender,
recipients=recipients,
cc=cc,
bcc=bcc,
subject=payload.subject,
body_text=payload.body_text,
body_html=payload.body_html,
status=document["status"],
created_at=created_at,
)
@router.get("/{email_id}", response_model=MessageRead | EmailRead)
async def get_email(
email_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if not ObjectId.is_valid(email_id):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid email ID.",
)
message_id = ObjectId(email_id)
viewer_email = account.email.lower()
entry = await database.mailbox_entries.find_one(
{"account_id": str(account.id), "message_id": message_id}
)
document = (
await database.messages.find_one({"_id": message_id})
if entry is not None
else None
)
if document is None or entry is None:
legacy = await legacy_email(database, message_id, viewer_email)
if legacy is not None:
return serialize_email(legacy, viewer_email)
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Email not found.",
)
body_text = document.get("body_text", "")
body_html = document.get("body_html")
if document.get("raw_mime"):
raw = await get_raw_mime_storage().get(document["raw_mime"])
parsed = parse_mime(raw)
body_text = parsed["body_text"]
body_html = parsed["body_html"]
summary = summarize(document, entry)
return MessageRead(
**summary.model_dump(),
cc=document.get("cc", []),
bcc=document.get("bcc", []) if document.get("account_id") == str(account.id) else [],
reply_to=document.get("reply_to", []),
body_text=body_text,
body_html=body_html,
message_id=document.get("message_id"),
)
@router.get("", response_model=MessagePage)
async def list_emails(
mailbox: Annotated[str, Query(pattern="^(inbox|sent|trash)$")] = "inbox",
limit: Annotated[int, Query(ge=1, le=100)] = 25,
cursor: str | None = None,
q: Annotated[str | None, Query(max_length=200)] = None,
is_read: bool | None = None,
sender: Annotated[str | None, Query(max_length=320)] = None,
date_from: date | None = None,
date_to: date | None = None,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
entry_match: dict = {
"account_id": str(account.id),
"deletion_state": {"$in": [None, ""]},
}
if mailbox == "trash":
entry_match["trashed_at"] = {"$ne": None}
else:
entry_match.update({"mailbox": mailbox, "trashed_at": None})
if is_read is not None:
entry_match["is_read"] = is_read
if sender:
pattern = f"^{re.escape(sender.strip())}"
entry_match["$or"] = [
{"sender.name": {"$regex": pattern, "$options": "i"}},
{"sender.address": {"$regex": pattern, "$options": "i"}},
]
if date_from or date_to:
event_range: dict = {}
if date_from:
event_range["$gte"] = datetime.combine(
date_from,
time.min,
tzinfo=timezone.utc,
)
if date_to:
event_range["$lt"] = datetime.combine(
date_to + timedelta(days=1),
time.min,
tzinfo=timezone.utc,
)
entry_match["event_at"] = event_range
if q and q.strip():
entry_match["$text"] = {"$search": q.strip()}
if cursor:
created_at, entry_id = decode_cursor(cursor)
cursor_clause = [
{"created_at": {"$lt": created_at}},
{"created_at": created_at, "_id": {"$lt": entry_id}},
]
if "$or" in entry_match:
existing_or = entry_match.pop("$or")
entry_match["$and"] = [{"$or": existing_or}, {"$or": cursor_clause}]
else:
entry_match["$or"] = cursor_clause
pipeline: list[dict] = [
{"$match": entry_match},
{"$sort": {"created_at": -1, "_id": -1}},
{"$limit": limit + 1},
{
"$lookup": {
"from": "messages",
"localField": "message_id",
"foreignField": "_id",
"as": "message",
}
},
{"$unwind": "$message"},
]
cursor = await database.mailbox_entries.aggregate(pipeline)
rows = [row async for row in cursor]
has_more = len(rows) > limit
rows = rows[:limit]
return MessagePage(
items=[summarize(row["message"], row) for row in rows],
next_cursor=encode_cursor(rows[-1]) if has_more and rows else None,
)
async def require_entry(database, account: Account, email_id: str) -> tuple[ObjectId, dict]:
if not ObjectId.is_valid(email_id):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid email ID.",
)
message_id = ObjectId(email_id)
entry = await database.mailbox_entries.find_one(
{"account_id": str(account.id), "message_id": message_id}
)
if entry is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email not found.")
return message_id, entry
@router.patch("/{email_id}/read", status_code=status.HTTP_204_NO_CONTENT)
async def set_email_read(
email_id: str,
payload: MessageReadState,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
message_id, _ = await require_entry(database, account, email_id)
await database.mailbox_entries.update_one(
{"account_id": str(account.id), "message_id": message_id},
{"$set": {"is_read": payload.is_read}},
)
@router.patch("/{email_id}/star", status_code=status.HTTP_204_NO_CONTENT)
async def set_email_star(
email_id: str,
payload: MessageStarState,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
message_id, _ = await require_entry(database, account, email_id)
await database.mailbox_entries.update_one(
{"account_id": str(account.id), "message_id": message_id},
{"$set": {"is_starred": payload.is_starred}},
)
async def move_to_trash(database, account: Account, email_id: str) -> None:
message_id, _ = await require_entry(database, account, email_id)
await database.mailbox_entries.update_one(
{"account_id": str(account.id), "message_id": message_id},
{"$set": {"trashed_at": utc_now()}},
)
@router.post("/{email_id}/trash", status_code=status.HTTP_204_NO_CONTENT)
async def trash_email(
email_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
await move_to_trash(database, account, email_id)
@router.delete("/{email_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_email(
email_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
await move_to_trash(database, account, email_id)
@router.post("/{email_id}/restore", status_code=status.HTTP_204_NO_CONTENT)
async def restore_email(
email_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
message_id, _ = await require_entry(database, account, email_id)
await database.mailbox_entries.update_one(
{"account_id": str(account.id), "message_id": message_id},
{"$set": {"trashed_at": None}},
)
@router.delete("/{email_id}/permanent", status_code=status.HTTP_204_NO_CONTENT)
async def permanently_delete_email(
email_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
message_id, entry = await require_entry(database, account, email_id)
if entry.get("trashed_at") is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email must be in trash before permanent deletion.",
)
message = await database.messages.find_one({"_id": message_id})
other_entries = await database.mailbox_entries.count_documents(
{"message_id": message_id, "_id": {"$ne": entry["_id"]}}
)
await database.mailbox_entries.update_one(
{"_id": entry["_id"]},
{"$set": {"deletion_state": "deleting"}},
)
if other_entries == 0 and message and message.get("raw_mime"):
await get_raw_mime_storage().delete(message["raw_mime"])
await database.mailbox_entries.delete_one({"_id": entry["_id"]})
if other_entries == 0:
if message and message.get("delivery_key"):
await database.message_tombstones.update_one(
{"delivery_key": message["delivery_key"]},
{
"$setOnInsert": {
"delivery_key": message["delivery_key"],
"deleted_at": utc_now(),
}
},
upsert=True,
)
await database.messages.delete_one({"_id": message_id})

View file

@ -0,0 +1,109 @@
from bson import ObjectId
from fastapi import APIRouter, Depends, HTTPException, status
from pymongo import ReturnDocument
from app.database import get_mongo_database
from app.models import Account
from app.routers.auth import get_current_account
from app.schemas import StickyNoteCreate, StickyNoteRead, StickyNoteUpdate, utc_now
router = APIRouter(prefix="/sticky-notes", tags=["sticky-notes"])
def serialize_note(document: dict) -> StickyNoteRead:
return StickyNoteRead(
id=str(document["_id"]),
title=document["title"],
body=document.get("body", ""),
tags=document.get("tags", []),
color=document.get("color", "yellow"),
created_at=document["created_at"],
updated_at=document["updated_at"],
)
@router.get("", response_model=list[StickyNoteRead])
async def list_sticky_notes(
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
cursor = database.sticky_notes.find({"account_id": str(account.id)}).sort(
"updated_at", -1
)
return [serialize_note(document) async for document in cursor]
@router.post("", response_model=StickyNoteRead, status_code=status.HTTP_201_CREATED)
async def create_sticky_note(
payload: StickyNoteCreate,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
now = utc_now()
document = {
"account_id": str(account.id),
"title": payload.title.strip(),
"body": payload.body.strip(),
"tags": [tag.strip() for tag in payload.tags if tag.strip()],
"color": payload.color,
"created_at": now,
"updated_at": now,
}
result = await database.sticky_notes.insert_one(document)
document["_id"] = result.inserted_id
return serialize_note(document)
@router.patch("/{note_id}", response_model=StickyNoteRead)
async def update_sticky_note(
note_id: str,
payload: StickyNoteUpdate,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if not ObjectId.is_valid(note_id):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid note ID.",
)
updates = payload.model_dump(exclude_unset=True)
if "title" in updates:
updates["title"] = updates["title"].strip()
if "body" in updates:
updates["body"] = updates["body"].strip()
if "tags" in updates and updates["tags"] is not None:
updates["tags"] = [tag.strip() for tag in updates["tags"] if tag.strip()]
if not updates:
document = await database.sticky_notes.find_one(
{"_id": ObjectId(note_id), "account_id": str(account.id)}
)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")
return serialize_note(document)
updates["updated_at"] = utc_now()
document = await database.sticky_notes.find_one_and_update(
{"_id": ObjectId(note_id), "account_id": str(account.id)},
{"$set": updates},
return_document=ReturnDocument.AFTER,
)
if document is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")
return serialize_note(document)
@router.delete("/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_sticky_note(
note_id: str,
database=Depends(get_mongo_database),
account: Account = Depends(get_current_account),
):
if not ObjectId.is_valid(note_id):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Invalid note ID.",
)
result = await database.sticky_notes.delete_one(
{"_id": ObjectId(note_id), "account_id": str(account.id)}
)
if result.deleted_count == 0:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

19
backend/requirements.txt Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"
}

View file

@ -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}

12
deploy/caddy/Caddyfile Normal file
View file

@ -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
}

View file

@ -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:

View file

@ -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:

17
docker-compose.yml Normal file
View file

@ -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

3
frontend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
*.log

17
frontend/Dockerfile Normal file
View file

@ -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

14
frontend/index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#060622" />
<meta name="description" content="Agmail is the focused inbox built for people and their AI agents." />
<title>Agmail — An email for your AI agent</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

27
frontend/nginx.conf Normal file
View file

@ -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;
}
}

763
frontend/package-lock.json generated Normal file
View file

@ -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
}
}
}
}
}

19
frontend/package.json Normal file
View file

@ -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": {}
}

27
frontend/src/App.jsx Normal file
View file

@ -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 <DashboardPage />;
}
if (window.location.pathname === "/login") {
return <LoginPage />;
}
if (window.location.pathname === "/signup") {
return <SignupPage />;
}
if (window.location.pathname === "/smtp-credentials") {
return <SmtpCredentialsPage />;
}
return (
<>
<div className="web-view"><WebLanding /></div>
<div className="mobile-view"><MobileLanding /></div>
</>
);
}

106
frontend/src/api.js Normal file
View file

@ -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,
});

View file

@ -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 (
<main className="auth-page">
<nav className="auth-nav">
<Brand />
<a className="back-link" href="/"><ArrowLeft size={15} /> Back home</a>
</nav>
<div className="auth-orb auth-orb-one" />
<div className="auth-orb auth-orb-two" />
<section className="auth-shell">
<div className="auth-story">
<div className="eyebrow"><Sparkles size={13} /> {eyebrow}</div>
<h1>{title}</h1>
<p>{description}</p>
<div className="auth-points">
<span><Bot size={17} /> Ready for your AI agents</span>
<span><ShieldCheck size={17} /> Private by design</span>
</div>
</div>
<div className="auth-card">{children}</div>
</section>
</main>
);
}

View file

@ -0,0 +1,11 @@
export default function Brand({ compact = false }) {
return (
<a className={`brand${compact ? " brand-compact" : ""}`} href="/" aria-label="Agmail home">
<span className="brand-mark" aria-hidden="true">
<span className="robot-eyes"><i /><i /></span>
<span className="robot-smile" />
</span>
{!compact && <span>agmail</span>}
</a>
);
}

View file

@ -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 (
<div className="dns-table-wrap">
<table className="dns-table">
<thead>
<tr>
<th>Type</th>
<th>Host</th>
<th>Value</th>
<th>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
const displayValue = record.record_type === "MX"
? `10 ${record.value}`
: record.value;
return (
<tr key={rowKey}>
<td>{record.record_type}</td>
<td><code>{record.host}</code></td>
<td>
<div className="dns-value-cell">
<code>{displayValue}</code>
<button
type="button"
className="dns-copy"
aria-label="Copy value"
onClick={() => copyValue(displayValue)}
>
<Copy size={14} />
</button>
</div>
</td>
<td>{record.ttl}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
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 (
<main className="company-dns-setup">
<header className="company-dns-header">
<Brand />
<button type="button" onClick={onLogout}>Log out</button>
</header>
<section className="company-dns-card">
<p className="eyebrow">Company setup</p>
<h1>Connect <em>{domain.domain_name}</em></h1>
<p className="company-dns-lead">
Add these DNS records at your domain registrar for <strong>{account.email}</strong>.
Mail uses shared hosting at <strong>{domain.mail_host}</strong>, like Zoho or Namecheap Private Email.
</p>
<DnsRecordTable records={domain.dns_records} />
<ul className="dns-notes">
<li>Point MX to <strong>{domain.mail_host}</strong> — you do not need a separate mail A record.</li>
<li>SPF uses <code>include:_spf.getall.help</code> — that record must exist on <strong>getall.help</strong> (Host <code>_spf</code>, TXT <code>v=spf1 ip4:&lt;mail-server-ip&gt; ~all</code>).</li>
<li>DNS changes can take a few minutes to propagate.</li>
</ul>
{checks && checks.length > 0 && (
<div className="company-dns-checks">
{checks.map((check) => (
<div
key={check.purpose}
className={`company-dns-check${check.verified ? " ok" : " fail"}`}
>
{check.verified ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
<div>
<strong>{check.purpose.toUpperCase()}</strong>
<span>{check.message}</span>
</div>
</div>
))}
</div>
)}
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button
className="button button-coral company-dns-next"
type="button"
disabled={status.loading}
onClick={handleVerify}
>
{status.loading ? "Checking DNS…" : "Next — verify DNS"} <ArrowRight size={16} />
</button>
</section>
</main>
);
}

View file

@ -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"
? `<p>Hi,</p><p>${request}</p><p>Best,<br>${account.first_name}</p>`
: `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 (
<div className="compose-overlay" role="presentation">
<section className="compose-modal" role="dialog" aria-modal="true" aria-labelledby="compose-title">
<header className="compose-header">
<div>
<span><Sparkles size={14} /></span>
<div><h2 id="compose-title">New message</h2><small>From {account.email}</small></div>
</div>
<button type="button" aria-label="Close compose" onClick={onClose} disabled={status.sending}>
<X size={20} />
</button>
</header>
<form className="compose-form" onSubmit={submit}>
<div className="compose-recipient-row">
<label htmlFor="compose-to">To</label>
<input id="compose-to" name="to" type="text" placeholder="name@example.com" required autoFocus />
<button type="button" onClick={() => setShowCopies((visible) => !visible)}>Cc · Bcc</button>
</div>
{showCopies && (
<div className="compose-copy-fields">
<label>Cc<input name="cc" type="text" placeholder="Copy recipients" /></label>
<label>Bcc<input name="bcc" type="text" placeholder="Hidden recipients" /></label>
</div>
)}
<input className="compose-subject" name="subject" placeholder="Subject" maxLength="998" />
<div className="compose-format-row">
<span>Message format</span>
<div role="tablist" aria-label="Message format">
<button className={format === "plain" ? "active" : ""} type="button" onClick={() => setFormat("plain")}>Plain</button>
<button className={format === "html" ? "active" : ""} type="button" onClick={() => setFormat("html")}>HTML</button>
</div>
</div>
<textarea
className="compose-body"
ref={bodyRef}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={format === "html" ? "<p>Write your HTML email…</p>" : "Write your message…"}
required
/>
<div className="compose-tools" aria-label="Message tools">
<button type="button" title="Bold" disabled={format !== "html"} onClick={() => applyHtml("<strong>", "</strong>")}><Bold size={16} /></button>
<button type="button" title="Italic" disabled={format !== "html"} onClick={() => applyHtml("<em>", "</em>")}><Italic size={16} /></button>
<button type="button" title="Underline" disabled={format !== "html"} onClick={() => applyHtml("<u>", "</u>")}><Underline size={16} /></button>
<button type="button" title="List" disabled={format !== "html"} onClick={() => applyHtml("<ul><li>", "</li></ul>")}><List size={17} /></button>
<button type="button" title="Add link" disabled={format !== "html"} onClick={() => applyHtml('<a href="https://">', "</a>")}><Link size={17} /></button>
{format === "plain" && <span>Formatting tools are available in HTML mode</span>}
</div>
<div className="compose-ai">
<Sparkles size={18} />
<input
value={aiPrompt}
onChange={(event) => setAiPrompt(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
helpWrite();
}
}}
placeholder="Describe what you want to say"
/>
<button type="button" onClick={helpWrite} disabled={!aiPrompt.trim()}>
Help me write
</button>
</div>
{status.error && <p className="compose-error" role="alert">{status.error}</p>}
<footer className="compose-footer">
<span>Sent securely through agmail SMTP</span>
<button className="compose-send" type="submit" disabled={status.sending}>
{status.sending ? "Sending…" : "Send"} <Send size={16} />
</button>
</footer>
</form>
</section>
</div>
);
}

View file

@ -0,0 +1,213 @@
import {
Bot,
Clock3,
Inbox,
ListFilter,
LogOut,
Mail,
Menu,
Plug,
Send,
Settings,
ShieldAlert,
Sparkles,
Tags,
Trash2,
X,
} from "lucide-react";
import { useEffect, useState } from "react";
import { getCurrentAccount, getMyCompanyDomain } from "../api";
import Brand from "./Brand";
import CompanyDnsSetup from "./CompanyDnsSetup";
import ComposeModal from "./ComposeModal";
import MailboxView from "./MailboxView";
const mailItems = [
[Inbox, "Inbox"],
[Send, "Outbox"],
[ShieldAlert, "Spam"],
[Trash2, "Trash"],
[Clock3, "Scheduled"],
[Settings, "Settings"],
];
const agentItems = [
[Tags, "Tags"],
[ListFilter, "Filters"],
[Sparkles, "Summary"],
[Plug, "Integration"],
];
function clearSession() {
sessionStorage.removeItem("agmail_access_token");
sessionStorage.removeItem("agmail_account");
}
function NavGroup({ icon: Icon, title, items, active, onSelect }) {
return (
<section className="dashboard-nav-group">
<h2><Icon size={15} />{title}</h2>
<nav aria-label={title}>
{items.map(([ItemIcon, label]) => (
<button
className={active === label ? "active" : ""}
key={label}
onClick={() => onSelect(label)}
type="button"
>
<ItemIcon size={17} />
<span>{label}</span>
</button>
))}
</nav>
</section>
);
}
export default function DashboardPage() {
const [account, setAccount] = useState(null);
const [companyDomain, setCompanyDomain] = useState(null);
const [needsDnsSetup, setNeedsDnsSetup] = useState(false);
const [domainError, setDomainError] = useState("");
const [active, setActive] = useState("Inbox");
const [menuOpen, setMenuOpen] = useState(false);
const [composeOpen, setComposeOpen] = useState(false);
const [notice, setNotice] = useState("");
const [loading, setLoading] = useState(true);
const [mailRefreshKey, setMailRefreshKey] = useState(0);
useEffect(() => {
const token = sessionStorage.getItem("agmail_access_token");
if (!token) {
window.location.replace("/login");
return;
}
getCurrentAccount(token)
.then(async (currentAccount) => {
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
setAccount(currentAccount);
if (currentAccount.account_type === "company") {
try {
const domain = await getMyCompanyDomain(token);
setCompanyDomain(domain);
setNeedsDnsSetup(domain.status === "pending_dns");
} catch (error) {
setDomainError(error.message || "Could not load your company domain.");
}
}
setLoading(false);
})
.catch(() => {
clearSession();
window.location.replace("/login");
});
}, []);
function logout() {
clearSession();
window.location.assign("/login");
}
function selectItem(item) {
setActive(item);
setMenuOpen(false);
}
function emailSent() {
setComposeOpen(false);
setActive("Outbox");
setMailRefreshKey((key) => key + 1);
setNotice("Message accepted by the mail server. Delivery can take a few minutes — check spam if it does not arrive.");
window.setTimeout(() => setNotice(""), 4000);
}
if (loading || !account) {
return <main className="dashboard-loading"><Brand /><span>Opening your workspace…</span></main>;
}
if (account.account_type === "company" && domainError) {
return (
<main className="company-dns-setup">
<header className="company-dns-header">
<Brand />
<button type="button" onClick={logout}>Log out</button>
</header>
<section className="company-dns-card">
<p className="eyebrow">Company setup</p>
<h1>Domain not ready</h1>
<p className="form-error" role="alert">{domainError}</p>
</section>
</main>
);
}
if (needsDnsSetup && companyDomain) {
return (
<CompanyDnsSetup
account={account}
domain={companyDomain}
onComplete={() => {
setCompanyDomain({ ...companyDomain, status: "active" });
setNeedsDnsSetup(false);
}}
onLogout={logout}
/>
);
}
const workspaceLabel = account.account_type === "company" ? "Company" : "Individual";
const workspaceTitle = account.account_type === "company" && companyDomain
? companyDomain.domain_name
: "Personal workspace";
return (
<div className="dashboard">
<header className="dashboard-mobile-header">
<Brand />
<button aria-label="Open navigation" type="button" onClick={() => setMenuOpen(true)}>
<Menu size={21} />
</button>
</header>
{menuOpen && <button className="dashboard-scrim" aria-label="Close navigation" onClick={() => setMenuOpen(false)} />}
<aside className={menuOpen ? "dashboard-sidebar open" : "dashboard-sidebar"}>
<div className="dashboard-brand-row">
<Brand />
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
</div>
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
<Mail size={17} />Compose
</button>
<NavGroup icon={Mail} title="Mail" items={mailItems} active={active} onSelect={selectItem} />
<NavGroup icon={Bot} title="Agent" items={agentItems} active={active} onSelect={selectItem} />
<div className="dashboard-profile">
<span>{account.first_name[0]}{account.last_name[0]}</span>
<div><strong>{account.first_name} {account.last_name}</strong><small>{account.email}</small></div>
<button aria-label="Log out" title="Log out" type="button" onClick={logout}><LogOut size={17} /></button>
</div>
</aside>
<main className="dashboard-content">
{!["Inbox", "Outbox", "Trash"].includes(active) && (
<div className="dashboard-content-heading">
<div><p>{workspaceTitle}</p><h1>{active}</h1></div>
<span className="dashboard-account-pill">{workspaceLabel}</span>
</div>
)}
{active === "Inbox" && <MailboxView mailbox="inbox" title="Inbox" refreshKey={mailRefreshKey} />}
{active === "Outbox" && <MailboxView mailbox="sent" title="Sent" refreshKey={mailRefreshKey} />}
{active === "Trash" && <MailboxView mailbox="trash" title="Trash" refreshKey={mailRefreshKey} />}
{!["Inbox", "Outbox", "Trash"].includes(active) && (
<section className="dashboard-empty">
<span><Sparkles size={24} /></span>
<h2>{active} is ready for you</h2>
<p>Your personal mail and agent tools will appear here.</p>
</section>
)}
</main>
{notice && <div className="dashboard-notice" role="status">{notice}</div>}
{composeOpen && <ComposeModal account={account} onClose={() => setComposeOpen(false)} onSent={emailSent} />}
</div>
);
}

View file

@ -0,0 +1,46 @@
import { ArrowRight, LockKeyhole, Mail } from "lucide-react";
import { useState } from "react";
import { login } from "../api";
import AuthLayout from "./AuthLayout";
export default function LoginPage() {
const [status, setStatus] = useState({ loading: false, error: "" });
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const result = await login({
email: form.get("email"),
password: form.get("password"),
});
sessionStorage.setItem("agmail_access_token", result.access_token);
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
window.location.assign("/app");
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<AuthLayout
eyebrow="Welcome back"
title={<>Your inbox<br /><em>missed you.</em></>}
description="Sign in to continue working alongside your agents."
>
<>
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div>
<form className="auth-form" onSubmit={handleSubmit}>
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@getall.help" required autoComplete="email" /></div></label>
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="Your password" required autoComplete="current-password" /></div></label>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Signing in…" : "Log in"} <ArrowRight size={16} />
</button>
</form>
<p className="auth-switch">New to agmail? <a href="/signup">Create an account</a></p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,110 @@
import {
ArrowLeft,
Mail,
MailOpen,
RotateCcw,
Trash2,
X,
} from "lucide-react";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
function joinAddresses(value) {
if (!value) return "";
const addresses = Array.isArray(value) ? value : [value];
return addresses
.map((item) => (typeof item === "string" ? item : item?.email || item?.address))
.filter(Boolean)
.join(", ");
}
function detailDate(email) {
const value =
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export default function MailDetail({
email,
loading,
error,
mailbox,
busy,
onClose,
onToggleRead,
onTrash,
onRestore,
onDelete,
}) {
return (
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
<header className="mail-detail-toolbar">
<button type="button" onClick={onClose} aria-label="Back to message list">
<ArrowLeft size={18} />
</button>
<div>
{email && (
<button
type="button"
disabled={busy}
onClick={() => onToggleRead(!email.is_read)}
title={email.is_read ? "Mark unread" : "Mark read"}
aria-label={email.is_read ? "Mark unread" : "Mark read"}
>
{email.is_read ? <Mail size={17} /> : <MailOpen size={17} />}
</button>
)}
{email && mailbox === "trash" && (
<button type="button" disabled={busy} onClick={onRestore} title="Restore" aria-label="Restore message">
<RotateCcw size={17} />
</button>
)}
{email && (
<button
className={mailbox === "trash" ? "danger" : ""}
type="button"
disabled={busy}
onClick={mailbox === "trash" ? onDelete : onTrash}
title={mailbox === "trash" ? "Delete permanently" : "Move to trash"}
aria-label={mailbox === "trash" ? "Delete message permanently" : "Move message to trash"}
>
<Trash2 size={17} />
</button>
)}
<button className="mail-detail-close" type="button" onClick={onClose} aria-label="Close message">
<X size={18} />
</button>
</div>
</header>
{loading && <div className="mail-detail-state">Opening message…</div>}
{error && <div className="mail-detail-state error" role="alert">{error}</div>}
{email && !loading && (
<div className="mail-detail-content">
<h2>{email.subject?.trim() || "(No subject)"}</h2>
<div className="mail-detail-meta">
<div>
<div className="mail-detail-sender">
<SenderAvatar email={email} />
<strong>{senderDisplayName(email)}</strong>
</div>
<small>From: {joinAddresses(email.sender) || "Unknown"}</small>
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
{joinAddresses(email.cc) && <small>Cc: {joinAddresses(email.cc)}</small>}
</div>
<time>{detailDate(email)}</time>
</div>
<div className="mail-detail-body">
{email.body_text || email.text_body || email.plain_text || "This message has no plain-text content."}
</div>
</div>
)}
</article>
);
}

View file

@ -0,0 +1,75 @@
import { MailOpen, Star } from "lucide-react";
import SenderAvatar from "./SenderAvatar";
import { senderDisplayName } from "../utils/senderAvatar";
function formatTimestamp(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
const now = new Date();
const sameDay = date.toDateString() === now.toDateString();
if (sameDay) {
return new Intl.DateTimeFormat(undefined, {
hour: "numeric",
minute: "2-digit",
}).format(date);
}
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
}).format(date);
}
export default function MailRow({ email, selected, onOpen, onToggleStar }) {
const unread = email.is_read === false;
const starred = email.is_starred === true;
const subject = email.subject?.trim() || "(No subject)";
const snippet = email.snippet || email.preview || email.body_text || "";
const timestamp =
email.received_at || email.sent_at || email.created_at || email.updated_at;
const senderName = senderDisplayName(email);
function handleKeyDown(event) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onOpen(email);
}
}
function handleStarClick(event) {
event.stopPropagation();
onToggleStar?.(email, !starred);
}
return (
<div
className={`mailbox-row${unread ? " unread" : ""}${selected ? " selected" : ""}${starred ? " starred" : ""}`}
role="button"
tabIndex={0}
onClick={() => onOpen(email)}
onKeyDown={handleKeyDown}
aria-label={`${unread ? "Unread" : "Read"} email from ${senderName}: ${subject}`}
>
<span className="mailbox-read-dot" aria-hidden="true">
{unread ? <i /> : <MailOpen size={14} />}
</span>
<SenderAvatar email={email} className="mailbox-avatar" />
<span className="mailbox-sender">{senderName}</span>
<span className="mailbox-summary">
<strong>{subject}</strong>
{snippet && <span><b aria-hidden="true">—</b> {snippet}</span>}
</span>
<time dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
<button
type="button"
className={`mailbox-star${starred ? " active" : ""}`}
onClick={handleStarClick}
aria-label={starred ? "Unstar message" : "Star message"}
aria-pressed={starred}
>
<Star size={15} fill={starred ? "currentColor" : "none"} />
</button>
</div>
);
}

View file

@ -0,0 +1,304 @@
import {
ChevronLeft,
ChevronRight,
Filter,
Inbox,
RefreshCw,
Search,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
getEmail,
listEmails,
permanentlyDeleteEmail,
restoreEmail,
setEmailRead,
setEmailStar,
trashEmail,
} from "../api";
import MailDetail from "./MailDetail";
import MailRow from "./MailRow";
import StickyNotesPanel from "./StickyNotesPanel";
const PAGE_SIZE = 25;
function resultPage(result) {
if (Array.isArray(result)) return { items: result, nextCursor: null };
return {
items: result.items || result.emails || result.results || [],
nextCursor: result.next_cursor || result.nextCursor || null,
};
}
export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
const token = sessionStorage.getItem("agmail_access_token");
const [emails, setEmails] = useState([]);
const [nextCursor, setNextCursor] = useState(null);
const [cursor, setCursor] = useState(null);
const [cursorHistory, setCursorHistory] = useState([]);
const [selected, setSelected] = useState(null);
const [detail, setDetail] = useState(null);
const [status, setStatus] = useState({ loading: true, error: "" });
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
const [filtersOpen, setFiltersOpen] = useState(false);
const [filters, setFilters] = useState({
query: "",
read: "all",
sender: "",
dateFrom: "",
dateTo: "",
});
const load = useCallback(async () => {
setStatus({ loading: true, error: "" });
try {
const result = await listEmails(
{
mailbox,
limit: PAGE_SIZE,
cursor,
q: filters.query.trim(),
is_read: filters.read === "all" ? undefined : filters.read === "read",
sender: filters.sender.trim(),
date_from: filters.dateFrom || undefined,
date_to: filters.dateTo || undefined,
},
token,
);
const page = resultPage(result);
setEmails(page.items);
setNextCursor(page.nextCursor);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}, [cursor, filters, mailbox, token]);
useEffect(() => {
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
return () => window.clearTimeout(timeout);
}, [load, refreshKey]);
function changeFilter(name, value) {
setFilters((current) => ({ ...current, [name]: value }));
setCursor(null);
setCursorHistory([]);
}
async function openEmail(email) {
setSelected(email.id);
setDetail(email);
setDetailStatus({ loading: true, error: "", busy: false });
if (email.is_read === false) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: true } : item),
);
setDetail((current) => ({ ...current, is_read: true }));
setEmailRead(email.id, true, token).catch(() => {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
);
setDetail((current) =>
current?.id === email.id ? { ...current, is_read: false } : current,
);
});
}
try {
const completeEmail = await getEmail(email.id, token);
setDetail((current) => ({ ...current, ...completeEmail, is_read: current?.is_read ?? completeEmail.is_read }));
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus({ loading: false, error: error.message, busy: false });
}
}
async function toggleStar(email, isStarred) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: isStarred }));
}
try {
await setEmailStar(email.id, isStarred, token);
} catch (error) {
setEmails((current) =>
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
);
if (detail?.id === email.id) {
setDetail((current) => ({ ...current, is_starred: !isStarred }));
}
}
}
async function toggleRead(isRead) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await setEmailRead(detail.id, isRead, token);
setDetail((current) => ({ ...current, is_read: isRead }));
setEmails((current) =>
current.map((item) => item.id === detail.id ? { ...item, is_read: isRead } : item),
);
setDetailStatus((current) => ({ ...current, busy: false }));
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
async function mutateSelected(action) {
if (!detail) return;
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
try {
await action(detail.id, token);
setEmails((current) => current.filter((item) => item.id !== detail.id));
setSelected(null);
setDetail(null);
setDetailStatus({ loading: false, error: "", busy: false });
} catch (error) {
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
}
}
function deletePermanently() {
if (window.confirm("Permanently delete this message? This cannot be undone.")) {
mutateSelected(permanentlyDeleteEmail);
}
}
function nextPage() {
if (!nextCursor) return;
setCursorHistory((history) => [...history, cursor]);
setCursor(nextCursor);
setSelected(null);
setDetail(null);
}
function previousPage() {
if (!cursorHistory.length) return;
const previous = cursorHistory[cursorHistory.length - 1];
setCursorHistory((history) => history.slice(0, -1));
setCursor(previous);
setSelected(null);
setDetail(null);
}
const hasFilters =
filters.read !== "all" || filters.sender || filters.dateFrom || filters.dateTo;
return (
<section className="mailbox-workspace">
<div className="mailbox-main-column">
<div className="dashboard-content-heading">
<div><p>Personal workspace</p><h1>{title}</h1></div>
<span className="dashboard-account-pill">Individual</span>
</div>
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
<div className="mailbox-list-pane">
<div className="mailbox-toolbar">
<label>
<Search size={16} />
<input
type="search"
value={filters.query}
onChange={(event) => changeFilter("query", event.target.value)}
placeholder={`Search ${title.toLowerCase()}`}
/>
</label>
<button
className={filtersOpen || hasFilters ? "active" : ""}
type="button"
onClick={() => setFiltersOpen((open) => !open)}
aria-expanded={filtersOpen}
>
<Filter size={16} /> <span>Filters</span>
</button>
<button type="button" onClick={load} aria-label="Refresh messages" title="Refresh">
<RefreshCw size={16} />
</button>
</div>
{filtersOpen && (
<div className="mailbox-filters">
<label>Read status
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
<option value="all">All messages</option>
<option value="unread">Unread</option>
<option value="read">Read</option>
</select>
</label>
<label>Sender
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
</label>
<label>From
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
</label>
<label>To
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
</label>
</div>
)}
<div className="mailbox-rows" aria-busy={status.loading}>
{status.loading && <div className="mailbox-state">Loading messages…</div>}
{status.error && (
<div className="mailbox-state error" role="alert">
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
</div>
)}
{!status.loading && !status.error && emails.length === 0 && (
<div className="mailbox-state empty">
<span><Inbox size={22} /></span>
<strong>No messages here</strong>
<p>{hasFilters || filters.query ? "Try changing your search or filters." : `Your ${title.toLowerCase()} is empty.`}</p>
</div>
)}
{!status.loading && !status.error && emails.map((email) => (
<MailRow
key={email.id}
email={email}
selected={selected === email.id}
onOpen={openEmail}
onToggleStar={toggleStar}
/>
))}
</div>
{!status.loading && !status.error && emails.length > 0 && (
<footer className="mailbox-list-footer">
<p>
{emails.length} message{emails.length === 1 ? "" : "s"} in this view
{filters.query.trim() ? " · filtered" : ""}
</p>
<span>You're all caught up</span>
</footer>
)}
{(cursorHistory.length > 0 || nextCursor) && (
<footer className="mailbox-pagination">
<button type="button" disabled={!cursorHistory.length} onClick={previousPage}><ChevronLeft size={16} /> Previous</button>
<span>Page {cursorHistory.length + 1}</span>
<button type="button" disabled={!nextCursor} onClick={nextPage}>Next <ChevronRight size={16} /></button>
</footer>
)}
<MailDetail
email={detail}
loading={detailStatus.loading}
error={detailStatus.error}
busy={detailStatus.busy}
mailbox={mailbox}
onClose={() => { setSelected(null); setDetail(null); }}
onToggleRead={toggleRead}
onTrash={() => mutateSelected(trashEmail)}
onRestore={() => mutateSelected(restoreEmail)}
onDelete={deletePermanently}
/>
</div>
</div>
</div>
<StickyNotesPanel />
</section>
);
}

View file

@ -0,0 +1,34 @@
import { ArrowRight, Bot, Check, Inbox, Menu, Send, Sparkles } from "lucide-react";
import Brand from "./Brand";
export default function MobileLanding() {
return (
<main className="mobile-landing">
<nav className="mobile-nav">
<Brand />
<button className="icon-button" aria-label="Open menu"><Menu size={21} /></button>
</nav>
<section className="mobile-hero">
<div className="eyebrow"><Sparkles size={13} /> Email for humans + agents</div>
<h1>Your inbox,<br /><em>one step ahead.</em></h1>
<p>A focused home for every message, task, and AI agent you work with.</p>
<a className="button button-coral" href="/signup">Create your agmail <ArrowRight size={17} /></a>
<a className="mobile-login" href="/login">Already have an account? <strong>Log in</strong></a>
</section>
<section className="phone-preview" aria-label="Agmail mobile inbox preview">
<div className="phone-top"><span>9:41</span><span className="phone-pill" /><span>•••</span></div>
<header><div><small>Good morning</small><strong>Alex</strong></div><span className="avatar">AK</span></header>
<div className="mobile-summary">
<span className="agent-icon"><Bot size={18} /></span>
<div><strong>Your agent caught you up</strong><small>8 emails handled this morning</small></div>
<ArrowRight size={15} />
</div>
<div className="mobile-inbox-title"><h3>Priority inbox</h3><span>4 new</span></div>
<article className="mobile-mail"><span className="sender-avatar coral">R</span><div><strong>Research agent</strong><p>Market brief is ready</p></div><time>10:42</time></article>
<article className="mobile-mail"><span className="sender-avatar blue">M</span><div><strong>Maya Chen</strong><p>Re: Product direction</p></div><time>9:18</time></article>
<div className="mobile-tabbar"><span className="active"><Inbox size={19} /></span><button><span>+</span></button><span><Send size={19} /></span></div>
</section>
<section className="mobile-benefits"><span><Check size={15} /> Free to start</span><span><Check size={15} /> Private by design</span></section>
</main>
);
}

View file

@ -0,0 +1,23 @@
import { senderAvatar } from "../utils/senderAvatar";
export default function SenderAvatar({ email, className = "" }) {
const { name, initials, color, imageUrl } = senderAvatar(email);
if (imageUrl) {
return (
<span className={`sender-avatar${className ? ` ${className}` : ""}`}>
<img src={imageUrl} alt="" aria-hidden="true" />
</span>
);
}
return (
<span
className={`sender-avatar${className ? ` ${className}` : ""}`}
style={{ backgroundColor: color }}
aria-hidden="true"
>
{initials}
</span>
);
}

View file

@ -0,0 +1,80 @@
import {
ArrowRight, Building2, CalendarDays, Eye, LockKeyhole, Mail, Phone, UserRound,
} from "lucide-react";
import { useState } from "react";
import { register } from "../api";
import AuthLayout from "./AuthLayout";
export default function SignupPage() {
const [accountType, setAccountType] = useState("individual");
const [status, setStatus] = useState({ loading: false, error: "" });
async function handleSubmit(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const result = await register({
account_type: form.get("account_type"),
email_name: form.get("email_name"),
company_domain: form.get("company_domain") || null,
first_name: form.get("first_name"),
last_name: form.get("last_name"),
date_of_birth: form.get("date_of_birth"),
phone_number: form.get("phone_number"),
password: form.get("password"),
});
sessionStorage.setItem("agmail_access_token", result.access_token);
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
window.location.assign("/app");
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
return (
<AuthLayout
eyebrow="Your new digital address"
title={<>An inbox built<br /><em>around you.</em></>}
description="Create a personal address or bring your company's domain."
>
<>
<div className="card-heading"><span>01 — Account setup</span><h2>Create your account</h2><p>Every field is securely processed by agmail.</p></div>
<form className="auth-form signup-form" onSubmit={handleSubmit}>
<fieldset>
<legend>Account type</legend>
<div className="account-types">
<label className={accountType === "individual" ? "type-card selected" : "type-card"}>
<input type="radio" name="account_type" value="individual" checked={accountType === "individual"} onChange={(event) => setAccountType(event.target.value)} />
<UserRound size={19} /><span><strong>Individual</strong><small>Your personal agent inbox</small></span>
</label>
<label className={accountType === "company" ? "type-card selected" : "type-card"}>
<input type="radio" name="account_type" value="company" checked={accountType === "company"} onChange={(event) => setAccountType(event.target.value)} />
<Building2 size={19} /><span><strong>Company</strong><small>Use your business domain</small></span>
</label>
</div>
</fieldset>
{accountType === "company" && (
<label>Company domain<div className="input-wrap"><Building2 size={16} /><input name="company_domain" placeholder="yourcompany.com" required /></div></label>
)}
<label>Choose your email name
<div className="email-builder input-wrap"><Mail size={16} /><input name="email_name" placeholder="alex" pattern="[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?" required /><span>{accountType === "individual" ? "@getall.help" : "@your domain"}</span></div>
</label>
<div className="field-row">
<label>First name<div className="input-wrap"><UserRound size={16} /><input name="first_name" placeholder="Alex" required /></div></label>
<label>Last name<div className="input-wrap"><UserRound size={16} /><input name="last_name" placeholder="Morgan" required /></div></label>
</div>
<label>Date of birth<div className="input-wrap"><CalendarDays size={16} /><input name="date_of_birth" type="date" required /></div></label>
<label>Phone number<div className="input-wrap"><Phone size={16} /><input name="phone_number" type="tel" placeholder="+1 555 123 4567" required /></div></label>
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="8+ characters" minLength="8" required autoComplete="new-password" /><Eye size={15} /></div><small className="field-hint">Use uppercase, lowercase, and a number.</small></label>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Creating account…" : "Create account"} <ArrowRight size={16} />
</button>
</form>
<p className="auth-switch">Already have an account? <a href="/login">Log in</a></p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,271 @@
import { ArrowRight, Copy, Globe, Server } from "lucide-react";
import { useEffect, useState } from "react";
import { previewDnsSetup } from "../api";
import AuthLayout from "./AuthLayout";
const STORAGE_KEY = "agmail_dns_setup_auth";
function loadStoredAuth() {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
const parsed = JSON.parse(raw);
if (!parsed?.username || !parsed?.password) {
return null;
}
return parsed;
} catch {
return null;
}
}
function DnsRecordTable({ title, records }) {
if (!records?.length) {
return null;
}
async function copyValue(value) {
try {
await navigator.clipboard.writeText(value);
} catch {
/* clipboard may be unavailable */
}
}
return (
<section className="dns-section">
<h3>{title}</h3>
<div className="dns-table-wrap">
<table className="dns-table">
<thead>
<tr>
<th>Type</th>
<th>Host</th>
<th>Value</th>
<th>TTL</th>
</tr>
</thead>
<tbody>
{records.map((record) => {
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
const displayValue = record.priority
? `${record.priority} ${record.value}`
: record.value;
return (
<tr key={rowKey}>
<td>{record.record_type}</td>
<td><code>{record.host}</code></td>
<td>
<div className="dns-value-cell">
<code>{displayValue}</code>
<button
type="button"
className="dns-copy"
aria-label="Copy value"
onClick={() => copyValue(displayValue)}
>
<Copy size={14} />
</button>
</div>
</td>
<td>{record.ttl}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
);
}
export default function SmtpCredentialsPage() {
const [auth, setAuth] = useState(() => loadStoredAuth());
const [status, setStatus] = useState({ loading: false, error: "" });
const [result, setResult] = useState(null);
useEffect(() => {
if (auth) {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
} else {
sessionStorage.removeItem(STORAGE_KEY);
setResult(null);
}
}, [auth]);
async function handleLogin(event) {
event.preventDefault();
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
const nextAuth = {
username: String(form.get("username") || ""),
password: String(form.get("password") || ""),
};
try {
await previewDnsSetup(
{ domain: "example.com", mail_server_ip: "127.0.0.1" },
nextAuth,
);
setAuth(nextAuth);
setStatus({ loading: false, error: "" });
} catch (error) {
setStatus({ loading: false, error: error.message });
}
}
async function handleGenerate(event) {
event.preventDefault();
if (!auth) {
return;
}
setStatus({ loading: true, error: "" });
const form = new FormData(event.currentTarget);
try {
const data = await previewDnsSetup(
{
domain: String(form.get("domain") || ""),
mail_server_ip: String(form.get("mail_server_ip") || ""),
},
auth,
);
setResult(data);
setStatus({ loading: false, error: "" });
} catch (error) {
if (error.message.toLowerCase().includes("credential")) {
setAuth(null);
}
setStatus({ loading: false, error: error.message });
}
}
if (!auth) {
return (
<AuthLayout
eyebrow="Operations"
title={<>DNS setup<br /><em>for mail.</em></>}
description="Sign in to generate Namecheap-ready DNS records for self-hosted inbound and outbound mail."
>
<>
<div className="card-heading">
<h2>DNS setup access</h2>
<p>Restricted to deployment operators.</p>
</div>
<form className="auth-form" onSubmit={handleLogin}>
<label>
Username
<div className="input-wrap">
<Server size={16} />
<input
name="username"
type="text"
placeholder="smtp-credentia"
required
autoComplete="username"
/>
</div>
</label>
<label>
Passkey
<div className="input-wrap">
<Globe size={16} />
<input
name="password"
type="password"
placeholder="Your passkey"
required
autoComplete="current-password"
/>
</div>
</label>
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Checking…" : "Continue"} <ArrowRight size={16} />
</button>
</form>
</>
</AuthLayout>
);
}
return (
<AuthLayout
eyebrow="Operations"
title={<>DNS records<br /><em>ready to paste.</em></>}
description="Enter the master domain and this server's public IP. Records are formatted for Namecheap Advanced DNS."
>
<>
<div className="card-heading">
<h2>Generate DNS configuration</h2>
<p>Inbound delivery and outbound authentication records for this mail host.</p>
</div>
<form className="auth-form" onSubmit={handleGenerate}>
<label>
Master domain
<div className="input-wrap">
<Globe size={16} />
<input
name="domain"
type="text"
placeholder="getall.help"
defaultValue={result?.domain || "getall.help"}
required
/>
</div>
</label>
<label>
Mail server public IP
<div className="input-wrap">
<Server size={16} />
<input
name="mail_server_ip"
type="text"
placeholder="203.0.113.10"
defaultValue={result?.mail_server_ip || ""}
required
/>
</div>
</label>
{status.error && (
<p className="form-error" role="alert">{status.error}</p>
)}
<button className="button button-coral auth-submit" disabled={status.loading}>
{status.loading ? "Generating…" : "Generate DNS records"} <ArrowRight size={16} />
</button>
</form>
{result && (
<div className="dns-results">
<p className="dns-summary">
<strong>{result.mail_host}</strong> → {result.mail_server_ip}
{result.app_public_host && (
<> · web UI at <strong>{result.app_public_host}</strong></>
)}
{" · self-hosted inbound + outbound"}
</p>
<ul className="dns-notes">
{result.notes.map((note) => (
<li key={note}>{note}</li>
))}
</ul>
<DnsRecordTable
title="Inbound (MX, A — deliver mail to this host)"
records={result.sections.inbound}
/>
<DnsRecordTable
title="Outbound (SPF, DKIM, DMARC — authenticate sending)"
records={result.sections.outbound}
/>
</div>
)}
<p className="auth-switch">
<button type="button" className="link-button" onClick={() => setAuth(null)}>
Sign out
</button>
</p>
</>
</AuthLayout>
);
}

View file

@ -0,0 +1,283 @@
import {
MoreHorizontal,
Pin,
Plus,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import {
createStickyNote,
deleteStickyNote,
listStickyNotes,
} from "../api";
import bluePin from "../data/blue.png";
import greenPin from "../data/green.png";
import pinkPin from "../data/pink.png";
import yellowPin from "../data/yellow.png";
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
const PIN_IMAGES = {
yellow: yellowPin,
green: greenPin,
blue: bluePin,
pink: pinkPin,
};
function formatNoteTime(value) {
if (!value) return "";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "";
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
}).format(date);
}
function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
return (
<article className={`sticky-note sticky-note-${note.color}`}>
<span className="sticky-note-pin" aria-hidden="true">
<img src={PIN_IMAGES[note.color] || yellowPin} alt="" />
</span>
<header className="sticky-note-header">
<h3>{note.title}</h3>
<div className="sticky-note-menu-wrap">
<button
type="button"
className="sticky-note-menu-btn"
aria-label="Note options"
aria-expanded={menuOpen}
onClick={(event) => {
event.stopPropagation();
onMenuToggle(note.id);
}}
>
<MoreHorizontal size={16} />
</button>
{menuOpen && (
<div className="sticky-note-menu">
<button type="button" onClick={() => onDelete(note.id)}>
<Trash2 size={14} /> Delete
</button>
</div>
)}
</div>
</header>
{note.body && <p>{note.body}</p>}
<footer className="sticky-note-footer">
<div className="sticky-note-tags">
{(note.tags.length ? note.tags : ["General"]).map((tag) => (
<span key={tag}>{tag}</span>
))}
</div>
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
</footer>
</article>
);
}
export default function StickyNotesPanel() {
const token = sessionStorage.getItem("agmail_access_token");
const [notes, setNotes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [adding, setAdding] = useState(false);
const [menuOpenId, setMenuOpenId] = useState(null);
const [draft, setDraft] = useState({
title: "",
body: "",
tags: "",
color: "yellow",
});
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError("");
try {
const items = await listStickyNotes(token);
setNotes(items);
} catch (loadError) {
setError(loadError.message);
} finally {
setLoading(false);
}
}, [token]);
useEffect(() => {
load();
}, [load]);
useEffect(() => {
function closeMenu() {
setMenuOpenId(null);
}
window.addEventListener("click", closeMenu);
return () => window.removeEventListener("click", closeMenu);
}, []);
async function handleCreate(event) {
event.preventDefault();
if (!draft.title.trim()) return;
setSaving(true);
setError("");
try {
const created = await createStickyNote(
{
title: draft.title.trim(),
body: draft.body.trim(),
tags: draft.tags
.split(",")
.map((tag) => tag.trim())
.filter(Boolean),
color: draft.color,
},
token,
);
setNotes((current) => [created, ...current]);
setDraft({
title: "",
body: "",
tags: "",
color: NOTE_COLORS[(notes.length + 1) % NOTE_COLORS.length],
});
setAdding(false);
} catch (createError) {
setError(createError.message);
} finally {
setSaving(false);
}
}
async function handleDelete(noteId) {
setMenuOpenId(null);
setError("");
try {
await deleteStickyNote(noteId, token);
setNotes((current) => current.filter((note) => note.id !== noteId));
} catch (deleteError) {
setError(deleteError.message);
}
}
function handleBodyKeyDown(event) {
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
const textarea = event.currentTarget;
const { selectionStart, selectionEnd, value } = textarea;
const insertion = "\n• ";
const nextValue =
value.slice(0, selectionStart) + insertion + value.slice(selectionEnd);
setDraft((current) => ({ ...current, body: nextValue }));
window.requestAnimationFrame(() => {
textarea.selectionStart = selectionStart + insertion.length;
textarea.selectionEnd = selectionStart + insertion.length;
});
}
return (
<aside className="mailbox-notes-pane" aria-label="Sticky notes">
<header className="sticky-notes-header">
<div className="sticky-notes-title">
<Pin size={15} />
<h2>Sticky Notes</h2>
</div>
<div className="sticky-notes-actions">
<button
type="button"
aria-label="Add sticky note"
onClick={() => {
setDraft((current) => ({
...current,
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
}));
setAdding(true);
}}
>
<Plus size={18} />
</button>
</div>
</header>
<div className="sticky-notes-body">
{loading && <div className="sticky-notes-state">Loading notes…</div>}
{error && !loading && (
<div className="sticky-notes-state error" role="alert">{error}</div>
)}
{!loading && notes.map((note) => (
<NoteCard
key={note.id}
note={note}
menuOpen={menuOpenId === note.id}
onMenuToggle={(id) => setMenuOpenId((current) => (current === id ? null : id))}
onDelete={handleDelete}
/>
))}
{adding ? (
<form className="sticky-note-form" onSubmit={handleCreate}>
<input
type="text"
value={draft.title}
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
placeholder="Note title"
autoFocus
required
/>
<textarea
value={draft.body}
onChange={(event) => setDraft((current) => ({ ...current, body: event.target.value }))}
onKeyDown={handleBodyKeyDown}
placeholder="Jot down a quick note…"
rows={3}
/>
<input
type="text"
value={draft.tags}
onChange={(event) => setDraft((current) => ({ ...current, tags: event.target.value }))}
placeholder="Tags (comma separated)"
/>
<div className="sticky-note-colors">
{NOTE_COLORS.map((color) => (
<button
key={color}
type="button"
className={`sticky-note-color${draft.color === color ? " active" : ""}`}
data-color={color}
aria-label={`${color} note`}
onClick={() => setDraft((current) => ({ ...current, color }))}
/>
))}
</div>
<div className="sticky-note-form-actions">
<button type="button" onClick={() => setAdding(false)} disabled={saving}>
Cancel
</button>
<button type="submit" disabled={saving || !draft.title.trim()}>
{saving ? "Saving…" : "Save note"}
</button>
</div>
</form>
) : (
<button
type="button"
className="sticky-note-add"
onClick={() => {
setDraft((current) => ({
...current,
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
}));
setAdding(true);
}}
>
<Plus size={20} />
<span>Add sticky note</span>
<small>Jot down a quick note…</small>
</button>
)}
</div>
</aside>
);
}

View file

@ -0,0 +1,65 @@
import {
ArrowRight, Bot, Check, ChevronDown, Clock3, Inbox, Menu,
Paperclip, Search, Send, ShieldCheck, Sparkles,
} from "lucide-react";
import Brand from "./Brand";
const mail = [
{ sender: "Research agent", subject: "Market brief is ready", time: "10:42", color: "coral" },
{ sender: "Maya Chen", subject: "Re: Product direction", time: "9:18", color: "blue" },
{ sender: "Calendar", subject: "Design review in 30 minutes", time: "8:30", color: "gold" },
];
export default function WebLanding() {
return (
<main className="landing">
<nav className="nav shell">
<Brand />
<div className="nav-links"><a href="#product">Product</a><a href="#security">Security</a><a href="#about">About</a></div>
<div className="nav-actions"><a className="button button-quiet" href="/login">Log in</a><a className="button button-dark" href="/signup">Create account <ArrowRight size={15} /></a></div>
</nav>
<section className="hero shell">
<div className="hero-copy">
<div className="eyebrow"><Sparkles size={14} /> Built for you and your agents</div>
<h1>Email that works<br /><em>at your speed.</em></h1>
<p>A calm, intelligent inbox where you and your AI agents can send, receive, organize, and get work done.</p>
<div className="hero-actions"><a className="button button-coral" href="/signup">Get your @agmail <ArrowRight size={17} /></a><a className="button button-outline" href="#product">See how it works</a></div>
<div className="trust-line"><span><Check size={13} /> Free to start</span><span><Check size={13} /> No card required</span></div>
</div>
<div className="product-stage" aria-label="Agmail inbox preview">
<div className="orb orb-one" /><div className="orb orb-two" />
<div className="inbox-window">
<aside className="inbox-sidebar">
<div className="mini-brand"><Brand compact /></div>
<button className="compose"><span>+</span></button>
<div className="side-icons"><span className="active"><Inbox size={18} /></span><span><Send size={18} /></span><span><Clock3 size={18} /></span></div>
<div className="avatar">AK</div>
</aside>
<div className="mail-panel">
<header className="mail-toolbar"><div><small>Good morning,</small><strong>Alex</strong></div><div className="toolbar-actions"><Search size={17} /><Menu size={17} /></div></header>
<div className="inbox-heading"><div><h3>Inbox</h3><span>12</span></div><button>Priority <ChevronDown size={13} /></button></div>
<div className="agent-summary"><div className="agent-icon"><Bot size={19} /></div><div><strong>Your agent handled 8 emails</strong><p>3 replies drafted · 2 tasks completed</p></div><ArrowRight size={16} /></div>
<div className="mail-list">
{mail.map((item, index) => (
<article className={index === 0 ? "mail-item selected" : "mail-item"} key={item.sender}>
<span className={`sender-avatar ${item.color}`}>{item.sender[0]}</span><div><strong>{item.sender}</strong><p>{item.subject}</p></div><time>{item.time}</time>
</article>
))}
</div>
<div className="quick-reply"><Paperclip size={16} /><span>Ask your agent to draft a reply…</span><button><ArrowRight size={15} /></button></div>
</div>
</div>
<div className="floating-note"><span><Check size={14} /></span><div><strong>Follow-up sent</strong><small>Handled by your agent</small></div></div>
</div>
</section>
<section className="feature-strip shell" id="product">
<div className="feature-intro"><span>One inbox. Less noise.</span><h2>Made for the way work is changing.</h2></div>
<div className="feature-grid">
<article><span className="feature-icon peach"><Bot size={21} /></span><h3>Agent-ready</h3><p>Give every agent a secure address and clear permissions.</p></article>
<article><span className="feature-icon cyan"><Sparkles size={21} /></span><h3>Quietly intelligent</h3><p>Summaries, sorting, and replies—without the clutter.</p></article>
<article id="security"><span className="feature-icon violet"><ShieldCheck size={21} /></span><h3>Private by design</h3><p>Your conversations stay encrypted and in your control.</p></article>
</div>
</section>
</main>
);
}

View file

@ -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"
}

BIN
frontend/src/data/blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

BIN
frontend/src/data/green.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

BIN
frontend/src/data/pink.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

453
frontend/src/styles.css Normal file
View file

@ -0,0 +1,453 @@
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700&display=swap");
:root { font-family:"DM Sans",sans-serif; color:#080821; background:#fffdfb; font-synthesis:none; --ink:#080821; --muted:#67677a; --coral:#ff806c; --blush:#fff3ef; --line:#eee9e5; }
* { box-sizing:border-box; }
html { scroll-behavior:smooth; }
body { margin:0; min-width:320px; }
button,a { font:inherit; }
button { cursor:pointer; }
a { color:inherit; text-decoration:none; }
.shell { width:min(1180px,calc(100% - 64px)); margin-inline:auto; }
.mobile-view { display:none; }
.brand { display:inline-flex; align-items:center; gap:10px; font:700 20px "Manrope"; letter-spacing:-.7px; }
.brand-mark { width:31px; height:29px; position:relative; display:block; background:var(--ink); border-radius:9px 9px 11px 11px; transform:rotate(-4deg); box-shadow:inset 0 -2px 0 #ffffff12; }
.robot-eyes { position:absolute; top:8px; left:8px; display:flex; gap:6px; transform:rotate(4deg); }
.robot-eyes i { width:4px; height:5px; display:block; border-radius:50%; background:white; }
.robot-smile { width:14px; height:7px; position:absolute; left:9px; bottom:6px; border:2px solid white; border-top:0; border-radius:0 0 10px 10px; transform:rotate(4deg); }
.brand-compact { line-height:0; }
.nav { height:88px; display:flex; align-items:center; justify-content:space-between; }
.nav-links,.nav-actions,.hero-actions,.trust-line { display:flex; align-items:center; }
.nav-links { gap:38px; margin-left:80px; color:#4f4f62; font-size:14px; }
.nav-actions,.hero-actions { gap:10px; }
.button { min-height:43px; padding:0 19px; display:inline-flex; align-items:center; justify-content:center; gap:9px; border:0; border-radius:11px; font-size:14px; font-weight:600; transition:.2s; }
.button:hover { transform:translateY(-2px); }
.button-quiet { background:transparent; }
.button-dark { color:white; background:var(--ink); box-shadow:0 8px 24px #08082122; }
.button-coral { min-height:51px; padding-inline:24px; color:var(--ink); background:var(--coral); box-shadow:0 12px 25px #ff806c47; }
.button-outline { min-height:51px; color:var(--ink); border:1px solid #dcd7d3; background:#ffffffbf; }
.hero { min-height:660px; padding:36px 0 70px; display:grid; grid-template-columns:.88fr 1.12fr; align-items:center; gap:58px; }
.eyebrow { width:max-content; padding:7px 11px; display:flex; align-items:center; gap:7px; border:1px solid #f0ded7; border-radius:99px; background:#fff9f6; color:#6e5d58; font-size:12px; font-weight:600; }
h1 { margin:23px 0 20px; font:600 clamp(52px,5.2vw,76px)/.99 "Manrope"; letter-spacing:-4.6px; }
h1 em { color:var(--coral); font-style:normal; font-weight:500; }
.hero-copy>p { max-width:500px; color:var(--muted); font-size:17px; line-height:1.65; }
.hero-actions { margin-top:31px; }
.trust-line { gap:20px; margin-top:22px; color:#888795; font-size:12px; }
.trust-line span,.mobile-benefits span { display:flex; align-items:center; gap:5px; }
.trust-line svg,.mobile-benefits svg { color:#49aa87; }
.product-stage { min-height:540px; position:relative; display:grid; place-items:center; }
.product-stage:before { content:""; position:absolute; inset:1% -3% 0; border-radius:48% 52% 38% 45%; background:linear-gradient(145deg,#ffe1cd,#f6efc8 54%,#d6f3f4); transform:rotate(-2deg); }
.orb { position:absolute; border-radius:50%; opacity:.5; }
.orb-one { width:90px; height:90px; top:0; right:20px; background:#ffcfc6; }
.orb-two { width:70px; height:70px; bottom:0; left:0; background:#b8eef2; }
.inbox-window { width:94%; height:445px; z-index:2; display:grid; grid-template-columns:62px 1fr; overflow:hidden; border:7px solid #ffffffbf; border-radius:22px; background:white; box-shadow:0 34px 70px #312a3c33; transform:rotate(1.2deg); }
.inbox-sidebar { padding:18px 0 14px; display:flex; flex-direction:column; align-items:center; color:white; background:var(--ink); }
.mini-brand .brand-mark { width:25px; height:22px; background:white; }
.mini-brand .robot-eyes { top:6px; left:6px; gap:5px; }.mini-brand .robot-eyes i { width:3px; height:4px; background:var(--ink); }
.mini-brand .robot-smile { width:11px; height:5px; left:7px; bottom:4px; border-color:var(--ink); }
.compose { width:34px; height:34px; margin-top:34px; border:0; border-radius:10px; background:var(--coral); font-size:23px; }
.side-icons { margin-top:25px; display:grid; gap:12px; }
.side-icons span { width:36px; height:36px; display:grid; place-items:center; border-radius:10px; color:#9594a8; }
.side-icons .active { color:white; background:#ffffff1f; }
.avatar { width:30px; height:30px; margin-top:auto; display:grid; place-items:center; border-radius:50%; background:#ffe1d5; color:#37201c; font-size:10px; font-weight:700; }
.mail-panel { min-width:0; padding:22px 25px 17px; background:#fffefd; }
.mail-toolbar,.inbox-heading { display:flex; justify-content:space-between; align-items:center; }
.mail-toolbar div:first-child { display:grid; }
.mail-toolbar small { color:#9995a0; font-size:9px; }
.mail-toolbar strong { font-size:13px; }
.toolbar-actions { display:flex; gap:17px; color:#737181; }
.inbox-heading { margin-top:21px; }
.inbox-heading>div { display:flex; align-items:center; gap:8px; }
.inbox-heading h3 { margin:0; font:600 22px "Manrope"; }
.inbox-heading span { width:20px; height:20px; display:grid; place-items:center; border-radius:7px; background:var(--blush); color:var(--coral); font-size:9px; }
.inbox-heading button { display:flex; align-items:center; gap:4px; border:0; background:transparent; color:#777380; font-size:10px; }
.agent-summary { margin-top:14px; padding:12px; display:grid; grid-template-columns:36px 1fr auto; align-items:center; gap:11px; border:1px solid #ffddd5; border-radius:12px; background:#fff1ed; }
.agent-icon { width:34px; height:34px; display:grid; place-items:center; border-radius:10px; background:var(--ink); color:white; }
.agent-summary strong { font-size:11px; }
.agent-summary p { margin:3px 0 0; color:#81777a; font-size:8px; }
.mail-list { margin-top:9px; }
.mail-item,.mobile-mail { display:grid; grid-template-columns:35px 1fr auto; align-items:center; gap:10px; border-bottom:1px solid #f2eeeb; }
.mail-item { padding:10px 8px; }
.mail-item.selected { margin-inline:-5px; padding-inline:13px; border-radius:10px; background:#fcf9f7; }
.sender-avatar { width:31px; height:31px; display:grid; place-items:center; border-radius:50%; font-size:10px; font-weight:700; }
.coral { background:#ffe0d8; color:#bd4e3b; }.blue { background:#daf2f4; color:#247f87; }.gold { background:#f9e9b9; color:#9a741b; }
.mail-item strong { display:block; font-size:10px; }.mail-item p { margin:3px 0 0; color:#84808b; font-size:8px; }.mail-item time { color:#aaa6ac; font-size:8px; }
.quick-reply { height:38px; margin-top:9px; padding:0 8px 0 12px; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:8px; border:1px solid #eee8e4; border-radius:11px; color:#aaa3aa; font-size:9px; }
.quick-reply button { width:25px; height:25px; display:grid; place-items:center; border:0; border-radius:8px; background:var(--ink); color:white; }
.floating-note { position:absolute; z-index:4; right:-12px; bottom:35px; padding:11px 15px 11px 11px; display:flex; align-items:center; gap:9px; border-radius:12px; background:white; box-shadow:0 16px 32px #1e192829; transform:rotate(-2deg); }
.floating-note>span { width:27px; height:27px; display:grid; place-items:center; border-radius:50%; background:#dff5ed; color:#3a9877; }
.floating-note div { display:grid; }.floating-note strong { font-size:10px; }.floating-note small { color:#98939b; font-size:8px; }
.feature-strip { padding:74px 0 90px; display:grid; grid-template-columns:.75fr 1.25fr; gap:70px; border-top:1px solid var(--line); }
.feature-intro>span { color:var(--coral); font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:1.2px; }
.feature-intro h2 { margin:13px 0 0; font:600 34px/1.17 "Manrope"; letter-spacing:-1.7px; }
.feature-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:28px; }
.feature-icon { width:43px; height:43px; display:grid; place-items:center; border-radius:13px; }
.peach { background:#ffe6dd; color:#c85e49; }.cyan { background:#ddf3f4; color:#317d83; }.violet { background:#e9e5f8; color:#595081; }
.feature-grid h3 { margin:17px 0 7px; font-size:15px; }.feature-grid p { margin:0; color:#777482; font-size:12px; line-height:1.6; }
.auth-page { min-height:100vh; position:relative; overflow:hidden; background:linear-gradient(135deg,#fffdfb 0%,#fff9f5 48%,#f5f4df 100%); }
.auth-nav { width:min(1180px,calc(100% - 64px)); height:86px; margin:auto; display:flex; align-items:center; justify-content:space-between; position:relative; z-index:2; }
.back-link { display:flex; align-items:center; gap:7px; color:#74717c; font-size:13px; font-weight:600; }
.auth-shell { width:min(1050px,calc(100% - 64px)); min-height:calc(100vh - 86px); margin:auto; padding:42px 0 70px; display:grid; grid-template-columns:.85fr 1.15fr; align-items:center; gap:95px; position:relative; z-index:1; }
.auth-story h1 { margin:21px 0; font-size:clamp(48px,5vw,67px); }
.auth-story>p { max-width:390px; color:var(--muted); font-size:16px; line-height:1.65; }
.auth-points { margin-top:31px; display:grid; gap:12px; color:#5f5d69; font-size:12px; }
.auth-points span { display:flex; align-items:center; gap:9px; }.auth-points svg { color:var(--coral); }
.auth-card { padding:37px; border:1px solid #eee7e1; border-radius:24px; background:#ffffffeb; box-shadow:0 30px 75px #352b3b1c; backdrop-filter:blur(12px); }
.card-heading>span { color:var(--coral); font-size:10px; font-weight:700; letter-spacing:1.1px; text-transform:uppercase; }
.card-heading h2,.auth-success h2 { margin:8px 0 7px; font:600 25px "Manrope"; letter-spacing:-1px; }
.card-heading p,.auth-success p { margin:0; color:#85818b; font-size:12px; line-height:1.5; }
.auth-form { margin-top:26px; display:grid; gap:17px; }
.auth-form label,.auth-form legend { color:#393745; font-size:11px; font-weight:700; }
.auth-form fieldset { margin:0; padding:0; border:0; }
.auth-form legend { margin-bottom:9px; }
.input-wrap { height:48px; margin-top:7px; padding:0 14px; display:flex; align-items:center; gap:10px; border:1px solid #e4dfdc; border-radius:11px; background:white; color:#a19ba2; transition:.2s; }
.input-wrap:focus-within { border-color:var(--coral); box-shadow:0 0 0 3px #ff806c18; }
.input-wrap input { min-width:0; flex:1; border:0; outline:0; background:transparent; color:var(--ink); font-size:13px; }
.input-wrap input::placeholder { color:#aaa5ab; }
.account-types { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
.type-card { min-height:74px; padding:13px; display:grid; grid-template-columns:20px 1fr; align-items:center; gap:10px; border:1px solid #e5e0dc; border-radius:12px; background:white; cursor:pointer; }
.type-card.selected { border-color:var(--coral); background:#fff6f2; box-shadow:0 0 0 2px #ff806c12; }
.type-card input { position:absolute; opacity:0; pointer-events:none; }
.type-card>span { display:grid; gap:3px; }.type-card strong { font-size:11px; }.type-card small { color:#97919a; font-size:8px; font-weight:500; line-height:1.3; }
.type-card svg { color:#77727c; }.type-card.selected svg { color:var(--coral); }
.field-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.email-builder>span { padding-left:10px; border-left:1px solid #eae5e1; color:#7e7982; font-size:11px; white-space:nowrap; }
.field-hint { display:block; margin-top:6px; color:#99939b; font-size:9px; font-weight:500; }
.auth-submit { width:100%; margin-top:3px; }
.auth-submit:disabled { opacity:.65; cursor:wait; transform:none; }
.auth-switch { margin:20px 0 0; text-align:center; color:#85808a; font-size:11px; }.auth-switch a { color:var(--ink); font-weight:700; }
.form-error { margin:0; padding:10px 12px; border:1px solid #ffd3ca; border-radius:9px; background:#fff2ef; color:#b54f3d; font-size:10px; line-height:1.4; }
.auth-success { padding:35px 10px; display:flex; flex-direction:column; align-items:center; text-align:center; }
.auth-success>span { width:50px; height:50px; display:grid; place-items:center; border-radius:16px; background:#dff5ed; color:#359374; }
.auth-success .auth-submit { margin-top:25px; }
.auth-orb { position:absolute; border-radius:50%; filter:blur(2px); opacity:.65; }.auth-orb-one { width:240px; height:240px; top:-80px; right:10%; background:#ffe0d3; }.auth-orb-two { width:180px; height:180px; bottom:-70px; left:7%; background:#d9f1f0; }
.dashboard { min-height:100vh; display:grid; grid-template-columns:270px 1fr; background:#f5f4f2; }
.dashboard-sidebar { height:100vh; padding:29px 20px 19px; position:sticky; top:0; display:flex; flex-direction:column; border-right:1px solid #e6e2de; background:#fffefd; box-shadow:4px 0 28px #0808210c; z-index:12; }
.dashboard-brand-row { padding:0 9px; display:flex; align-items:center; justify-content:space-between; }
.dashboard-brand-row>button { display:none; }
.dashboard-compose { min-height:45px; margin:35px 3px 29px; display:flex; align-items:center; justify-content:center; gap:9px; border:0; border-radius:11px; background:var(--ink); color:white; font-weight:700; box-shadow:0 6px 18px #08082128; transition:transform .18s ease,box-shadow .18s ease,background .18s ease; }
.dashboard-compose:hover { transform:scale(1.02); box-shadow:0 10px 26px #08082136; background:#12102a; }
.dashboard-compose:active { transform:scale(0.99); box-shadow:0 4px 12px #08082122; }
.dashboard-nav-group { margin-bottom:24px; }
.dashboard-nav-group h2 { margin:0 10px 8px; display:flex; align-items:center; gap:8px; color:#97929a; font-size:10px; letter-spacing:1.2px; text-transform:uppercase; }
.dashboard-nav-group nav { display:grid; gap:3px; }
.dashboard-nav-group button { min-height:39px; padding:0 11px; display:flex; align-items:center; gap:11px; border:0; border-radius:9px; background:transparent; color:#6f6b76; font-size:13px; text-align:left; transition:background .15s ease,color .15s ease; }
.dashboard-nav-group button:hover { background:#faf5f2; color:var(--ink); }
.dashboard-nav-group button.active { background:#fff1ed; color:#c95945; font-weight:700; box-shadow:inset 2px 0 0 var(--coral); }
.dashboard-profile { margin-top:auto; padding:13px 5px 0; display:grid; grid-template-columns:34px minmax(0,1fr) 31px; align-items:center; gap:9px; border-top:1px solid var(--line); }
.dashboard-profile>span { width:34px; height:34px; display:grid; place-items:center; border-radius:11px; background:var(--ink); color:white; font-size:10px; font-weight:700; text-transform:uppercase; }
.dashboard-profile>div { min-width:0; display:grid; gap:2px; }
.dashboard-profile strong,.dashboard-profile small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.dashboard-profile strong { font-size:11px; }.dashboard-profile small { color:#99949d; font-size:8px; }
.dashboard-profile button,.dashboard-brand-row>button,.dashboard-mobile-header>button { width:31px; height:31px; display:grid; place-items:center; border:0; border-radius:9px; background:transparent; color:#8c8790; }
.dashboard-profile button:hover { color:var(--ink); background:#f5f1ee; }
.dashboard-content { min-width:0; padding:28px clamp(28px,4vw,60px); background:#f5f4f2; }
.dashboard-content-heading { display:flex; align-items:end; justify-content:space-between; gap:20px; }
.dashboard-content-heading p { margin:0 0 7px; color:#7a757f; font-size:11px; font-weight:700; letter-spacing:1px; text-transform:uppercase; }
.dashboard-content-heading h1 { margin:0; color:var(--ink); font-size:clamp(35px,4vw,48px); letter-spacing:-2.5px; }
.dashboard-account-pill { padding:8px 12px; border:1px solid #f1dcd5; border-radius:99px; background:#fff5f1; color:#a95343; font-size:10px; font-weight:700; }
.dashboard-empty { min-height:390px; margin-top:35px; display:flex; flex-direction:column; align-items:center; justify-content:center; border:1px solid var(--line); border-radius:22px; background:white; text-align:center; box-shadow:0 18px 50px #392e3e0a; }
.dashboard-empty>span,.company-soon-icon { width:53px; height:53px; display:grid; place-items:center; border-radius:16px; background:#fff0eb; color:var(--coral); }
.dashboard-empty h2 { margin:17px 0 7px; font:600 20px "Manrope"; letter-spacing:-.7px; }
.dashboard-empty p { margin:0; color:#8c8790; font-size:12px; }
.dashboard-mobile-header { display:none; }
.dashboard-loading { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; background:#fffdfb; color:#8d8891; font-size:12px; }
.company-soon { min-height:100vh; padding:0 clamp(22px,5vw,64px); display:flex; flex-direction:column; overflow:hidden; background:radial-gradient(circle at 78% 25%,#f3efd2 0,transparent 26%),linear-gradient(135deg,#fffdfb,#fff4ef); }
.company-soon header { height:88px; display:flex; align-items:center; justify-content:space-between; }
.company-soon header button { display:flex; align-items:center; gap:7px; border:0; background:transparent; color:#77727c; font-size:12px; }
.company-soon section { margin:auto; padding:40px 0 120px; display:flex; flex-direction:column; align-items:center; text-align:center; }
.company-soon section>.eyebrow { margin-top:22px; }
.company-soon h1 { margin:20px 0; font-size:clamp(45px,7vw,78px); }
.company-soon section>p:last-child { margin:0; color:#77727c; font-size:14px; }
.company-dns-setup { min-height:100vh; padding:0 clamp(22px,5vw,64px) 80px; display:flex; flex-direction:column; background:radial-gradient(circle at 78% 25%,#f3efd2 0,transparent 26%),linear-gradient(135deg,#fffdfb,#fff4ef); }
.company-dns-header { height:88px; display:flex; align-items:center; justify-content:space-between; }
.company-dns-header button { border:0; background:transparent; color:#77727c; font-size:12px; }
.company-dns-card { width:min(920px,100%); margin:0 auto; padding:34px 0; display:flex; flex-direction:column; gap:18px; }
.company-dns-card h1 { margin:0; font-size:clamp(34px,5vw,52px); letter-spacing:-1.2px; }
.company-dns-card h1 em { font-style:normal; color:var(--coral); }
.company-dns-lead { margin:0; color:#77727c; font-size:14px; line-height:1.6; max-width:720px; }
.dns-notes { margin:4px 0 0; padding-left:18px; color:#7d7882; font-size:12px; line-height:1.55; }
.dns-notes li+li { margin-top:6px; }
.company-dns-checks { display:grid; gap:8px; }
.company-dns-check { padding:10px 12px; display:flex; align-items:flex-start; gap:10px; border-radius:10px; font-size:12px; }
.company-dns-check strong { display:block; font-size:10px; letter-spacing:.4px; }
.company-dns-check span { color:#7d7882; }
.company-dns-check.ok { background:#eef8f1; color:#2f6b43; }
.company-dns-check.fail { background:#fff0eb; color:#b04a38; }
.company-dns-next { align-self:flex-start; margin-top:8px; display:inline-flex; align-items:center; gap:8px; }
.dns-copy { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; }
.dns-copy:hover { background:#fff0eb; color:var(--coral); }
.dashboard-scrim { display:none; }
.dashboard-notice { padding:12px 16px; position:fixed; right:24px; bottom:24px; z-index:30; border-radius:11px; background:var(--ink); color:white; box-shadow:0 14px 35px #08082138; font-size:12px; font-weight:600; }
.mailbox-workspace { min-height:calc(100vh - 56px); display:grid; grid-template-columns:minmax(0,70fr) minmax(0,30fr); align-items:stretch; gap:22px; }
.mailbox-main-column { min-width:0; display:flex; flex-direction:column; }
.mailbox-shell { min-height:calc(95vh - 158px); margin-top:24px; overflow:hidden; border:1px solid #e6e2de; border-radius:18px; background:white; box-shadow:0 1px 2px #08082106,0 12px 40px #0808210a; }
.mailbox-list-pane { min-width:0; min-height:calc(95vh - 158px); position:relative; display:flex; flex-direction:column; }
.mailbox-notes-pane { min-width:0; min-height:calc(100vh - 56px); max-height:calc(100vh - 56px); overflow:hidden; display:flex; flex-direction:column; border:1px solid #e6e2de; border-radius:18px; background:#fffefd; box-shadow:0 1px 2px #08082106,0 14px 42px #08082110; }
.mailbox-toolbar { min-height:62px; padding:11px 15px; display:flex; align-items:center; gap:8px; border-bottom:1px solid #ebe7e3; background:#fffefd; }
.mailbox-toolbar>label { height:38px; min-width:160px; flex:1; padding:0 12px; display:flex; align-items:center; gap:8px; border:1px solid #e8e3df; border-radius:10px; color:#aaa4ac; background:#fcfbfa; }
.mailbox-toolbar input { min-width:0; width:100%; border:0; outline:0; background:transparent; color:var(--ink); font-size:12px; }
.mailbox-toolbar>button,.mail-detail-toolbar button { height:36px; padding:0 10px; display:flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:9px; background:transparent; color:#77717a; font-size:10px; font-weight:700; }
.mailbox-toolbar>button:hover,.mailbox-toolbar>button.active,.mail-detail-toolbar button:hover { background:#fff0eb; color:#bd5947; }
.mailbox-filters { padding:13px 15px; display:grid; grid-template-columns:1fr 1.5fr 1fr 1fr; gap:10px; border-bottom:1px solid var(--line); background:#fcfaf9; }
.mailbox-filters label { display:grid; gap:5px; color:#76717a; font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; }
.mailbox-filters input,.mailbox-filters select { min-width:0; height:34px; padding:0 9px; border:1px solid #e4dfdc; border-radius:8px; outline:0; background:white; color:#393745; font-size:10px; text-transform:none; }
.mailbox-filters input:focus,.mailbox-filters select:focus { border-color:#f0ad9f; box-shadow:0 0 0 2px #ff806c12; }
.mailbox-rows { min-height:120px; flex:0 0 auto; }
.mailbox-row { width:100%; min-height:62px; padding:10px 15px 10px 13px; display:grid; grid-template-columns:20px 36px minmax(85px,140px) minmax(0,1fr) auto 28px; align-items:center; gap:10px 12px; border:0; border-bottom:1px solid #ebe7e3; border-left:2px solid transparent; background:white; color:#4a4652; text-align:left; cursor:pointer; transition:background .15s ease,border-color .15s ease; }
.mailbox-row:focus-visible { outline:2px solid #f0ad9f; outline-offset:-2px; }
.sender-avatar { width:36px; height:36px; border-radius:50%; display:grid; place-items:center; color:white; font-size:11px; font-weight:700; letter-spacing:.02em; flex-shrink:0; user-select:none; line-height:1; }
.sender-avatar img { width:100%; height:100%; border-radius:50%; object-fit:cover; display:block; }
.mailbox-avatar { align-self:center; }
.mailbox-row:hover { background:#faf8f6; border-left-color:var(--coral); }
.mailbox-row.selected { background:#fff8f5; border-left-color:var(--coral); }
.mailbox-read-dot { display:grid; place-items:center; color:#9a959f; }
.mailbox-read-dot i { width:7px; height:7px; display:block; border-radius:50%; background:var(--coral); box-shadow:0 0 0 3px #fff0eb; }
.mailbox-sender,.mailbox-summary strong,.mailbox-summary span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mailbox-sender { font-size:12px; font-weight:500; color:#3a3642; }
.mailbox-summary { min-width:0; display:flex; gap:6px; align-items:baseline; font-size:12px; }
.mailbox-summary strong { min-width:0; max-width:100%; flex:0 1 auto; font-weight:500; color:#2a2733; }
.mailbox-summary span { min-width:0; flex:1 1 0; color:#7a7580; font-weight:400; }
.mailbox-summary b { color:#9a959f; font-weight:400; }
.mailbox-row.unread .mailbox-sender,.mailbox-row.unread .mailbox-summary strong { color:var(--ink); font-weight:700; }
.mailbox-row.unread .mailbox-summary span { color:#5c5763; font-weight:400; }
.mailbox-row time { color:#8f8a94; font-size:10px; white-space:nowrap; }
.mailbox-star { width:28px; height:28px; padding:0; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#c5c0c8; cursor:pointer; transition:color .15s ease,background .15s ease; }
.mailbox-star:hover,.mailbox-star.active { color:#e8a317; background:#fff8e8; }
.mailbox-row.starred .mailbox-star { color:#e8a317; }
.mailbox-list-footer { margin-top:auto; padding:18px 20px 20px; display:flex; align-items:center; justify-content:space-between; gap:16px; border-top:1px solid #ebe7e3; background:linear-gradient(180deg,#fcfbfa 0%,#f8f7f5 100%); color:#8f8a94; font-size:11px; }
.mailbox-list-footer p { margin:0; color:#5c5763; font-weight:600; }
.mailbox-list-footer span { color:#a09aa4; font-size:10px; }
.mailbox-state { min-height:360px; padding:30px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#8d8891; text-align:center; font-size:11px; }
.mailbox-state.empty span { width:46px; height:46px; margin-bottom:13px; display:grid; place-items:center; border-radius:14px; color:var(--coral); background:#fff0eb; }
.mailbox-state.empty strong { color:#393541; font:600 15px "Manrope"; }
.mailbox-state p { margin:7px 0; }
.mailbox-state.error { color:#a94f40; }
.mailbox-state button { margin-top:8px; padding:7px 12px; border:0; border-radius:8px; background:var(--ink); color:white; font-size:10px; }
.mailbox-pagination { min-height:52px; padding:8px 14px; display:flex; align-items:center; justify-content:space-between; border-top:1px solid var(--line); color:#99949d; font-size:9px; }
.mailbox-pagination button { height:32px; padding:0 9px; display:flex; align-items:center; gap:4px; border:1px solid #e6e1dd; border-radius:8px; background:white; color:#5f5a64; font-size:9px; font-weight:700; }
.mailbox-pagination button:disabled { opacity:.4; cursor:not-allowed; }
.sticky-notes-header { min-height:64px; padding:11px 18px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #ebe7e3; background:#fffefd; }
.sticky-notes-title { display:flex; align-items:center; gap:8px; color:#5c5763; }
.sticky-notes-title>svg { transform:rotate(-24deg); transform-origin:center; }
.sticky-notes-title h2 { margin:0; font:700 11px "DM Sans"; letter-spacing:.9px; text-transform:uppercase; color:#4a4652; }
.sticky-notes-actions button { width:34px; height:34px; padding:0; display:grid; place-items:center; border:0; border-radius:9px; background:transparent; color:#77717a; cursor:pointer; }
.sticky-notes-actions button:hover { background:#fff0eb; color:#bd5947; }
.sticky-notes-body { flex:1; padding:20px 16px 18px; display:flex; flex-direction:column; gap:13px; overflow:auto; }
.sticky-notes-state { padding:24px 12px; color:#8d8891; font-size:11px; text-align:center; }
.sticky-notes-state.error { color:#a94f40; }
.sticky-note { height:132px; min-height:132px; position:relative; isolation:isolate; padding:19px 16px 14px; display:flex; flex-direction:column; border:1px solid #ffffffa8; border-radius:5px 7px 8px 6px; transform:rotate(-.45deg); box-shadow:0 2px 3px #31282120,0 8px 15px #31282116,0 18px 34px #3128210b; transition:transform .16s ease,box-shadow .16s ease; }
.sticky-note:nth-of-type(even) { transform:rotate(.5deg); }
.sticky-note:hover { transform:rotate(0) translateY(-2px); box-shadow:0 3px 4px #31282124,0 11px 20px #31282119,0 22px 38px #3128210e; }
.sticky-note::before { content:""; position:absolute; inset:0; z-index:-1; border-radius:inherit; background:linear-gradient(115deg,#ffffff38 0%,transparent 28%,transparent 72%,#00000008 100%); pointer-events:none; }
.sticky-note::after { content:""; position:absolute; z-index:-2; right:7px; bottom:-5px; width:45%; height:12px; border-radius:0 0 9px 45%; background:#443a3224; filter:blur(4px); transform:skewX(-16deg) rotate(-1deg); transform-origin:right; }
.sticky-note-pin { position:absolute; z-index:2; top:-13px; left:50%; width:34px; height:31px; overflow:hidden; transform:translateX(-50%) rotate(-7deg); filter:drop-shadow(1px 3px 2px #3028204a); pointer-events:none; }
.sticky-note-pin img { width:34px; height:34px; display:block; object-fit:cover; object-position:center top; clip-path:inset(0 0 10% 0); }
.sticky-note-header { flex:0 0 auto; display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:9px; }
.sticky-note-header h3 { margin:0; font:700 14px "Manrope"; color:#252230; letter-spacing:-.25px; line-height:1.25; }
.sticky-note-menu-wrap { position:relative; flex-shrink:0; }
.sticky-note-menu-btn { width:26px; height:26px; padding:0; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#8f8a94; cursor:pointer; }
.sticky-note-menu-btn:hover { background:#ffffff80; color:#5c5763; }
.sticky-note-menu { position:absolute; top:100%; right:0; z-index:2; min-width:120px; padding:4px; border:1px solid #e6e2de; border-radius:9px; background:white; box-shadow:0 8px 24px #08082118; }
.sticky-note-menu button { width:100%; padding:8px 10px; display:flex; align-items:center; gap:7px; border:0; border-radius:7px; background:transparent; color:#a94f40; font-size:11px; font-weight:600; cursor:pointer; text-align:left; }
.sticky-note-menu button:hover { background:#fff5f3; }
.sticky-note p { min-height:0; margin:0 0 10px; padding-right:4px; flex:1 1 auto; overflow:auto; color:#6b6872; font-size:11px; font-weight:400; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; scrollbar-width:thin; scrollbar-color:#8f8a9442 transparent; }
.sticky-note p::-webkit-scrollbar { width:4px; }
.sticky-note p::-webkit-scrollbar-thumb { border-radius:999px; background:#8f8a9442; }
.sticky-note-footer { margin-top:auto; flex:0 0 auto; display:flex; align-items:end; justify-content:space-between; gap:10px; }
.sticky-note-tags { display:flex; flex-wrap:wrap; gap:5px; }
.sticky-note-tags span { padding:3px 8px; border-radius:999px; font-size:9px; font-weight:700; letter-spacing:.2px; }
.sticky-note-footer time { color:#8f8a94; font-size:9px; white-space:nowrap; }
.sticky-note-yellow { background:linear-gradient(145deg,#fff9df,#fff4c9); }
.sticky-note-yellow .sticky-note-tags span { background:#fff3c4; color:#b8860b; }
.sticky-note-green { background:linear-gradient(145deg,#f0fae9,#e2f3d7); }
.sticky-note-green .sticky-note-tags span { background:#d8f0dc; color:#2d7a38; }
.sticky-note-blue { background:linear-gradient(145deg,#edf7ff,#ddebfa); }
.sticky-note-blue .sticky-note-tags span { background:#d5e8ff; color:#2a6db0; }
.sticky-note-pink { background:linear-gradient(145deg,#fff1f4,#f9dfe5); }
.sticky-note-pink .sticky-note-tags span { background:#fce0ea; color:#c44d75; }
.sticky-note-add { width:100%; min-height:68px; padding:10px 14px; display:grid; grid-template-columns:24px auto; grid-template-rows:auto auto; column-gap:8px; align-content:center; justify-content:center; align-items:center; border:1.5px dashed #d1cbc5; border-radius:10px; background:#fffcfa80; color:#8f8a94; cursor:pointer; transition:border-color .15s ease,background .15s ease; }
.sticky-note-add>svg { grid-row:1/3; }
.sticky-note-add:hover { border-color:#c5bdb4; background:white; color:#5c5763; }
.sticky-note-add span { align-self:end; font-size:11px; font-weight:700; color:#5c5763; text-align:left; }
.sticky-note-add small { align-self:start; font-size:9px; color:#aaa4ac; text-align:left; }
.sticky-note-form { padding:14px; display:grid; gap:10px; border:1px solid #e6e2de; border-radius:12px; background:white; box-shadow:0 4px 16px #0808210c; }
.sticky-note-form input,.sticky-note-form textarea { width:100%; padding:9px 10px; border:1px solid #e4dfdc; border-radius:8px; outline:0; background:#fcfbfa; color:#393745; font:500 11px "DM Sans"; resize:vertical; }
.sticky-note-form input:focus,.sticky-note-form textarea:focus { border-color:#f0ad9f; box-shadow:0 0 0 2px #ff806c12; }
.sticky-note-colors { display:flex; gap:8px; }
.sticky-note-color { width:22px; height:22px; padding:0; border:2px solid transparent; border-radius:50%; cursor:pointer; }
.sticky-note-color[data-color="yellow"] { background:#fff3c4; }
.sticky-note-color[data-color="green"] { background:#d8f0dc; }
.sticky-note-color[data-color="blue"] { background:#d5e8ff; }
.sticky-note-color[data-color="pink"] { background:#fce0ea; }
.sticky-note-color.active { border-color:#393745; box-shadow:0 0 0 2px white; }
.sticky-note-form-actions { display:flex; justify-content:flex-end; gap:8px; }
.sticky-note-form-actions button { height:32px; padding:0 12px; border:0; border-radius:8px; font-size:10px; font-weight:700; cursor:pointer; }
.sticky-note-form-actions button[type="button"] { background:#f3f0ed; color:#5c5763; }
.sticky-note-form-actions button[type="submit"] { background:var(--ink); color:white; }
.sticky-note-form-actions button:disabled { opacity:.5; cursor:not-allowed; }
.mail-detail { min-width:0; display:none; position:absolute; inset:0; z-index:4; overflow:auto; border-left:0; background:#fffefd; }
.mail-detail.open { display:block; }
.mail-detail-toolbar { min-height:62px; padding:10px 13px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
.mail-detail-toolbar>div { display:flex; gap:3px; }
.mail-detail-toolbar button { width:36px; padding:0; }
.mail-detail-toolbar button.danger { color:#bb4e43; }
.mail-detail-close { display:none!important; }
.mail-detail-state { min-height:320px; padding:30px; display:grid; place-items:center; color:#8d8891; font-size:11px; text-align:center; }
.mail-detail-state.error { color:#a94f40; }
.mail-detail-content { padding:26px clamp(19px,3vw,34px) 40px; }
.mail-detail-content h2 { margin:0 0 22px; overflow-wrap:anywhere; font:600 21px/1.3 "Manrope"; letter-spacing:-.7px; }
.mail-detail-meta { padding-bottom:19px; display:flex; align-items:start; justify-content:space-between; gap:14px; border-bottom:1px solid var(--line); }
.mail-detail-meta>div { min-width:0; display:grid; gap:3px; }
.mail-detail-sender { display:flex; align-items:center; gap:10px; min-width:0; }
.mail-detail-sender .sender-avatar { width:40px; height:40px; font-size:12px; flex-shrink:0; }
.mail-detail-sender strong { overflow:hidden; text-overflow:ellipsis; font-size:11px; }
.mail-detail-meta strong { overflow:hidden; text-overflow:ellipsis; font-size:11px; }
.mail-detail-meta small,.mail-detail-meta time { color:#928d96; font-size:8px; line-height:1.5; }
.mail-detail-meta time { white-space:nowrap; }
.mail-detail-body { padding-top:23px; color:#383440; font-size:12px; line-height:1.75; white-space:pre-wrap; overflow-wrap:anywhere; }
.compose-overlay { position:fixed; inset:0; z-index:40; padding:24px; display:flex; align-items:center; justify-content:center; background:#08082175; backdrop-filter:blur(5px); }
.compose-modal { width:min(760px,100%); max-height:calc(100vh - 48px); overflow:auto; border:1px solid #eee7e2; border-radius:22px; background:#fffefd; box-shadow:0 32px 90px #08082150; }
.compose-header { min-height:76px; padding:0 24px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:linear-gradient(100deg,#fff6f2,#fffefd); }
.compose-header>div { display:flex; align-items:center; gap:11px; }
.compose-header>div>span { width:34px; height:34px; display:grid; place-items:center; border-radius:11px; background:var(--ink); color:var(--coral); }
.compose-header h2 { margin:0; font:600 17px "Manrope"; letter-spacing:-.5px; }
.compose-header small { display:block; margin-top:2px; color:#97919a; font-size:9px; }
.compose-header>button { width:35px; height:35px; display:grid; place-items:center; border:0; border-radius:10px; background:transparent; color:#77717a; }
.compose-header>button:hover { background:#f4eeea; color:var(--ink); }
.compose-form { padding:0 24px 22px; }
.compose-recipient-row { min-height:55px; display:grid; grid-template-columns:28px 1fr auto; align-items:center; gap:9px; border-bottom:1px solid var(--line); }
.compose-recipient-row label,.compose-copy-fields label { color:#77717a; font-size:11px; font-weight:700; }
.compose-recipient-row input,.compose-copy-fields input,.compose-subject,.compose-ai input { min-width:0; border:0; outline:0; background:transparent; color:var(--ink); font:500 12px "DM Sans"; }
.compose-recipient-row>button { padding:6px 8px; border:0; border-radius:7px; background:transparent; color:#847e87; font-size:10px; font-weight:700; }
.compose-recipient-row>button:hover { background:var(--blush); color:#bc5948; }
.compose-copy-fields { display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid var(--line); }
.compose-copy-fields label { min-height:48px; padding-right:18px; display:grid; grid-template-columns:32px 1fr; align-items:center; }
.compose-copy-fields label+label { padding-left:18px; border-left:1px solid var(--line); }
.compose-subject { width:100%; height:52px; border-bottom:1px solid var(--line); font-size:13px; }
.compose-format-row { padding:12px 0; display:flex; align-items:center; justify-content:space-between; }
.compose-format-row>span { color:#97919a; font-size:9px; font-weight:700; letter-spacing:.8px; text-transform:uppercase; }
.compose-format-row>div { padding:3px; display:flex; border-radius:9px; background:#f3efec; }
.compose-format-row button { min-width:56px; height:27px; border:0; border-radius:7px; background:transparent; color:#85808a; font-size:9px; font-weight:700; }
.compose-format-row button.active { background:white; color:var(--ink); box-shadow:0 3px 9px #241a2b18; }
.compose-body { width:100%; min-height:210px; padding:17px; resize:vertical; border:1px solid #ebe5e1; outline:0; border-radius:13px; background:white; color:var(--ink); font:400 13px/1.65 "DM Sans"; }
.compose-body:focus { border-color:#f0ad9f; box-shadow:0 0 0 3px #ff806c14; }
.compose-tools { padding:9px 3px; display:flex; gap:3px; border-bottom:1px solid var(--line); }
.compose-tools button { width:32px; height:30px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#77717a; }
.compose-tools button:hover { background:#fff0eb; color:#c25c49; }
.compose-tools button:disabled { opacity:.3; cursor:not-allowed; }
.compose-tools button:disabled:hover { background:transparent; color:#77717a; }
.compose-tools>span { margin-left:7px; align-self:center; color:#aaa4ac; font-size:8px; }
.compose-ai { min-height:58px; margin:14px 0 10px; padding:8px 9px 8px 14px; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; border:1px solid #e9e4ef; border-radius:14px; background:linear-gradient(100deg,#f5f4fb,#fff6f2); }
.compose-ai>svg { color:#7463a8; }
.compose-ai button { min-height:35px; padding:0 13px; border:0; border-radius:9px; background:var(--ink); color:white; font-size:10px; font-weight:700; }
.compose-ai button:disabled { opacity:.45; cursor:not-allowed; }
.compose-error { margin:8px 0; color:#b54f3d; font-size:10px; }
.compose-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; }
.compose-footer>span { color:#99939b; font-size:9px; }
.compose-send { min-width:105px; min-height:41px; display:flex; align-items:center; justify-content:center; gap:8px; border:0; border-radius:10px; background:var(--coral); color:var(--ink); box-shadow:0 9px 20px #ff806c3b; font-weight:700; }
.compose-send:hover { transform:translateY(-1px); box-shadow:0 13px 25px #ff806c57; }
.compose-send:disabled { opacity:.6; cursor:wait; transform:none; }
@media(max-width:900px) {
.web-view { display:none; }.mobile-view { display:block; }
.mobile-landing { min-height:100vh; overflow:hidden; padding-bottom:28px; background:radial-gradient(circle at 90% 36%,#f3efd0,transparent 27%),#fffdfb; }
.mobile-nav { height:72px; padding:0 22px; display:flex; align-items:center; justify-content:space-between; }
.icon-button { width:39px; height:39px; display:grid; place-items:center; border:1px solid #e8e3df; border-radius:11px; background:white; }
.mobile-hero { padding:42px 24px 27px; display:flex; flex-direction:column; align-items:center; text-align:center; }
.mobile-hero h1 { margin:21px 0 16px; font-size:clamp(43px,13vw,61px); letter-spacing:-3.5px; }
.mobile-hero>p { max-width:420px; margin:0 0 26px; color:var(--muted); font-size:15px; line-height:1.6; }
.mobile-hero .button { width:min(100%,330px); }
.mobile-login { margin-top:15px; border:0; background:none; color:#7e7a84; font-size:12px; }.mobile-login strong { color:var(--ink); }
.phone-preview { width:min(86%,390px); margin:34px auto 0; padding:11px 16px 14px; position:relative; border:5px solid white; border-radius:31px; background:#fffefd; box-shadow:0 28px 70px #32283733; transform:rotate(-1deg); }
.phone-preview:before { content:""; position:absolute; z-index:-1; inset:-22px -45px 20px; border-radius:42% 45% 40% 44%; background:linear-gradient(145deg,#ffe0cf,#f6efc9 55%,#cfedf0); }
.phone-top { height:30px; padding-inline:6px; display:flex; align-items:center; justify-content:space-between; font-size:8px; font-weight:700; }
.phone-pill { width:61px; height:18px; border-radius:99px; background:var(--ink); }
.phone-preview>header { padding:9px 6px; display:flex; align-items:center; justify-content:space-between; }.phone-preview>header div { display:grid; }.phone-preview>header small { color:#99949c; font-size:8px; }.phone-preview>header strong { font-size:13px; }.phone-preview>header .avatar { margin:0; }
.mobile-summary { margin:10px 3px 18px; padding:11px; display:grid; grid-template-columns:35px 1fr auto; align-items:center; gap:10px; border:1px solid #ffded5; border-radius:13px; background:#fff1ed; }
.mobile-summary div { display:grid; }.mobile-summary strong { font-size:10px; }.mobile-summary small { color:#8d8183; font-size:8px; }
.mobile-inbox-title { padding-inline:4px; display:flex; align-items:center; justify-content:space-between; }.mobile-inbox-title h3 { margin:0; font:600 16px "Manrope"; }.mobile-inbox-title span { color:var(--coral); font-size:8px; }
.mobile-mail { padding:12px 5px; }.mobile-mail strong { display:block; font-size:9px; }.mobile-mail p { margin:3px 0 0; color:#85818a; font-size:8px; }.mobile-mail time { color:#aaa5aa; font-size:7px; }
.mobile-tabbar { height:49px; margin-top:10px; display:flex; align-items:center; justify-content:space-around; border-radius:14px; background:var(--ink); color:#9291a4; }.mobile-tabbar .active { color:var(--coral); }.mobile-tabbar button { width:37px; height:37px; display:grid; place-items:center; border:0; border-radius:12px; background:var(--coral); font-size:23px; }
.mobile-benefits { margin:38px auto 0; display:flex; justify-content:center; gap:18px; color:#77727d; font-size:10px; }
.auth-nav { width:calc(100% - 40px); height:72px; }
.auth-shell { width:min(100% - 32px,560px); min-height:auto; padding:28px 0 50px; display:block; }
.auth-story { margin-bottom:28px; text-align:center; display:flex; flex-direction:column; align-items:center; }
.auth-story h1 { margin:18px 0 13px; font-size:clamp(40px,11vw,54px); }
.auth-story>p { margin:0; font-size:14px; }
.auth-points { display:none; }
.auth-card { padding:25px 21px; border-radius:20px; }
.back-link { font-size:0; }.back-link svg { width:20px; height:20px; }
.dashboard { display:block; }
.dashboard-mobile-header { height:70px; padding:0 20px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:#fffefd; }
.dashboard-sidebar { width:min(290px,85vw); position:fixed; left:0; transform:translateX(-105%); transition:transform .25s ease; box-shadow:18px 0 45px #18111f24; }
.dashboard-sidebar.open { transform:translateX(0); }
.dashboard-brand-row>button { display:grid; }
.dashboard-scrim { display:block; position:fixed; inset:0; z-index:11; border:0; background:#08082155; }
.dashboard-content { padding:32px 20px; }
.dashboard-empty { min-height:340px; }
.mailbox-workspace { min-height:auto; display:block; }
.mailbox-shell { display:block; }
.mailbox-notes-pane { display:none; }
.mailbox-list-pane { border-right:0; }
.mailbox-shell.detail-visible .mailbox-toolbar,
.mailbox-shell.detail-visible .mailbox-filters,
.mailbox-shell.detail-visible .mailbox-rows,
.mailbox-shell.detail-visible .mailbox-list-footer,
.mailbox-shell.detail-visible .mailbox-pagination { display:none; }
.mailbox-shell.detail-visible .mailbox-list-pane { display:flex; min-height:520px; }
.mail-detail { position:static; flex:1; }
.mail-detail-close { display:flex!important; }
.compose-overlay { padding:12px; align-items:flex-end; }
.compose-modal { max-height:calc(100vh - 24px); border-radius:19px 19px 12px 12px; }
}
@media(max-width:480px) {
.account-types,.field-row { grid-template-columns:1fr; }.auth-story h1 br { display:none; }
.compose-overlay { padding:0; }
.compose-modal { width:100%; max-height:100vh; border:0; border-radius:0; }
.compose-header { min-height:64px; padding:0 16px; }
.compose-form { padding:0 15px 16px; }
.compose-copy-fields { grid-template-columns:1fr; }
.compose-copy-fields label+label { padding-left:0; border-left:0; border-top:1px solid var(--line); }
.compose-body { min-height:32vh; }
.compose-ai { grid-template-columns:auto 1fr; }
.compose-ai button { grid-column:1/-1; }
.compose-footer>span { display:none; }
.compose-send { width:100%; }
.dashboard-content-heading { align-items:center; }
.dashboard-content-heading h1 { font-size:34px; }
.dashboard-account-pill { display:none; }
.mailbox-shell { margin-top:20px; border-radius:14px; }
.mailbox-toolbar { padding:9px; }
.mailbox-toolbar>button span { display:none; }
.mailbox-filters { grid-template-columns:1fr 1fr; }
.mailbox-row { min-height:70px; padding:10px 11px; grid-template-columns:17px 32px minmax(0,1fr) auto 26px; gap:8px 9px; }
.sender-avatar.mailbox-avatar { width:32px; height:32px; font-size:10px; }
.mailbox-sender { grid-column:3; align-self:end; }
.mailbox-summary { grid-column:3/5; display:flex; align-self:start; }
.mailbox-summary strong { max-width:100%; }
.mailbox-row time { grid-column:4; grid-row:1; align-self:end; }
.mailbox-star { grid-column:5; grid-row:1; align-self:center; width:26px; height:26px; }
.mailbox-read-dot { grid-row:1/3; }
.mailbox-avatar { grid-row:1/3; align-self:center; }
}
@media(max-width:380px) { .mobile-hero { padding-top:25px; }.mobile-hero h1 { font-size:41px; }.phone-preview { width:90%; } }
.dns-results { margin-top:22px; display:grid; gap:18px; }
.dns-summary { margin:0; color:#4f4f62; font-size:12px; line-height:1.5; }
.dns-notes { margin:0; padding-left:18px; color:#74717c; font-size:11px; line-height:1.55; }
.dns-section h3 { margin:0 0 10px; font:600 15px "Manrope"; letter-spacing:-.3px; }
.dns-table-wrap { overflow:auto; border:1px solid var(--line); border-radius:12px; background:#fff; }
.dns-table { width:100%; border-collapse:collapse; font-size:11px; }
.dns-table th,.dns-table td { padding:10px 12px; border-bottom:1px solid #f2eeeb; text-align:left; vertical-align:top; }
.dns-table th { color:#74717c; font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.4px; background:#fcf9f7; }
.dns-table tr:last-child td { border-bottom:0; }
.dns-table code { font:500 11px ui-monospace,SFMono-Regular,Menlo,monospace; word-break:break-all; }
.dns-value-cell { display:flex; align-items:flex-start; gap:8px; }
.dns-copy { width:28px; height:28px; flex:0 0 auto; display:grid; place-items:center; border:1px solid #ece7e3; border-radius:8px; background:white; color:#67677a; }
.link-button { border:0; background:transparent; color:var(--coral); font:inherit; font-weight:600; cursor:pointer; padding:0; }
@media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto!important; transition:none!important; } }

View file

@ -0,0 +1,63 @@
import colorMap from "../data/az_2_letter_color_map.json";
const FALLBACK_COLOR = "#5c5763";
export function addressText(sender) {
if (typeof sender === "string") return sender;
return sender?.email || sender?.address || "";
}
export function senderDisplayName(email) {
const displayName =
email.sender_display_name ||
email.sender_name ||
email.from_name ||
(typeof email.sender === "object" ? email.sender?.name : "");
const address = addressText(email.sender || email.from);
return displayName?.trim() || address.split("@")[0] || "Unknown sender";
}
/** Two-letter key for color lookup: first + last word, or first two chars. */
export function initialsFromName(name) {
const cleaned = String(name || "")
.trim()
.replace(/[^a-zA-Z\s'-]/g, " ");
const parts = cleaned.split(/\s+/).filter(Boolean);
if (parts.length >= 2) {
const first = parts[0][0] || "";
const last = parts[parts.length - 1][0] || "";
return `${first}${last}`.toUpperCase();
}
const word = parts[0] || "?";
const letters = word.replace(/[^a-zA-Z]/g, "");
if (letters.length >= 2) {
return letters.slice(0, 2).toUpperCase();
}
if (letters.length === 1) {
return `${letters[0]}${letters[0]}`.toUpperCase();
}
return "??";
}
export function colorForInitials(initials) {
const key = String(initials || "").toUpperCase().slice(0, 2);
return colorMap[key] || FALLBACK_COLOR;
}
/** Registered-user avatar URL hook — wire when profile photos exist. */
export function senderAvatarUrl(email) {
return email.sender_avatar_url || email.sender?.avatar_url || null;
}
export function senderAvatar(email) {
const name = senderDisplayName(email);
const initials = initialsFromName(name);
return {
name,
initials,
color: colorForInitials(initials),
imageUrl: senderAvatarUrl(email),
};
}

17
smtp/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM alpine:3.21
RUN apk add --no-cache postfix openssl
COPY main.cf /etc/postfix/main.cf
COPY master.cf /etc/postfix/master.cf
COPY entrypoint.sh /entrypoint.sh
RUN chmod 0644 /etc/postfix/main.cf /etc/postfix/master.cf && \
chmod 0755 /entrypoint.sh && \
mkdir -p /var/spool/postfix/pid
EXPOSE 25
HEALTHCHECK --interval=10s --timeout=3s --retries=5 \
CMD postfix status >/dev/null 2>&1 || exit 1
CMD ["/entrypoint.sh"]

142
smtp/entrypoint.sh Normal file
View file

@ -0,0 +1,142 @@
#!/bin/sh
set -eu
: "${MAIL_HOSTNAME:=mail.getall.help}"
: "${MAIL_DOMAIN:=getall.help}"
: "${INBOUND_DOMAINS:=${MAIL_DOMAIN}}"
: "${RELAY_DOMAINS_FILE:=/data/relay_domains/domains.list}"
: "${RELAY_DOMAINS_REFRESH:=60}"
: "${BACKEND_INGEST_HOST:=backend}"
: "${BACKEND_INGEST_PORT:=2525}"
case "${MAIL_HOSTNAME}" in
""|*[!A-Za-z0-9._-]*)
echo "MAIL_HOSTNAME contains invalid characters" >&2
exit 1
;;
esac
case "${BACKEND_INGEST_HOST}" in
""|*[!A-Za-z0-9._-]*)
echo "BACKEND_INGEST_HOST must be an IPv4 address or DNS hostname" >&2
exit 1
;;
esac
case "${BACKEND_INGEST_PORT}" in
""|*[!0-9]*)
echo "BACKEND_INGEST_PORT must be numeric" >&2
exit 1
;;
esac
if [ "${BACKEND_INGEST_PORT}" -lt 1 ] || [ "${BACKEND_INGEST_PORT}" -gt 65535 ]; then
echo "BACKEND_INGEST_PORT must be between 1 and 65535" >&2
exit 1
fi
validate_domain() {
domain="$1"
printf '%s\n' "${domain}" | awk '
/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/ {
valid = 1
}
END { exit !valid }
'
}
install_relay_maps() {
relay_domains_tmp="$(mktemp)"
transport_tmp="$(mktemp)"
seen_tmp="$(mktemp)"
add_domain() {
raw_domain="$1"
domain="$(printf '%s' "${raw_domain}" | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')"
if [ -z "${domain}" ]; then
return 0
fi
if ! validate_domain "${domain}"; then
echo "Skipping invalid relay domain: ${raw_domain}" >&2
return 0
fi
if grep -Fxq "${domain}" "${seen_tmp}" 2>/dev/null; then
return 0
fi
printf '%s\n' "${domain}" >> "${seen_tmp}"
printf '%s OK\n' "${domain}" >> "${relay_domains_tmp}"
printf '%s smtp:[%s]:%s\n' \
"${domain}" "${BACKEND_INGEST_HOST}" "${BACKEND_INGEST_PORT}" \
>> "${transport_tmp}"
}
old_ifs="${IFS}"
IFS=','
for raw_domain in ${INBOUND_DOMAINS}; do
add_domain "${raw_domain}"
done
IFS="${old_ifs}"
if [ -f "${RELAY_DOMAINS_FILE}" ]; then
while IFS= read -r line || [ -n "${line}" ]; do
add_domain "${line}"
done < "${RELAY_DOMAINS_FILE}"
fi
if [ ! -s "${relay_domains_tmp}" ]; then
rm -f "${relay_domains_tmp}" "${transport_tmp}" "${seen_tmp}"
echo "At least one inbound relay domain is required" >&2
return 1
fi
install -m 0644 "${relay_domains_tmp}" /etc/postfix/relay_domains
install -m 0644 "${transport_tmp}" /etc/postfix/transport
postmap /etc/postfix/relay_domains
postmap /etc/postfix/transport
rm -f "${relay_domains_tmp}" "${transport_tmp}" "${seen_tmp}"
}
install_relay_maps
postconf -e "myhostname = ${MAIL_HOSTNAME}"
postconf -e "mydomain = ${MAIL_DOMAIN}"
postconf -e "myorigin = \$mydomain"
# STARTTLS cert for inbound providers (Gmail, Zoho, etc.)
mkdir -p /etc/postfix/certs
if [ ! -f /etc/postfix/certs/smtpd.crt ]; then
openssl req -new -x509 -days 825 -nodes \
-subj "/CN=${MAIL_HOSTNAME}" \
-keyout /etc/postfix/certs/smtpd.key \
-out /etc/postfix/certs/smtpd.crt
chmod 600 /etc/postfix/certs/smtpd.key
chmod 644 /etc/postfix/certs/smtpd.crt
fi
# Postfix chroot cannot see Docker's resolver unless we copy these in.
mkdir -p /var/spool/postfix/etc
mkdir -p /var/spool/postfix/etc/postfix/certs
cp /etc/postfix/certs/smtpd.crt /var/spool/postfix/etc/postfix/certs/smtpd.crt
cp /etc/postfix/certs/smtpd.key /var/spool/postfix/etc/postfix/certs/smtpd.key
chmod 644 /var/spool/postfix/etc/postfix/certs/smtpd.crt
chmod 600 /var/spool/postfix/etc/postfix/certs/smtpd.key
for file in resolv.conf hosts nsswitch.conf; do
if [ -f "/etc/${file}" ]; then
cp "/etc/${file}" "/var/spool/postfix/etc/${file}"
fi
done
postfix check
if [ "${RELAY_DOMAINS_REFRESH}" -gt 0 ] 2>/dev/null; then
(
while true; do
sleep "${RELAY_DOMAINS_REFRESH}"
if install_relay_maps; then
postfix reload >/dev/null 2>&1 || true
fi
done
) &
fi
exec postfix start-fg

43
smtp/main.cf Normal file
View file

@ -0,0 +1,43 @@
# Internet-facing inbound relay + trusted outbound for Agmail.
compatibility_level = 3.6
myhostname = mail.getall.help
mydomain = getall.help
myorigin = $mydomain
inet_interfaces = all
inet_protocols = ipv4
mydestination =
local_recipient_maps =
local_transport = error:local delivery disabled
relay_domains = lmdb:/etc/postfix/relay_domains
transport_maps = lmdb:/etc/postfix/transport
# Internet clients may only send to relay_domains (inbound).
# Docker/backend clients in mynetworks may relay outbound to the public internet.
mynetworks = 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
smtpd_relay_restrictions = permit_mynetworks, reject_unauth_destination
smtpd_recipient_restrictions = permit_mynetworks, reject_unauth_destination
# Match the backend's 25 MiB inbound limit.
message_size_limit = 26214400
mailbox_size_limit = 0
# Keep transient backend failures queued for retry.
minimal_backoff_time = 60s
maximal_backoff_time = 3600s
maximal_queue_lifetime = 1d
bounce_queue_lifetime = 1d
# Opportunistic TLS for internet senders and the backend hop.
# Gmail/Zoho require a working STARTTLS offer; certs are generated in entrypoint.sh.
smtpd_tls_cert_file = /etc/postfix/certs/smtpd.crt
smtpd_tls_key_file = /etc/postfix/certs/smtpd.key
smtpd_tls_security_level = may
smtpd_tls_loglevel = 1
smtp_tls_security_level = may
# Log directly to the container output.
maillog_file = /dev/stdout
alias_maps =
alias_database =

25
smtp/master.cf Normal file
View file

@ -0,0 +1,25 @@
smtp inet n - n - - smtpd
pickup unix n - n 60 1 pickup
cleanup unix n - n - 0 cleanup
qmgr unix n - n 300 1 qmgr
tlsmgr unix - - n 1000? 1 tlsmgr
rewrite unix - - n - - trivial-rewrite
bounce unix - - n - 0 bounce
defer unix - - n - 0 bounce
trace unix - - n - 0 bounce
verify unix - - n - 1 verify
flush unix n - n 1000? 0 flush
proxymap unix - - n - - proxymap
proxywrite unix - - n - 1 proxymap
smtp unix - - n - - smtp
relay unix - - n - - smtp
showq unix n - n - - showq
error unix - - n - - error
retry unix - - n - - error
discard unix - - n - - discard
local unix - n n - - local
virtual unix - n n - - virtual
lmtp unix - - n - - lmtp
anvil unix - - n - 1 anvil
scache unix - - n - 1 scache
postlog unix-dgram - - n - 1 postlog