first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal 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
244
DEPLOYMENT.md
Normal 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
5
backend/.dockerignore
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
.env
|
||||||
15
backend/Dockerfile
Normal file
15
backend/Dockerfile
Normal 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
1
backend/app/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Agmail API package."""
|
||||||
66
backend/app/config.py
Normal file
66
backend/app/config.py
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
app_name: str = "Agmail API"
|
||||||
|
environment: str = "development"
|
||||||
|
api_prefix: str = "/api/v1"
|
||||||
|
cors_origins: list[str] = ["http://localhost:5173"]
|
||||||
|
|
||||||
|
database_url: str = (
|
||||||
|
"postgresql+asyncpg://agmail:agmail_dev_password"
|
||||||
|
"@localhost:5432/agmail_profiles"
|
||||||
|
)
|
||||||
|
mongo_url: str = (
|
||||||
|
"mongodb://agmail:agmail_dev_password@localhost:27017"
|
||||||
|
"/?authSource=admin"
|
||||||
|
)
|
||||||
|
mongo_database: str = "agmail_messages"
|
||||||
|
jwt_secret: str = "replace-this-development-secret"
|
||||||
|
access_token_minutes: int = 60
|
||||||
|
|
||||||
|
platform_domain: str = "getall.help"
|
||||||
|
platform_spf_include: str = "_spf.getall.help"
|
||||||
|
app_public_host: str = "email.getall.help"
|
||||||
|
dkim_selector: str = "agmail"
|
||||||
|
|
||||||
|
# Outbound via self-hosted Postfix (Docker service `smtp` by default)
|
||||||
|
smtp_host: str = "smtp"
|
||||||
|
smtp_port: int = 25
|
||||||
|
smtp_public_host: str = "mail.getall.help"
|
||||||
|
smtp_username: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_use_tls: bool = False
|
||||||
|
smtp_timeout: int = 30
|
||||||
|
smtp_health_required: bool = False
|
||||||
|
relay_domains_file: str = "/data/relay_domains/domains.list"
|
||||||
|
|
||||||
|
inbound_smtp_enabled: bool = True
|
||||||
|
inbound_smtp_host: str = "127.0.0.1"
|
||||||
|
inbound_smtp_port: int = 2525
|
||||||
|
inbound_allowed_cidrs: list[str] = [
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
]
|
||||||
|
inbound_max_bytes: int = 25 * 1024 * 1024
|
||||||
|
gcs_raw_mime_bucket: str = ""
|
||||||
|
gcs_raw_mime_prefix: str = "raw"
|
||||||
|
gcs_project: str | None = None
|
||||||
|
|
||||||
|
dns_setup_username: str = "smtp-credentia"
|
||||||
|
dns_setup_password: str = "Nishtha#01"
|
||||||
|
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
27
backend/app/database.py
Normal file
27
backend/app/database.py
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
from pymongo import AsyncMongoClient
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
|
||||||
|
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
|
||||||
|
mongo_client: AsyncMongoClient = AsyncMongoClient(settings.mongo_url)
|
||||||
|
mongo_db = mongo_client[settings.mongo_database]
|
||||||
|
|
||||||
|
|
||||||
|
async def get_postgres_session() -> AsyncIterator[AsyncSession]:
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
yield session
|
||||||
|
|
||||||
|
|
||||||
|
def get_mongo_database():
|
||||||
|
return mongo_db
|
||||||
0
backend/app/deps/__init__.py
Normal file
0
backend/app/deps/__init__.py
Normal file
28
backend/app/deps/dns_setup_auth.py
Normal file
28
backend/app/deps/dns_setup_auth.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
security = HTTPBasic()
|
||||||
|
|
||||||
|
|
||||||
|
async def require_dns_setup_auth(
|
||||||
|
credentials: HTTPBasicCredentials = Depends(security),
|
||||||
|
) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
username_ok = secrets.compare_digest(
|
||||||
|
credentials.username,
|
||||||
|
settings.dns_setup_username,
|
||||||
|
)
|
||||||
|
password_ok = secrets.compare_digest(
|
||||||
|
credentials.password,
|
||||||
|
settings.dns_setup_password,
|
||||||
|
)
|
||||||
|
if not (username_ok and password_ok):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid DNS setup credentials.",
|
||||||
|
headers={"WWW-Authenticate": "Basic"},
|
||||||
|
)
|
||||||
185
backend/app/main.py
Normal file
185
backend/app/main.py
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Response, status
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import SessionFactory, engine, mongo_client, mongo_db
|
||||||
|
from app.models import Base
|
||||||
|
from app.routers import auth, dns_setup, domains, emails, sticky_notes
|
||||||
|
from app.services.domain_provisioning import ensure_platform_domain
|
||||||
|
from app.services.inbound_smtp import InboundSMTPServer
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
inbound_server = InboundSMTPServer()
|
||||||
|
|
||||||
|
|
||||||
|
async def migrate_legacy_user_tables(connection) -> None:
|
||||||
|
"""Promote the former auth table to users without discarding old profiles."""
|
||||||
|
tables = (
|
||||||
|
await connection.execute(
|
||||||
|
text(
|
||||||
|
"SELECT to_regclass('public.accounts'), "
|
||||||
|
"to_regclass('public.users'), "
|
||||||
|
"to_regclass('public.legacy_users')"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).one()
|
||||||
|
accounts_table, users_table, legacy_users_table = tables
|
||||||
|
|
||||||
|
if accounts_table is None:
|
||||||
|
return
|
||||||
|
if users_table is not None:
|
||||||
|
if legacy_users_table is not None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Cannot migrate accounts: both users and legacy_users already exist."
|
||||||
|
)
|
||||||
|
await connection.execute(text("ALTER TABLE users RENAME TO legacy_users"))
|
||||||
|
await connection.execute(text("ALTER TABLE accounts RENAME TO users"))
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_: FastAPI):
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await migrate_legacy_user_tables(connection)
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
await ensure_platform_domain(session)
|
||||||
|
await session.commit()
|
||||||
|
await mongo_db.emails.create_index([("recipients", 1), ("created_at", -1)])
|
||||||
|
await mongo_db.emails.create_index([("cc", 1), ("created_at", -1)])
|
||||||
|
await mongo_db.emails.create_index([("bcc", 1), ("created_at", -1)])
|
||||||
|
await mongo_db.emails.create_index([("sender", 1), ("created_at", -1)])
|
||||||
|
await mongo_db.emails.create_index([("account_id", 1), ("created_at", -1)])
|
||||||
|
await mongo_db.messages.create_index(
|
||||||
|
[("delivery_key", 1)],
|
||||||
|
unique=True,
|
||||||
|
sparse=True,
|
||||||
|
)
|
||||||
|
await mongo_db.message_tombstones.create_index(
|
||||||
|
[("delivery_key", 1)],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
await mongo_db.messages.create_index(
|
||||||
|
[("subject", "text"), ("snippet", "text"), ("sender.name", "text")]
|
||||||
|
)
|
||||||
|
await mongo_db.mailbox_entries.create_index(
|
||||||
|
[
|
||||||
|
("account_id", 1),
|
||||||
|
("mailbox", 1),
|
||||||
|
("trashed_at", 1),
|
||||||
|
("deletion_state", 1),
|
||||||
|
("created_at", -1),
|
||||||
|
("_id", -1),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await mongo_db.mailbox_entries.create_index(
|
||||||
|
[("account_id", 1), ("is_read", 1), ("created_at", -1), ("_id", -1)]
|
||||||
|
)
|
||||||
|
await mongo_db.mailbox_entries.create_index(
|
||||||
|
[("account_id", 1), ("message_id", 1), ("mailbox", 1)],
|
||||||
|
unique=True,
|
||||||
|
)
|
||||||
|
await mongo_db.mailbox_entries.create_index(
|
||||||
|
[("account_id", 1), ("is_starred", 1), ("created_at", -1), ("_id", -1)]
|
||||||
|
)
|
||||||
|
await mongo_db.sticky_notes.create_index(
|
||||||
|
[("account_id", 1), ("updated_at", -1)]
|
||||||
|
)
|
||||||
|
await mongo_db.mailbox_entries.create_index(
|
||||||
|
[
|
||||||
|
("account_id", 1),
|
||||||
|
("sender.name", "text"),
|
||||||
|
("sender.address", "text"),
|
||||||
|
("subject", "text"),
|
||||||
|
("snippet", "text"),
|
||||||
|
],
|
||||||
|
name="mailbox_search",
|
||||||
|
)
|
||||||
|
if settings.inbound_smtp_enabled:
|
||||||
|
await inbound_server.start()
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
await inbound_server.stop()
|
||||||
|
await engine.dispose()
|
||||||
|
await mongo_client.close()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version="0.1.0",
|
||||||
|
description="The API for Agmail — email for people and their AI agents.",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.include_router(emails.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(sticky_notes.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(auth.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(domains.router, prefix=settings.api_prefix)
|
||||||
|
app.include_router(dns_setup.router, prefix=settings.api_prefix)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {
|
||||||
|
"name": settings.app_name,
|
||||||
|
"docs": "/docs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health(response: Response):
|
||||||
|
checks = {
|
||||||
|
"postgres": False,
|
||||||
|
"mongodb": False,
|
||||||
|
"smtp": False,
|
||||||
|
"inbound_smtp": (
|
||||||
|
not settings.inbound_smtp_enabled
|
||||||
|
or (
|
||||||
|
inbound_server.server is not None
|
||||||
|
and inbound_server.server.is_serving()
|
||||||
|
)
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with SessionFactory() as session:
|
||||||
|
await session.execute(text("SELECT 1"))
|
||||||
|
checks["postgres"] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
await mongo_db.command("ping")
|
||||||
|
checks["mongodb"] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
import socket
|
||||||
|
|
||||||
|
with socket.create_connection(
|
||||||
|
(settings.smtp_host, settings.smtp_port),
|
||||||
|
timeout=3,
|
||||||
|
):
|
||||||
|
checks["smtp"] = True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
healthy = checks["postgres"] and checks["mongodb"]
|
||||||
|
if settings.inbound_smtp_enabled:
|
||||||
|
healthy = healthy and checks["inbound_smtp"]
|
||||||
|
if settings.smtp_health_required:
|
||||||
|
healthy = healthy and checks["smtp"]
|
||||||
|
if not healthy:
|
||||||
|
response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
return {"status": "healthy" if healthy else "degraded", "checks": checks}
|
||||||
129
backend/app/models.py
Normal file
129
backend/app/models.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
CheckConstraint,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
func,
|
||||||
|
)
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Account(Base):
|
||||||
|
__tablename__ = "users"
|
||||||
|
__table_args__ = (
|
||||||
|
CheckConstraint(
|
||||||
|
"account_type IN ('individual', 'company')",
|
||||||
|
name="ck_users_account_type",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
account_type: Mapped[str] = mapped_column(String(20))
|
||||||
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||||
|
first_name: Mapped[str] = mapped_column(String(80))
|
||||||
|
last_name: Mapped[str] = mapped_column(String(80))
|
||||||
|
date_of_birth: Mapped[date] = mapped_column(Date)
|
||||||
|
phone_number: Mapped[str] = mapped_column(String(24), unique=True)
|
||||||
|
company_domain: Mapped[str | None] = mapped_column(String(253), nullable=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
mail_domains: Mapped[list["MailDomain"]] = relationship(back_populates="account")
|
||||||
|
|
||||||
|
|
||||||
|
class MailDomain(Base):
|
||||||
|
__tablename__ = "mail_domains"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
domain_name: Mapped[str] = mapped_column(String(253), unique=True, index=True)
|
||||||
|
domain_type: Mapped[str] = mapped_column(String(20))
|
||||||
|
account_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
mail_host: Mapped[str] = mapped_column(String(253))
|
||||||
|
dkim_selector: Mapped[str] = mapped_column(String(63))
|
||||||
|
dkim_private_key: Mapped[str] = mapped_column(Text)
|
||||||
|
dkim_public_key: Mapped[str] = mapped_column(Text)
|
||||||
|
status: Mapped[str] = mapped_column(String(20), default="pending_dns")
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
account: Mapped[Account | None] = relationship(back_populates="mail_domains")
|
||||||
|
dns_records: Mapped[list["DomainDnsRecord"]] = relationship(
|
||||||
|
back_populates="domain",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
smtp_credentials: Mapped[list["SmtpCredential"]] = relationship(
|
||||||
|
back_populates="domain",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DomainDnsRecord(Base):
|
||||||
|
__tablename__ = "domain_dns_records"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("mail_domains.id"),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
record_type: Mapped[str] = mapped_column(String(10))
|
||||||
|
host: Mapped[str] = mapped_column(String(253))
|
||||||
|
value: Mapped[str] = mapped_column(Text)
|
||||||
|
purpose: Mapped[str] = mapped_column(String(20))
|
||||||
|
ttl: Mapped[int] = mapped_column(Integer, default=3600)
|
||||||
|
domain: Mapped[MailDomain] = relationship(back_populates="dns_records")
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpCredential(Base):
|
||||||
|
__tablename__ = "smtp_credentials"
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid.uuid4,
|
||||||
|
)
|
||||||
|
domain_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("mail_domains.id"),
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
username: Mapped[str] = mapped_column(String(120), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
label: Mapped[str] = mapped_column(String(120))
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
|
domain: Mapped[MailDomain] = relationship(back_populates="smtp_credentials")
|
||||||
1
backend/app/routers/__init__.py
Normal file
1
backend/app/routers/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""API route modules."""
|
||||||
212
backend/app/routers/auth.py
Normal file
212
backend/app/routers/auth.py
Normal file
|
|
@ -0,0 +1,212 @@
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
from pwdlib import PasswordHash
|
||||||
|
from pydantic import EmailStr, TypeAdapter, ValidationError
|
||||||
|
from sqlalchemy import or_, select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_postgres_session
|
||||||
|
from app.models import Account
|
||||||
|
from app.schemas import AccountRegister, AccountRead, AuthResponse, LoginRequest
|
||||||
|
from app.services.domain_provisioning import (
|
||||||
|
get_domain_by_name,
|
||||||
|
provision_mail_domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["authentication"])
|
||||||
|
password_hash = PasswordHash.recommended()
|
||||||
|
dummy_password_hash = password_hash.hash("Agmail timing protection value")
|
||||||
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
settings = get_settings()
|
||||||
|
email_adapter = TypeAdapter(EmailStr)
|
||||||
|
domain_pattern = re.compile(
|
||||||
|
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
|
||||||
|
r"[a-z]{2,63}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_phone(value: str) -> str:
|
||||||
|
prefix = "+" if value.strip().startswith("+") else ""
|
||||||
|
return prefix + "".join(character for character in value if character.isdigit())
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password(value: str) -> None:
|
||||||
|
if not (
|
||||||
|
re.search(r"[a-z]", value)
|
||||||
|
and re.search(r"[A-Z]", value)
|
||||||
|
and re.search(r"\d", value)
|
||||||
|
):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Password must include uppercase, lowercase, and a number.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_email(payload: AccountRegister) -> tuple[str, str | None]:
|
||||||
|
domain = "getall.help"
|
||||||
|
company_domain = None
|
||||||
|
|
||||||
|
if payload.account_type == "company":
|
||||||
|
if not payload.company_domain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Company domain is required for company accounts.",
|
||||||
|
)
|
||||||
|
domain = payload.company_domain.strip().lower().removeprefix("@")
|
||||||
|
if not domain_pattern.fullmatch(domain):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Enter a valid company domain, such as example.com.",
|
||||||
|
)
|
||||||
|
company_domain = domain
|
||||||
|
|
||||||
|
email = f"{payload.email_name.lower()}@{domain}"
|
||||||
|
try:
|
||||||
|
return str(email_adapter.validate_python(email)), company_domain
|
||||||
|
except ValidationError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="The resulting email address is invalid.",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(account: Account) -> str:
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||||
|
minutes=settings.access_token_minutes
|
||||||
|
)
|
||||||
|
return jwt.encode(
|
||||||
|
{"sub": str(account.id), "email": account.email, "exp": expires_at},
|
||||||
|
settings.jwt_secret,
|
||||||
|
algorithm="HS256",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_account(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> Account:
|
||||||
|
unauthorized = HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Your session is invalid or has expired.",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
if credentials is None or credentials.scheme.lower() != "bearer":
|
||||||
|
raise unauthorized
|
||||||
|
|
||||||
|
try:
|
||||||
|
claims = jwt.decode(
|
||||||
|
credentials.credentials,
|
||||||
|
settings.jwt_secret,
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"require": ["sub", "exp"]},
|
||||||
|
)
|
||||||
|
account_id = UUID(claims["sub"])
|
||||||
|
except (jwt.PyJWTError, KeyError, TypeError, ValueError):
|
||||||
|
raise unauthorized
|
||||||
|
|
||||||
|
account = await session.get(Account, account_id)
|
||||||
|
if account is None:
|
||||||
|
raise unauthorized
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/register",
|
||||||
|
response_model=AuthResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def register(
|
||||||
|
payload: AccountRegister,
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> AuthResponse:
|
||||||
|
if payload.date_of_birth >= date.today():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Date of birth must be in the past.",
|
||||||
|
)
|
||||||
|
validate_password(payload.password)
|
||||||
|
email, company_domain = build_email(payload)
|
||||||
|
phone_number = normalize_phone(payload.phone_number)
|
||||||
|
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(Account).where(
|
||||||
|
or_(Account.email == email, Account.phone_number == phone_number)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
detail = (
|
||||||
|
"That email address is already registered."
|
||||||
|
if existing.email == email
|
||||||
|
else "That phone number is already registered."
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
|
||||||
|
|
||||||
|
account = Account(
|
||||||
|
account_type=payload.account_type,
|
||||||
|
email=email,
|
||||||
|
first_name=payload.first_name.strip(),
|
||||||
|
last_name=payload.last_name.strip(),
|
||||||
|
date_of_birth=payload.date_of_birth,
|
||||||
|
phone_number=phone_number,
|
||||||
|
company_domain=company_domain,
|
||||||
|
password_hash=password_hash.hash(payload.password),
|
||||||
|
)
|
||||||
|
session.add(account)
|
||||||
|
try:
|
||||||
|
await session.flush()
|
||||||
|
if company_domain:
|
||||||
|
existing_domain = await get_domain_by_name(session, company_domain)
|
||||||
|
if existing_domain is None:
|
||||||
|
await provision_mail_domain(
|
||||||
|
session,
|
||||||
|
domain_name=company_domain,
|
||||||
|
domain_type="company",
|
||||||
|
account_id=account.id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except IntegrityError as error:
|
||||||
|
await session.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="An account with those details already exists.",
|
||||||
|
) from error
|
||||||
|
await session.refresh(account)
|
||||||
|
return AuthResponse(
|
||||||
|
access_token=create_access_token(account),
|
||||||
|
account=AccountRead.model_validate(account),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=AuthResponse)
|
||||||
|
async def login(
|
||||||
|
payload: LoginRequest,
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> AuthResponse:
|
||||||
|
account = await session.scalar(
|
||||||
|
select(Account).where(Account.email == str(payload.email).lower())
|
||||||
|
)
|
||||||
|
password_is_valid = password_hash.verify(
|
||||||
|
payload.password,
|
||||||
|
account.password_hash if account is not None else dummy_password_hash,
|
||||||
|
)
|
||||||
|
if account is None or not password_is_valid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Incorrect email or password.",
|
||||||
|
)
|
||||||
|
return AuthResponse(
|
||||||
|
access_token=create_access_token(account),
|
||||||
|
account=AccountRead.model_validate(account),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=AccountRead)
|
||||||
|
async def get_me(account: Account = Depends(get_current_account)) -> Account:
|
||||||
|
return account
|
||||||
81
backend/app/routers/dns_setup.py
Normal file
81
backend/app/routers/dns_setup.py
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_postgres_session
|
||||||
|
from app.deps.dns_setup_auth import require_dns_setup_auth
|
||||||
|
from app.schemas import DnsSetupRequest, DnsSetupResponse
|
||||||
|
from app.services.domain_provisioning import (
|
||||||
|
build_deployment_dns_records,
|
||||||
|
get_domain_by_name,
|
||||||
|
validate_domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dns-setup", tags=["dns-setup"])
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mail_server_ip(value: str) -> str:
|
||||||
|
candidate = value.strip()
|
||||||
|
try:
|
||||||
|
return str(ipaddress.ip_address(candidate))
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError("Enter a valid IPv4 or IPv6 address.") from error
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/preview", response_model=DnsSetupResponse)
|
||||||
|
async def preview_dns_setup(
|
||||||
|
payload: DnsSetupRequest,
|
||||||
|
_: None = Depends(require_dns_setup_auth),
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> DnsSetupResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
domain_name = validate_domain(payload.domain)
|
||||||
|
mail_server_ip = validate_mail_server_ip(payload.mail_server_ip)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
domain = await get_domain_by_name(session, domain_name)
|
||||||
|
sections = build_deployment_dns_records(
|
||||||
|
domain_name=domain_name,
|
||||||
|
mail_server_ip=mail_server_ip,
|
||||||
|
dkim_selector=settings.dkim_selector,
|
||||||
|
dkim_public_key=domain.dkim_public_key if domain else None,
|
||||||
|
app_public_host=settings.app_public_host,
|
||||||
|
)
|
||||||
|
mail_host = f"mail.{domain_name}"
|
||||||
|
return DnsSetupResponse(
|
||||||
|
domain=domain_name,
|
||||||
|
mail_host=mail_host,
|
||||||
|
app_public_host=settings.app_public_host,
|
||||||
|
mail_server_ip=mail_server_ip,
|
||||||
|
is_platform_domain=domain_name == settings.platform_domain,
|
||||||
|
sections=sections,
|
||||||
|
notes=_build_notes(
|
||||||
|
domain_name,
|
||||||
|
settings.app_public_host,
|
||||||
|
mail_server_ip,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_notes(
|
||||||
|
domain_name: str,
|
||||||
|
app_public_host: str,
|
||||||
|
mail_server_ip: str,
|
||||||
|
) -> list[str]:
|
||||||
|
return [
|
||||||
|
"Add these records in Namecheap Advanced DNS for the zone.",
|
||||||
|
f"Point mail.{domain_name} and {app_public_host} A records to {mail_server_ip}.",
|
||||||
|
f"Keep the root {domain_name} A record on your main website server if it hosts the marketing site.",
|
||||||
|
f"Set {domain_name} MX to mail.{domain_name} (priority 10) so inbound mail reaches Postfix on this host.",
|
||||||
|
"Postfix accepts inbound mail on :25 and delivers outbound mail for the app over the Docker network.",
|
||||||
|
f"Users open the web UI at https://{app_public_host} (Caddy terminates TLS).",
|
||||||
|
f"Ask your VPS provider to set reverse DNS (PTR) for {mail_server_ip} → mail.{domain_name}.",
|
||||||
|
"SPF, DKIM, and DMARC authenticate outbound mail signed by Agmail (selector agmail).",
|
||||||
|
]
|
||||||
249
backend/app/routers/domains.py
Normal file
249
backend/app/routers/domains.py
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import get_postgres_session
|
||||||
|
from app.models import Account, MailDomain
|
||||||
|
from app.routers.auth import get_current_account
|
||||||
|
from app.schemas import (
|
||||||
|
DnsVerificationResponse,
|
||||||
|
DomainCreate,
|
||||||
|
MailDomainRead,
|
||||||
|
SmtpCredentialCreate,
|
||||||
|
SmtpCredentialCreated,
|
||||||
|
SmtpCredentialRead,
|
||||||
|
)
|
||||||
|
from app.services.dns_verification import verify_company_dns
|
||||||
|
from app.services.domain_provisioning import (
|
||||||
|
activate_company_domain,
|
||||||
|
create_smtp_credential,
|
||||||
|
ensure_platform_domain,
|
||||||
|
get_account_company_domain,
|
||||||
|
provision_mail_domain,
|
||||||
|
validate_domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/domains", tags=["domains"])
|
||||||
|
|
||||||
|
|
||||||
|
async def get_domain_or_404(
|
||||||
|
domain_id: UUID,
|
||||||
|
session: AsyncSession,
|
||||||
|
) -> MailDomain:
|
||||||
|
domain = await session.scalar(
|
||||||
|
select(MailDomain)
|
||||||
|
.options(
|
||||||
|
selectinload(MailDomain.dns_records),
|
||||||
|
selectinload(MailDomain.smtp_credentials),
|
||||||
|
)
|
||||||
|
.where(MailDomain.id == domain_id)
|
||||||
|
)
|
||||||
|
if domain is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Domain not found.",
|
||||||
|
)
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MailDomainRead)
|
||||||
|
async def get_my_company_domain(
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> MailDomain:
|
||||||
|
if account.account_type != "company" or not account.company_domain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No company domain is linked to this account.",
|
||||||
|
)
|
||||||
|
domain = await get_account_company_domain(
|
||||||
|
session,
|
||||||
|
account.id,
|
||||||
|
account.company_domain,
|
||||||
|
)
|
||||||
|
if domain is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Company domain has not been provisioned yet.",
|
||||||
|
)
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/me/verify-dns", response_model=DnsVerificationResponse)
|
||||||
|
async def verify_my_company_dns(
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> DnsVerificationResponse:
|
||||||
|
settings = get_settings()
|
||||||
|
if account.account_type != "company" or not account.company_domain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="No company domain is linked to this account.",
|
||||||
|
)
|
||||||
|
domain = await get_account_company_domain(
|
||||||
|
session,
|
||||||
|
account.id,
|
||||||
|
account.company_domain,
|
||||||
|
)
|
||||||
|
if domain is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Company domain has not been provisioned yet.",
|
||||||
|
)
|
||||||
|
if domain.status == "active":
|
||||||
|
return DnsVerificationResponse(
|
||||||
|
domain=domain.domain_name,
|
||||||
|
status=domain.status,
|
||||||
|
verified=True,
|
||||||
|
checks=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
checks = verify_company_dns(
|
||||||
|
domain_name=domain.domain_name,
|
||||||
|
shared_mail_host=settings.smtp_public_host,
|
||||||
|
dkim_selector=domain.dkim_selector,
|
||||||
|
dkim_public_key=domain.dkim_public_key,
|
||||||
|
platform_spf_include=settings.platform_spf_include,
|
||||||
|
)
|
||||||
|
verified = all(check["verified"] for check in checks)
|
||||||
|
if verified:
|
||||||
|
await activate_company_domain(session, domain)
|
||||||
|
await session.commit()
|
||||||
|
return DnsVerificationResponse(
|
||||||
|
domain=domain.domain_name,
|
||||||
|
status=domain.status if not verified else "active",
|
||||||
|
verified=verified,
|
||||||
|
checks=checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/platform", response_model=MailDomainRead)
|
||||||
|
async def get_platform_domain(
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> MailDomain:
|
||||||
|
domain = await ensure_platform_domain(session)
|
||||||
|
await session.commit()
|
||||||
|
return await get_domain_or_404(domain.id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[MailDomainRead])
|
||||||
|
async def list_domains(
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> list[MailDomain]:
|
||||||
|
await ensure_platform_domain(session)
|
||||||
|
await session.commit()
|
||||||
|
result = await session.scalars(
|
||||||
|
select(MailDomain)
|
||||||
|
.options(selectinload(MailDomain.dns_records))
|
||||||
|
.order_by(MailDomain.created_at.desc())
|
||||||
|
)
|
||||||
|
return list(result.all())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=MailDomainRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def register_company_domain(
|
||||||
|
payload: DomainCreate,
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> MailDomain:
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
normalized = validate_domain(payload.domain_name)
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=str(error),
|
||||||
|
) from error
|
||||||
|
|
||||||
|
if normalized == settings.platform_domain:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Use the platform domain endpoint for getall.help.",
|
||||||
|
)
|
||||||
|
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(MailDomain).where(MailDomain.domain_name == normalized)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="That domain is already registered.",
|
||||||
|
)
|
||||||
|
|
||||||
|
domain = await provision_mail_domain(
|
||||||
|
session,
|
||||||
|
domain_name=normalized,
|
||||||
|
domain_type="company",
|
||||||
|
account_id=payload.account_id,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return await get_domain_or_404(domain.id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{domain_id}", response_model=MailDomainRead)
|
||||||
|
async def get_domain(
|
||||||
|
domain_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
) -> MailDomain:
|
||||||
|
return await get_domain_or_404(domain_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{domain_id}/dns-records", response_model=list)
|
||||||
|
async def list_dns_records(
|
||||||
|
domain_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
):
|
||||||
|
domain = await get_domain_or_404(domain_id, session)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": record.record_type,
|
||||||
|
"host": record.host,
|
||||||
|
"value": record.value,
|
||||||
|
"purpose": record.purpose,
|
||||||
|
"ttl": record.ttl,
|
||||||
|
}
|
||||||
|
for record in domain.dns_records
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{domain_id}/smtp-credentials", response_model=list[SmtpCredentialRead])
|
||||||
|
async def list_smtp_credentials(
|
||||||
|
domain_id: UUID,
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
):
|
||||||
|
domain = await get_domain_or_404(domain_id, session)
|
||||||
|
return domain.smtp_credentials
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{domain_id}/smtp-credentials",
|
||||||
|
response_model=SmtpCredentialCreated,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_domain_smtp_credential(
|
||||||
|
domain_id: UUID,
|
||||||
|
payload: SmtpCredentialCreate,
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
):
|
||||||
|
settings = get_settings()
|
||||||
|
domain = await get_domain_or_404(domain_id, session)
|
||||||
|
credential, plain_password = await create_smtp_credential(
|
||||||
|
session,
|
||||||
|
domain,
|
||||||
|
label=payload.label,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return SmtpCredentialCreated(
|
||||||
|
id=credential.id,
|
||||||
|
username=credential.username,
|
||||||
|
label=credential.label,
|
||||||
|
is_active=credential.is_active,
|
||||||
|
created_at=credential.created_at,
|
||||||
|
password=plain_password,
|
||||||
|
smtp_host=settings.smtp_public_host,
|
||||||
|
smtp_port=settings.smtp_port,
|
||||||
|
use_tls=settings.smtp_use_tls,
|
||||||
|
)
|
||||||
464
backend/app/routers/emails.py
Normal file
464
backend/app/routers/emails.py
Normal file
|
|
@ -0,0 +1,464 @@
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import date, datetime, time, timedelta, timezone
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from bson import ObjectId
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_mongo_database, get_postgres_session
|
||||||
|
from app.models import Account
|
||||||
|
from app.routers.auth import get_current_account
|
||||||
|
from app.schemas import (
|
||||||
|
EmailCreate,
|
||||||
|
EmailRead,
|
||||||
|
MailAddress,
|
||||||
|
MessagePage,
|
||||||
|
MessageRead,
|
||||||
|
MessageReadState,
|
||||||
|
MessageStarState,
|
||||||
|
MessageSummary,
|
||||||
|
utc_now,
|
||||||
|
)
|
||||||
|
from app.services.domain_provisioning import get_domain_by_name
|
||||||
|
from app.services.mail_sender import deliver_email
|
||||||
|
from app.services.mime_parser import parse_mime
|
||||||
|
from app.services.raw_mime_storage import get_raw_mime_storage
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/emails", tags=["emails"])
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_email(document: dict, viewer_email: str) -> EmailRead:
|
||||||
|
return EmailRead(
|
||||||
|
id=str(document["_id"]),
|
||||||
|
sender=document["sender"],
|
||||||
|
recipients=document["recipients"],
|
||||||
|
cc=document.get("cc", []),
|
||||||
|
bcc=document.get("bcc", []) if document["sender"] == viewer_email else [],
|
||||||
|
subject=document["subject"],
|
||||||
|
body_text=document["body_text"],
|
||||||
|
body_html=document.get("body_html"),
|
||||||
|
status=document["status"],
|
||||||
|
created_at=document["created_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sender_domain(address: str) -> str:
|
||||||
|
return address.split("@", 1)[1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def address(value: str, name: str | None = None) -> dict[str, str]:
|
||||||
|
return {"name": name or value, "address": value.lower()}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_cursor(entry: dict) -> str:
|
||||||
|
value = json.dumps(
|
||||||
|
{"created_at": entry["created_at"].isoformat(), "id": str(entry["_id"])},
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
return base64.urlsafe_b64encode(value.encode()).decode().rstrip("=")
|
||||||
|
|
||||||
|
|
||||||
|
def decode_cursor(value: str) -> tuple[datetime, ObjectId]:
|
||||||
|
try:
|
||||||
|
padded = value + "=" * (-len(value) % 4)
|
||||||
|
payload = json.loads(base64.urlsafe_b64decode(padded).decode())
|
||||||
|
return datetime.fromisoformat(payload["created_at"]), ObjectId(payload["id"])
|
||||||
|
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as error:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Invalid cursor.",
|
||||||
|
) from error
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(message: dict, entry: dict) -> MessageSummary:
|
||||||
|
sender = message.get("sender", {"name": "Unknown sender", "address": ""})
|
||||||
|
recipients = message.get("to") or [
|
||||||
|
address(value) for value in message.get("recipients", [])
|
||||||
|
]
|
||||||
|
mailbox = "trash" if entry.get("trashed_at") else entry["mailbox"]
|
||||||
|
return MessageSummary(
|
||||||
|
id=str(message["_id"]),
|
||||||
|
mailbox=mailbox,
|
||||||
|
direction=message.get("direction", "outbound"),
|
||||||
|
sender=MailAddress(**sender),
|
||||||
|
recipients=[MailAddress(**item) for item in recipients],
|
||||||
|
subject=message.get("subject", ""),
|
||||||
|
snippet=message.get("snippet", message.get("body_text", "")[:240]),
|
||||||
|
status=message.get("status", "received"),
|
||||||
|
created_at=message.get("received_at") or message["created_at"],
|
||||||
|
is_read=entry.get("is_read", True),
|
||||||
|
is_starred=entry.get("is_starred", False),
|
||||||
|
trashed_at=entry.get("trashed_at"),
|
||||||
|
attachments=message.get("attachments", []),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def legacy_email(database, email_id: ObjectId, viewer_email: str) -> dict | None:
|
||||||
|
document = await database.emails.find_one({"_id": email_id})
|
||||||
|
if document is None:
|
||||||
|
return None
|
||||||
|
visible_to = {
|
||||||
|
document["sender"],
|
||||||
|
*document.get("recipients", []),
|
||||||
|
*document.get("cc", []),
|
||||||
|
*document.get("bcc", []),
|
||||||
|
}
|
||||||
|
return document if viewer_email in visible_to else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=EmailRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_email(
|
||||||
|
payload: EmailCreate,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
sender = account.email.lower()
|
||||||
|
recipients = [str(recipient).lower() for recipient in payload.recipients]
|
||||||
|
cc = [str(recipient).lower() for recipient in payload.cc]
|
||||||
|
bcc = [str(recipient).lower() for recipient in payload.bcc]
|
||||||
|
all_recipients = [*recipients, *cc, *bcc]
|
||||||
|
if len(set(all_recipients)) != len(all_recipients):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Each recipient may only appear once across To, Cc, and Bcc.",
|
||||||
|
)
|
||||||
|
domain = await get_domain_by_name(session, sender_domain(sender))
|
||||||
|
if domain is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Sender domain is not registered with agmail.",
|
||||||
|
)
|
||||||
|
if domain.domain_type == "company" and domain.status != "active":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Complete DNS setup for your company domain before sending mail.",
|
||||||
|
)
|
||||||
|
|
||||||
|
created_at = utc_now()
|
||||||
|
document = {
|
||||||
|
"direction": "outbound",
|
||||||
|
"account_id": str(account.id),
|
||||||
|
"sender": address(
|
||||||
|
sender,
|
||||||
|
" ".join(part for part in [account.first_name, account.last_name] if part),
|
||||||
|
),
|
||||||
|
"to": [address(value) for value in recipients],
|
||||||
|
"cc": [address(value) for value in cc],
|
||||||
|
"bcc": [address(value) for value in bcc],
|
||||||
|
"subject": payload.subject,
|
||||||
|
"snippet": re.sub(r"\s+", " ", payload.body_text).strip()[:240],
|
||||||
|
"body_text": payload.body_text,
|
||||||
|
"body_html": payload.body_html,
|
||||||
|
"attachments": [],
|
||||||
|
"created_at": created_at,
|
||||||
|
"status": "queued",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
await deliver_email(
|
||||||
|
sender=sender,
|
||||||
|
recipients=recipients,
|
||||||
|
cc=cc,
|
||||||
|
bcc=bcc,
|
||||||
|
subject=document["subject"],
|
||||||
|
body_text=document["body_text"],
|
||||||
|
body_html=document.get("body_html"),
|
||||||
|
domain=domain,
|
||||||
|
)
|
||||||
|
document["status"] = "accepted"
|
||||||
|
except Exception as error:
|
||||||
|
document["status"] = "failed"
|
||||||
|
document["error"] = str(error)
|
||||||
|
|
||||||
|
result = await database.messages.insert_one(document)
|
||||||
|
document["_id"] = result.inserted_id
|
||||||
|
await database.mailbox_entries.insert_one(
|
||||||
|
{
|
||||||
|
"account_id": str(account.id),
|
||||||
|
"message_id": result.inserted_id,
|
||||||
|
"mailbox": "sent",
|
||||||
|
"direction": "outbound",
|
||||||
|
"sender": document["sender"],
|
||||||
|
"recipients": document["to"],
|
||||||
|
"subject": document["subject"],
|
||||||
|
"snippet": document["snippet"],
|
||||||
|
"attachments": [],
|
||||||
|
"status": document["status"],
|
||||||
|
"is_read": True,
|
||||||
|
"is_starred": False,
|
||||||
|
"event_at": created_at,
|
||||||
|
"created_at": created_at,
|
||||||
|
"trashed_at": None,
|
||||||
|
"deletion_state": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if document["status"] == "failed":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=document.get("error", "Unable to deliver email."),
|
||||||
|
)
|
||||||
|
return EmailRead(
|
||||||
|
id=str(document["_id"]),
|
||||||
|
sender=sender,
|
||||||
|
recipients=recipients,
|
||||||
|
cc=cc,
|
||||||
|
bcc=bcc,
|
||||||
|
subject=payload.subject,
|
||||||
|
body_text=payload.body_text,
|
||||||
|
body_html=payload.body_html,
|
||||||
|
status=document["status"],
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{email_id}", response_model=MessageRead | EmailRead)
|
||||||
|
async def get_email(
|
||||||
|
email_id: str,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
if not ObjectId.is_valid(email_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Invalid email ID.",
|
||||||
|
)
|
||||||
|
message_id = ObjectId(email_id)
|
||||||
|
viewer_email = account.email.lower()
|
||||||
|
entry = await database.mailbox_entries.find_one(
|
||||||
|
{"account_id": str(account.id), "message_id": message_id}
|
||||||
|
)
|
||||||
|
document = (
|
||||||
|
await database.messages.find_one({"_id": message_id})
|
||||||
|
if entry is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if document is None or entry is None:
|
||||||
|
legacy = await legacy_email(database, message_id, viewer_email)
|
||||||
|
if legacy is not None:
|
||||||
|
return serialize_email(legacy, viewer_email)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Email not found.",
|
||||||
|
)
|
||||||
|
body_text = document.get("body_text", "")
|
||||||
|
body_html = document.get("body_html")
|
||||||
|
if document.get("raw_mime"):
|
||||||
|
raw = await get_raw_mime_storage().get(document["raw_mime"])
|
||||||
|
parsed = parse_mime(raw)
|
||||||
|
body_text = parsed["body_text"]
|
||||||
|
body_html = parsed["body_html"]
|
||||||
|
summary = summarize(document, entry)
|
||||||
|
return MessageRead(
|
||||||
|
**summary.model_dump(),
|
||||||
|
cc=document.get("cc", []),
|
||||||
|
bcc=document.get("bcc", []) if document.get("account_id") == str(account.id) else [],
|
||||||
|
reply_to=document.get("reply_to", []),
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
message_id=document.get("message_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=MessagePage)
|
||||||
|
async def list_emails(
|
||||||
|
mailbox: Annotated[str, Query(pattern="^(inbox|sent|trash)$")] = "inbox",
|
||||||
|
limit: Annotated[int, Query(ge=1, le=100)] = 25,
|
||||||
|
cursor: str | None = None,
|
||||||
|
q: Annotated[str | None, Query(max_length=200)] = None,
|
||||||
|
is_read: bool | None = None,
|
||||||
|
sender: Annotated[str | None, Query(max_length=320)] = None,
|
||||||
|
date_from: date | None = None,
|
||||||
|
date_to: date | None = None,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
entry_match: dict = {
|
||||||
|
"account_id": str(account.id),
|
||||||
|
"deletion_state": {"$in": [None, ""]},
|
||||||
|
}
|
||||||
|
if mailbox == "trash":
|
||||||
|
entry_match["trashed_at"] = {"$ne": None}
|
||||||
|
else:
|
||||||
|
entry_match.update({"mailbox": mailbox, "trashed_at": None})
|
||||||
|
if is_read is not None:
|
||||||
|
entry_match["is_read"] = is_read
|
||||||
|
if sender:
|
||||||
|
pattern = f"^{re.escape(sender.strip())}"
|
||||||
|
entry_match["$or"] = [
|
||||||
|
{"sender.name": {"$regex": pattern, "$options": "i"}},
|
||||||
|
{"sender.address": {"$regex": pattern, "$options": "i"}},
|
||||||
|
]
|
||||||
|
if date_from or date_to:
|
||||||
|
event_range: dict = {}
|
||||||
|
if date_from:
|
||||||
|
event_range["$gte"] = datetime.combine(
|
||||||
|
date_from,
|
||||||
|
time.min,
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
if date_to:
|
||||||
|
event_range["$lt"] = datetime.combine(
|
||||||
|
date_to + timedelta(days=1),
|
||||||
|
time.min,
|
||||||
|
tzinfo=timezone.utc,
|
||||||
|
)
|
||||||
|
entry_match["event_at"] = event_range
|
||||||
|
if q and q.strip():
|
||||||
|
entry_match["$text"] = {"$search": q.strip()}
|
||||||
|
if cursor:
|
||||||
|
created_at, entry_id = decode_cursor(cursor)
|
||||||
|
cursor_clause = [
|
||||||
|
{"created_at": {"$lt": created_at}},
|
||||||
|
{"created_at": created_at, "_id": {"$lt": entry_id}},
|
||||||
|
]
|
||||||
|
if "$or" in entry_match:
|
||||||
|
existing_or = entry_match.pop("$or")
|
||||||
|
entry_match["$and"] = [{"$or": existing_or}, {"$or": cursor_clause}]
|
||||||
|
else:
|
||||||
|
entry_match["$or"] = cursor_clause
|
||||||
|
pipeline: list[dict] = [
|
||||||
|
{"$match": entry_match},
|
||||||
|
{"$sort": {"created_at": -1, "_id": -1}},
|
||||||
|
{"$limit": limit + 1},
|
||||||
|
{
|
||||||
|
"$lookup": {
|
||||||
|
"from": "messages",
|
||||||
|
"localField": "message_id",
|
||||||
|
"foreignField": "_id",
|
||||||
|
"as": "message",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{"$unwind": "$message"},
|
||||||
|
]
|
||||||
|
cursor = await database.mailbox_entries.aggregate(pipeline)
|
||||||
|
rows = [row async for row in cursor]
|
||||||
|
has_more = len(rows) > limit
|
||||||
|
rows = rows[:limit]
|
||||||
|
return MessagePage(
|
||||||
|
items=[summarize(row["message"], row) for row in rows],
|
||||||
|
next_cursor=encode_cursor(rows[-1]) if has_more and rows else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def require_entry(database, account: Account, email_id: str) -> tuple[ObjectId, dict]:
|
||||||
|
if not ObjectId.is_valid(email_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Invalid email ID.",
|
||||||
|
)
|
||||||
|
message_id = ObjectId(email_id)
|
||||||
|
entry = await database.mailbox_entries.find_one(
|
||||||
|
{"account_id": str(account.id), "message_id": message_id}
|
||||||
|
)
|
||||||
|
if entry is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email not found.")
|
||||||
|
return message_id, entry
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{email_id}/read", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def set_email_read(
|
||||||
|
email_id: str,
|
||||||
|
payload: MessageReadState,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
message_id, _ = await require_entry(database, account, email_id)
|
||||||
|
await database.mailbox_entries.update_one(
|
||||||
|
{"account_id": str(account.id), "message_id": message_id},
|
||||||
|
{"$set": {"is_read": payload.is_read}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{email_id}/star", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def set_email_star(
|
||||||
|
email_id: str,
|
||||||
|
payload: MessageStarState,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
message_id, _ = await require_entry(database, account, email_id)
|
||||||
|
await database.mailbox_entries.update_one(
|
||||||
|
{"account_id": str(account.id), "message_id": message_id},
|
||||||
|
{"$set": {"is_starred": payload.is_starred}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def move_to_trash(database, account: Account, email_id: str) -> None:
|
||||||
|
message_id, _ = await require_entry(database, account, email_id)
|
||||||
|
await database.mailbox_entries.update_one(
|
||||||
|
{"account_id": str(account.id), "message_id": message_id},
|
||||||
|
{"$set": {"trashed_at": utc_now()}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{email_id}/trash", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def trash_email(
|
||||||
|
email_id: str,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
await move_to_trash(database, account, email_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{email_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_email(
|
||||||
|
email_id: str,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
await move_to_trash(database, account, email_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{email_id}/restore", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def restore_email(
|
||||||
|
email_id: str,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
message_id, _ = await require_entry(database, account, email_id)
|
||||||
|
await database.mailbox_entries.update_one(
|
||||||
|
{"account_id": str(account.id), "message_id": message_id},
|
||||||
|
{"$set": {"trashed_at": None}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{email_id}/permanent", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def permanently_delete_email(
|
||||||
|
email_id: str,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
message_id, entry = await require_entry(database, account, email_id)
|
||||||
|
if entry.get("trashed_at") is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="Email must be in trash before permanent deletion.",
|
||||||
|
)
|
||||||
|
message = await database.messages.find_one({"_id": message_id})
|
||||||
|
other_entries = await database.mailbox_entries.count_documents(
|
||||||
|
{"message_id": message_id, "_id": {"$ne": entry["_id"]}}
|
||||||
|
)
|
||||||
|
await database.mailbox_entries.update_one(
|
||||||
|
{"_id": entry["_id"]},
|
||||||
|
{"$set": {"deletion_state": "deleting"}},
|
||||||
|
)
|
||||||
|
if other_entries == 0 and message and message.get("raw_mime"):
|
||||||
|
await get_raw_mime_storage().delete(message["raw_mime"])
|
||||||
|
await database.mailbox_entries.delete_one({"_id": entry["_id"]})
|
||||||
|
if other_entries == 0:
|
||||||
|
if message and message.get("delivery_key"):
|
||||||
|
await database.message_tombstones.update_one(
|
||||||
|
{"delivery_key": message["delivery_key"]},
|
||||||
|
{
|
||||||
|
"$setOnInsert": {
|
||||||
|
"delivery_key": message["delivery_key"],
|
||||||
|
"deleted_at": utc_now(),
|
||||||
|
}
|
||||||
|
},
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
await database.messages.delete_one({"_id": message_id})
|
||||||
109
backend/app/routers/sticky_notes.py
Normal file
109
backend/app/routers/sticky_notes.py
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
from bson import ObjectId
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from pymongo import ReturnDocument
|
||||||
|
|
||||||
|
from app.database import get_mongo_database
|
||||||
|
from app.models import Account
|
||||||
|
from app.routers.auth import get_current_account
|
||||||
|
from app.schemas import StickyNoteCreate, StickyNoteRead, StickyNoteUpdate, utc_now
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/sticky-notes", tags=["sticky-notes"])
|
||||||
|
|
||||||
|
|
||||||
|
def serialize_note(document: dict) -> StickyNoteRead:
|
||||||
|
return StickyNoteRead(
|
||||||
|
id=str(document["_id"]),
|
||||||
|
title=document["title"],
|
||||||
|
body=document.get("body", ""),
|
||||||
|
tags=document.get("tags", []),
|
||||||
|
color=document.get("color", "yellow"),
|
||||||
|
created_at=document["created_at"],
|
||||||
|
updated_at=document["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[StickyNoteRead])
|
||||||
|
async def list_sticky_notes(
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
cursor = database.sticky_notes.find({"account_id": str(account.id)}).sort(
|
||||||
|
"updated_at", -1
|
||||||
|
)
|
||||||
|
return [serialize_note(document) async for document in cursor]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=StickyNoteRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_sticky_note(
|
||||||
|
payload: StickyNoteCreate,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
now = utc_now()
|
||||||
|
document = {
|
||||||
|
"account_id": str(account.id),
|
||||||
|
"title": payload.title.strip(),
|
||||||
|
"body": payload.body.strip(),
|
||||||
|
"tags": [tag.strip() for tag in payload.tags if tag.strip()],
|
||||||
|
"color": payload.color,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
result = await database.sticky_notes.insert_one(document)
|
||||||
|
document["_id"] = result.inserted_id
|
||||||
|
return serialize_note(document)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{note_id}", response_model=StickyNoteRead)
|
||||||
|
async def update_sticky_note(
|
||||||
|
note_id: str,
|
||||||
|
payload: StickyNoteUpdate,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
if not ObjectId.is_valid(note_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Invalid note ID.",
|
||||||
|
)
|
||||||
|
updates = payload.model_dump(exclude_unset=True)
|
||||||
|
if "title" in updates:
|
||||||
|
updates["title"] = updates["title"].strip()
|
||||||
|
if "body" in updates:
|
||||||
|
updates["body"] = updates["body"].strip()
|
||||||
|
if "tags" in updates and updates["tags"] is not None:
|
||||||
|
updates["tags"] = [tag.strip() for tag in updates["tags"] if tag.strip()]
|
||||||
|
if not updates:
|
||||||
|
document = await database.sticky_notes.find_one(
|
||||||
|
{"_id": ObjectId(note_id), "account_id": str(account.id)}
|
||||||
|
)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")
|
||||||
|
return serialize_note(document)
|
||||||
|
updates["updated_at"] = utc_now()
|
||||||
|
document = await database.sticky_notes.find_one_and_update(
|
||||||
|
{"_id": ObjectId(note_id), "account_id": str(account.id)},
|
||||||
|
{"$set": updates},
|
||||||
|
return_document=ReturnDocument.AFTER,
|
||||||
|
)
|
||||||
|
if document is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")
|
||||||
|
return serialize_note(document)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{note_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_sticky_note(
|
||||||
|
note_id: str,
|
||||||
|
database=Depends(get_mongo_database),
|
||||||
|
account: Account = Depends(get_current_account),
|
||||||
|
):
|
||||||
|
if not ObjectId.is_valid(note_id):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="Invalid note ID.",
|
||||||
|
)
|
||||||
|
result = await database.sticky_notes.delete_one(
|
||||||
|
{"_id": ObjectId(note_id), "account_id": str(account.id)}
|
||||||
|
)
|
||||||
|
if result.deleted_count == 0:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Note not found.")
|
||||||
229
backend/app/schemas.py
Normal file
229
backend/app/schemas.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from datetime import date
|
||||||
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRegister(BaseModel):
|
||||||
|
account_type: Literal["individual", "company"]
|
||||||
|
email_name: str = Field(
|
||||||
|
min_length=1,
|
||||||
|
max_length=64,
|
||||||
|
pattern=r"^[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?$",
|
||||||
|
)
|
||||||
|
company_domain: str | None = Field(default=None, max_length=253)
|
||||||
|
first_name: str = Field(min_length=1, max_length=80)
|
||||||
|
last_name: str = Field(min_length=1, max_length=80)
|
||||||
|
date_of_birth: date
|
||||||
|
phone_number: str = Field(
|
||||||
|
min_length=7,
|
||||||
|
max_length=24,
|
||||||
|
pattern=r"^\+?[0-9][0-9 ()-]{5,22}[0-9]$",
|
||||||
|
)
|
||||||
|
password: str = Field(min_length=8, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class AccountRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
account_type: str
|
||||||
|
email: EmailStr
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
date_of_birth: date
|
||||||
|
phone_number: str
|
||||||
|
company_domain: str | None
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LoginRequest(BaseModel):
|
||||||
|
email: EmailStr
|
||||||
|
password: str = Field(min_length=1, max_length=128)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthResponse(BaseModel):
|
||||||
|
access_token: str
|
||||||
|
token_type: Literal["bearer"] = "bearer"
|
||||||
|
account: AccountRead
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCreate(BaseModel):
|
||||||
|
recipients: list[EmailStr] = Field(min_length=1)
|
||||||
|
cc: list[EmailStr] = Field(default_factory=list)
|
||||||
|
bcc: list[EmailStr] = Field(default_factory=list)
|
||||||
|
subject: str = Field(default="", max_length=998, pattern=r"^[^\r\n]*$")
|
||||||
|
body_text: str = ""
|
||||||
|
body_html: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EmailRead(EmailCreate):
|
||||||
|
id: str
|
||||||
|
sender: EmailStr
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MailAddress(BaseModel):
|
||||||
|
name: str
|
||||||
|
address: str
|
||||||
|
|
||||||
|
|
||||||
|
class AttachmentRead(BaseModel):
|
||||||
|
filename: str
|
||||||
|
content_type: str
|
||||||
|
size: int
|
||||||
|
content_id: str | None = None
|
||||||
|
disposition: str = "attachment"
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSummary(BaseModel):
|
||||||
|
id: str
|
||||||
|
mailbox: Literal["inbox", "sent", "trash"]
|
||||||
|
direction: Literal["inbound", "outbound"]
|
||||||
|
sender: MailAddress
|
||||||
|
recipients: list[MailAddress]
|
||||||
|
subject: str
|
||||||
|
snippet: str
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
is_read: bool
|
||||||
|
is_starred: bool = False
|
||||||
|
trashed_at: datetime | None = None
|
||||||
|
attachments: list[AttachmentRead] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class MessageRead(MessageSummary):
|
||||||
|
cc: list[MailAddress] = Field(default_factory=list)
|
||||||
|
bcc: list[MailAddress] = Field(default_factory=list)
|
||||||
|
reply_to: list[MailAddress] = Field(default_factory=list)
|
||||||
|
body_text: str = ""
|
||||||
|
body_html: str | None = None
|
||||||
|
message_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class MessageReadState(BaseModel):
|
||||||
|
is_read: bool
|
||||||
|
|
||||||
|
|
||||||
|
class MessageStarState(BaseModel):
|
||||||
|
is_starred: bool
|
||||||
|
|
||||||
|
|
||||||
|
class StickyNoteCreate(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=120)
|
||||||
|
body: str = Field(default="", max_length=2000)
|
||||||
|
tags: list[str] = Field(default_factory=list, max_length=5)
|
||||||
|
color: Literal["yellow", "green", "blue", "pink"] = "yellow"
|
||||||
|
|
||||||
|
|
||||||
|
class StickyNoteUpdate(BaseModel):
|
||||||
|
title: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
body: str | None = Field(default=None, max_length=2000)
|
||||||
|
tags: list[str] | None = Field(default=None, max_length=5)
|
||||||
|
color: Literal["yellow", "green", "blue", "pink"] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StickyNoteRead(StickyNoteCreate):
|
||||||
|
id: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class MessagePage(BaseModel):
|
||||||
|
items: list[MessageSummary]
|
||||||
|
next_cursor: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DomainCreate(BaseModel):
|
||||||
|
domain_name: str = Field(min_length=3, max_length=253)
|
||||||
|
account_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DnsRecordRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
record_type: str
|
||||||
|
host: str
|
||||||
|
value: str
|
||||||
|
purpose: str
|
||||||
|
ttl: int
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class DnsVerificationCheck(BaseModel):
|
||||||
|
purpose: str
|
||||||
|
verified: bool
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class DnsVerificationResponse(BaseModel):
|
||||||
|
domain: str
|
||||||
|
status: str
|
||||||
|
verified: bool
|
||||||
|
checks: list[DnsVerificationCheck]
|
||||||
|
|
||||||
|
|
||||||
|
class MailDomainRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
domain_name: str
|
||||||
|
domain_type: str
|
||||||
|
account_id: UUID | None
|
||||||
|
mail_host: str
|
||||||
|
dkim_selector: str
|
||||||
|
status: str
|
||||||
|
created_at: datetime
|
||||||
|
dns_records: list[DnsRecordRead] = []
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpCredentialCreate(BaseModel):
|
||||||
|
label: str = Field(default="default", min_length=1, max_length=120)
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpCredentialRead(BaseModel):
|
||||||
|
id: UUID
|
||||||
|
username: str
|
||||||
|
label: str
|
||||||
|
is_active: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
|
||||||
|
class SmtpCredentialCreated(SmtpCredentialRead):
|
||||||
|
password: str
|
||||||
|
smtp_host: str
|
||||||
|
smtp_port: int
|
||||||
|
use_tls: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DnsSetupRequest(BaseModel):
|
||||||
|
domain: str = Field(min_length=3, max_length=253)
|
||||||
|
mail_server_ip: str = Field(min_length=3, max_length=45)
|
||||||
|
|
||||||
|
|
||||||
|
class DnsSetupRecord(BaseModel):
|
||||||
|
record_type: str
|
||||||
|
host: str
|
||||||
|
value: str
|
||||||
|
purpose: str
|
||||||
|
ttl: int
|
||||||
|
priority: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DnsSetupResponse(BaseModel):
|
||||||
|
domain: str
|
||||||
|
mail_host: str
|
||||||
|
app_public_host: str
|
||||||
|
mail_server_ip: str
|
||||||
|
is_platform_domain: bool
|
||||||
|
sections: dict[str, list[DnsSetupRecord]]
|
||||||
|
notes: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_now() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Backend service modules."""
|
||||||
176
backend/app/services/dns_verification.py
Normal file
176
backend/app/services/dns_verification.py
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
"""Live DNS checks for company domain onboarding."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import dns.exception
|
||||||
|
import dns.resolver
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_txt(value: str) -> str:
|
||||||
|
return " ".join(value.split()).strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_txt(name: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
answers = dns.resolver.resolve(name, "TXT")
|
||||||
|
except (
|
||||||
|
dns.resolver.NXDOMAIN,
|
||||||
|
dns.resolver.NoAnswer,
|
||||||
|
dns.resolver.NoNameservers,
|
||||||
|
dns.exception.Timeout,
|
||||||
|
dns.resolver.LifetimeTimeout,
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
records: list[str] = []
|
||||||
|
for answer in answers:
|
||||||
|
parts = []
|
||||||
|
for chunk in answer.strings:
|
||||||
|
if isinstance(chunk, bytes):
|
||||||
|
parts.append(chunk.decode("utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
parts.append(str(chunk))
|
||||||
|
records.append("".join(parts))
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_mx(domain_name: str) -> list[tuple[int, str]]:
|
||||||
|
try:
|
||||||
|
answers = dns.resolver.resolve(domain_name, "MX")
|
||||||
|
except (
|
||||||
|
dns.resolver.NXDOMAIN,
|
||||||
|
dns.resolver.NoAnswer,
|
||||||
|
dns.resolver.NoNameservers,
|
||||||
|
dns.exception.Timeout,
|
||||||
|
dns.resolver.LifetimeTimeout,
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
hosts: list[tuple[int, str]] = []
|
||||||
|
for answer in answers:
|
||||||
|
host = str(answer.exchange).rstrip(".").lower()
|
||||||
|
hosts.append((int(answer.preference), host))
|
||||||
|
return hosts
|
||||||
|
|
||||||
|
|
||||||
|
def verify_mx(domain_name: str, expected_host: str) -> tuple[bool, str]:
|
||||||
|
expected = expected_host.rstrip(".").lower()
|
||||||
|
hosts = _lookup_mx(domain_name)
|
||||||
|
if not hosts:
|
||||||
|
return False, f"No MX records found for {domain_name}."
|
||||||
|
hostnames = [host for _, host in hosts]
|
||||||
|
if expected in hostnames:
|
||||||
|
return True, f"MX points to {expected_host}."
|
||||||
|
return False, f"Expected MX host {expected_host}; found {', '.join(hostnames)}."
|
||||||
|
|
||||||
|
|
||||||
|
def verify_spf(domain_name: str, expected_include: str) -> tuple[bool, str]:
|
||||||
|
include_token = expected_include.rstrip(".").lower()
|
||||||
|
records = [_normalize_txt(record) for record in _lookup_txt(domain_name)]
|
||||||
|
spf_records = [record for record in records if record.startswith("v=spf1")]
|
||||||
|
if not spf_records:
|
||||||
|
return False, f"No SPF TXT record found at {domain_name}."
|
||||||
|
needle = f"include:{include_token}"
|
||||||
|
for record in spf_records:
|
||||||
|
if needle in record:
|
||||||
|
return True, f"SPF includes {expected_include}."
|
||||||
|
return False, f"SPF record must include {expected_include}."
|
||||||
|
|
||||||
|
|
||||||
|
def _lookup_a(name: str) -> list[str]:
|
||||||
|
try:
|
||||||
|
answers = dns.resolver.resolve(name, "A")
|
||||||
|
except (
|
||||||
|
dns.resolver.NXDOMAIN,
|
||||||
|
dns.resolver.NoAnswer,
|
||||||
|
dns.resolver.NoNameservers,
|
||||||
|
dns.exception.Timeout,
|
||||||
|
dns.resolver.LifetimeTimeout,
|
||||||
|
):
|
||||||
|
return []
|
||||||
|
return [str(answer).strip() for answer in answers]
|
||||||
|
|
||||||
|
|
||||||
|
def verify_platform_spf(platform_spf_host: str, mail_host: str) -> tuple[bool, str]:
|
||||||
|
"""Ensure the shared SPF include target exists and authorizes the mail host."""
|
||||||
|
records = [_normalize_txt(record) for record in _lookup_txt(platform_spf_host)]
|
||||||
|
spf_records = [record for record in records if record.startswith("v=spf1")]
|
||||||
|
if not spf_records:
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"No SPF TXT at {platform_spf_host}. On getall.help add Host _spf with "
|
||||||
|
f"v=spf1 ip4:<mail-server-ip> ~all.",
|
||||||
|
)
|
||||||
|
|
||||||
|
spf_value = spf_records[0]
|
||||||
|
mail_ips = _lookup_a(mail_host.rstrip("."))
|
||||||
|
for ip in mail_ips:
|
||||||
|
if f"ip4:{ip}" in spf_value or f"ip6:{ip}" in spf_value:
|
||||||
|
return True, f"{platform_spf_host} authorizes {mail_host} ({ip})."
|
||||||
|
|
||||||
|
if "ip4:" in spf_value or "ip6:" in spf_value or " a " in f" {spf_value} ":
|
||||||
|
return True, f"{platform_spf_host} SPF record is present."
|
||||||
|
|
||||||
|
return (
|
||||||
|
False,
|
||||||
|
f"{platform_spf_host} exists but does not appear to authorize the mail server.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_dkim(
|
||||||
|
domain_name: str,
|
||||||
|
selector: str,
|
||||||
|
expected_public_key: str,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
name = f"{selector}._domainkey.{domain_name}"
|
||||||
|
records = [_normalize_txt(record) for record in _lookup_txt(name)]
|
||||||
|
if not records:
|
||||||
|
return False, f"No DKIM TXT record found at {name}."
|
||||||
|
key_token = expected_public_key.strip().lower()
|
||||||
|
for record in records:
|
||||||
|
if key_token in record.replace(" ", ""):
|
||||||
|
return True, "DKIM public key matches."
|
||||||
|
return False, f"DKIM TXT at {selector}._domainkey is missing the expected public key."
|
||||||
|
|
||||||
|
|
||||||
|
def verify_dmarc(domain_name: str, expected_value: str) -> tuple[bool, str]:
|
||||||
|
name = f"_dmarc.{domain_name}"
|
||||||
|
records = [_normalize_txt(record) for record in _lookup_txt(name)]
|
||||||
|
if not records:
|
||||||
|
return False, f"No DMARC TXT record found at {name}."
|
||||||
|
expected = _normalize_txt(expected_value)
|
||||||
|
for record in records:
|
||||||
|
if record == expected or record.startswith("v=dmarc1"):
|
||||||
|
if "v=dmarc1" in record:
|
||||||
|
return True, "DMARC record is present."
|
||||||
|
return False, "DMARC TXT record is missing or incomplete."
|
||||||
|
|
||||||
|
|
||||||
|
def verify_company_dns(
|
||||||
|
*,
|
||||||
|
domain_name: str,
|
||||||
|
shared_mail_host: str,
|
||||||
|
dkim_selector: str,
|
||||||
|
dkim_public_key: str,
|
||||||
|
platform_spf_include: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
dmarc_expected = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||||
|
checks = [
|
||||||
|
("mx", *verify_mx(domain_name, shared_mail_host)),
|
||||||
|
("spf", *verify_spf(domain_name, platform_spf_include)),
|
||||||
|
(
|
||||||
|
"platform_spf",
|
||||||
|
*verify_platform_spf(platform_spf_include, shared_mail_host),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"dkim",
|
||||||
|
*verify_dkim(domain_name, dkim_selector, dkim_public_key),
|
||||||
|
),
|
||||||
|
("dmarc", *verify_dmarc(domain_name, dmarc_expected)),
|
||||||
|
]
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"purpose": purpose,
|
||||||
|
"verified": verified,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
for purpose, verified, message in checks
|
||||||
|
]
|
||||||
380
backend/app/services/domain_provisioning.py
Normal file
380
backend/app/services/domain_provisioning.py
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
import ipaddress
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import string
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives import serialization
|
||||||
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||||
|
from pwdlib import PasswordHash
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import DomainDnsRecord, MailDomain, SmtpCredential
|
||||||
|
|
||||||
|
password_hasher = PasswordHash.recommended()
|
||||||
|
domain_pattern = re.compile(
|
||||||
|
r"^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+"
|
||||||
|
r"[a-z]{2,63}$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_domain(value: str) -> str:
|
||||||
|
return value.strip().lower().removeprefix("@")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_domain(value: str) -> str:
|
||||||
|
domain = normalize_domain(value)
|
||||||
|
if not domain_pattern.fullmatch(domain):
|
||||||
|
raise ValueError("Enter a valid domain name, such as example.com.")
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
def generate_dkim_keys() -> tuple[str, str]:
|
||||||
|
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||||
|
private_pem = private_key.private_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PrivateFormat.PKCS8,
|
||||||
|
encryption_algorithm=serialization.NoEncryption(),
|
||||||
|
).decode()
|
||||||
|
public_pem = (
|
||||||
|
private_key.public_key()
|
||||||
|
.public_bytes(
|
||||||
|
encoding=serialization.Encoding.PEM,
|
||||||
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
||||||
|
)
|
||||||
|
.decode()
|
||||||
|
)
|
||||||
|
public_key = "".join(
|
||||||
|
line
|
||||||
|
for line in public_pem.splitlines()
|
||||||
|
if not line.startswith("-----")
|
||||||
|
)
|
||||||
|
return private_pem, public_key
|
||||||
|
|
||||||
|
|
||||||
|
def namecheap_host(host: str, domain_name: str) -> str:
|
||||||
|
"""Strip the zone suffix so records match Namecheap's Host field."""
|
||||||
|
if host in {"@", "*"}:
|
||||||
|
return host
|
||||||
|
suffix = f".{domain_name}"
|
||||||
|
if host.endswith(suffix):
|
||||||
|
return host[: -len(suffix)]
|
||||||
|
return host
|
||||||
|
|
||||||
|
|
||||||
|
def spf_ip_mechanism(mail_server_ip: str | None) -> str | None:
|
||||||
|
if not mail_server_ip or mail_server_ip == "YOUR_MAIL_SERVER_IP":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
address = ipaddress.ip_address(mail_server_ip.strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
return f"ip6:{address}" if address.version == 6 else f"ip4:{address}"
|
||||||
|
|
||||||
|
|
||||||
|
def build_spf_value(mail_server_ip: str | None = None) -> str:
|
||||||
|
parts = ["v=spf1", "mx", "a"]
|
||||||
|
ip_mechanism = spf_ip_mechanism(mail_server_ip)
|
||||||
|
if ip_mechanism:
|
||||||
|
parts.append(ip_mechanism)
|
||||||
|
parts.append("~all")
|
||||||
|
return " ".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build_shared_mx_spf_value(platform_spf_include: str) -> str:
|
||||||
|
return f"v=spf1 include:{platform_spf_include} ~all"
|
||||||
|
|
||||||
|
|
||||||
|
def build_company_dns_records(
|
||||||
|
domain_name: str,
|
||||||
|
shared_mail_host: str,
|
||||||
|
dkim_selector: str,
|
||||||
|
dkim_public_key: str,
|
||||||
|
platform_spf_include: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Zoho-style records: shared MX host, SPF include, per-domain DKIM/DMARC."""
|
||||||
|
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
|
||||||
|
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"record_type": "MX",
|
||||||
|
"host": namecheap_host("@", domain_name),
|
||||||
|
"value": shared_mail_host,
|
||||||
|
"priority": 10,
|
||||||
|
"purpose": "mx",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_type": "TXT",
|
||||||
|
"host": namecheap_host("@", domain_name),
|
||||||
|
"value": build_shared_mx_spf_value(platform_spf_include),
|
||||||
|
"purpose": "spf",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_type": "TXT",
|
||||||
|
"host": namecheap_host(f"{dkim_selector}._domainkey", domain_name),
|
||||||
|
"value": dkim_value,
|
||||||
|
"purpose": "dkim",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_type": "TXT",
|
||||||
|
"host": namecheap_host("_dmarc", domain_name),
|
||||||
|
"value": dmarc_value,
|
||||||
|
"purpose": "dmarc",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_dns_records(
|
||||||
|
domain_name: str,
|
||||||
|
mail_host: str,
|
||||||
|
dkim_selector: str,
|
||||||
|
dkim_public_key: str,
|
||||||
|
mail_server_ip: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
dkim_value = f"v=DKIM1; k=rsa; p={dkim_public_key}"
|
||||||
|
dmarc_value = f"v=DMARC1; p=quarantine; rua=mailto:dmarc@{domain_name}"
|
||||||
|
mail_subdomain = mail_host.removesuffix(f".{domain_name}")
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"record_type": "MX",
|
||||||
|
"host": namecheap_host("@", domain_name),
|
||||||
|
"value": mail_host,
|
||||||
|
"priority": 10,
|
||||||
|
"purpose": "mx",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_type": "A",
|
||||||
|
"host": namecheap_host(mail_subdomain, domain_name),
|
||||||
|
"value": mail_server_ip or "YOUR_MAIL_SERVER_IP",
|
||||||
|
"purpose": "mail_host",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_type": "TXT",
|
||||||
|
"host": namecheap_host("@", domain_name),
|
||||||
|
"value": build_spf_value(mail_server_ip),
|
||||||
|
"purpose": "spf",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_type": "TXT",
|
||||||
|
"host": namecheap_host(f"{dkim_selector}._domainkey", domain_name),
|
||||||
|
"value": dkim_value,
|
||||||
|
"purpose": "dkim",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"record_type": "TXT",
|
||||||
|
"host": namecheap_host("_dmarc", domain_name),
|
||||||
|
"value": dmarc_value,
|
||||||
|
"purpose": "dmarc",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_deployment_dns_records(
|
||||||
|
*,
|
||||||
|
domain_name: str,
|
||||||
|
mail_server_ip: str,
|
||||||
|
dkim_selector: str,
|
||||||
|
dkim_public_key: str | None,
|
||||||
|
app_public_host: str | None = None,
|
||||||
|
) -> dict[str, list[dict]]:
|
||||||
|
"""Split records into inbound delivery vs outbound authentication."""
|
||||||
|
mail_host = f"mail.{domain_name}"
|
||||||
|
dkim_key = dkim_public_key or (
|
||||||
|
"REGISTER_DOMAIN_TO_GENERATE_DKIM_PUBLIC_KEY"
|
||||||
|
)
|
||||||
|
records = build_dns_records(
|
||||||
|
domain_name,
|
||||||
|
mail_host,
|
||||||
|
dkim_selector,
|
||||||
|
dkim_key,
|
||||||
|
mail_server_ip=mail_server_ip,
|
||||||
|
)
|
||||||
|
by_purpose = {record["purpose"]: record for record in records}
|
||||||
|
inbound = [by_purpose["mx"], by_purpose["mail_host"]]
|
||||||
|
if app_public_host:
|
||||||
|
inbound.append(
|
||||||
|
{
|
||||||
|
"record_type": "A",
|
||||||
|
"host": namecheap_host(app_public_host, domain_name),
|
||||||
|
"value": mail_server_ip,
|
||||||
|
"purpose": "app_host",
|
||||||
|
"ttl": 3600,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
outbound = [by_purpose["spf"], by_purpose["dkim"], by_purpose["dmarc"]]
|
||||||
|
ip_mechanism = spf_ip_mechanism(mail_server_ip)
|
||||||
|
if ip_mechanism:
|
||||||
|
outbound.insert(
|
||||||
|
0,
|
||||||
|
{
|
||||||
|
"record_type": "TXT",
|
||||||
|
"host": namecheap_host("_spf", domain_name),
|
||||||
|
"value": f"v=spf1 {ip_mechanism} ~all",
|
||||||
|
"purpose": "platform_spf",
|
||||||
|
"ttl": 3600,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return {"inbound": inbound, "outbound": outbound}
|
||||||
|
|
||||||
|
|
||||||
|
def generate_smtp_username(domain_name: str) -> str:
|
||||||
|
slug = domain_name.replace(".", "-")
|
||||||
|
token = secrets.token_hex(4)
|
||||||
|
return f"smtp-{slug}-{token}"
|
||||||
|
|
||||||
|
|
||||||
|
def generate_smtp_password(length: int = 24) -> str:
|
||||||
|
alphabet = string.ascii_letters + string.digits
|
||||||
|
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||||
|
|
||||||
|
|
||||||
|
def record_for_db(record: dict) -> dict:
|
||||||
|
allowed = {"record_type", "host", "value", "purpose", "ttl"}
|
||||||
|
return {key: record[key] for key in allowed if key in record}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_domain_by_name(
|
||||||
|
session: AsyncSession,
|
||||||
|
domain_name: str,
|
||||||
|
) -> MailDomain | None:
|
||||||
|
return await session.scalar(
|
||||||
|
select(MailDomain).where(MailDomain.domain_name == domain_name)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def provision_mail_domain(
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
domain_name: str,
|
||||||
|
domain_type: str,
|
||||||
|
account_id=None,
|
||||||
|
) -> MailDomain:
|
||||||
|
settings = get_settings()
|
||||||
|
normalized = validate_domain(domain_name)
|
||||||
|
existing = await get_domain_by_name(session, normalized)
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
mail_host = f"mail.{normalized}"
|
||||||
|
dkim_selector = settings.dkim_selector
|
||||||
|
private_key, public_key = generate_dkim_keys()
|
||||||
|
if domain_type == "company":
|
||||||
|
mail_host = settings.smtp_public_host
|
||||||
|
dns_records = build_company_dns_records(
|
||||||
|
normalized,
|
||||||
|
settings.smtp_public_host,
|
||||||
|
dkim_selector,
|
||||||
|
public_key,
|
||||||
|
settings.platform_spf_include,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
dns_records = build_dns_records(
|
||||||
|
normalized,
|
||||||
|
mail_host,
|
||||||
|
dkim_selector,
|
||||||
|
public_key,
|
||||||
|
)
|
||||||
|
domain = MailDomain(
|
||||||
|
domain_name=normalized,
|
||||||
|
domain_type=domain_type,
|
||||||
|
account_id=account_id,
|
||||||
|
mail_host=mail_host,
|
||||||
|
dkim_selector=dkim_selector,
|
||||||
|
dkim_private_key=private_key,
|
||||||
|
dkim_public_key=public_key,
|
||||||
|
status="pending_dns",
|
||||||
|
)
|
||||||
|
session.add(domain)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
for record in dns_records:
|
||||||
|
session.add(DomainDnsRecord(domain_id=domain.id, **record_for_db(record)))
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
def register_relay_domain(domain_name: str) -> None:
|
||||||
|
"""Append a verified company domain for Postfix inbound relay."""
|
||||||
|
settings = get_settings()
|
||||||
|
normalized = validate_domain(domain_name)
|
||||||
|
path = Path(settings.relay_domains_file)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
existing = {
|
||||||
|
line.strip().lower()
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines()
|
||||||
|
if line.strip()
|
||||||
|
} if path.exists() else set()
|
||||||
|
if normalized in existing:
|
||||||
|
return
|
||||||
|
with path.open("a", encoding="utf-8") as handle:
|
||||||
|
handle.write(f"{normalized}\n")
|
||||||
|
|
||||||
|
|
||||||
|
async def activate_company_domain(session: AsyncSession, domain: MailDomain) -> MailDomain:
|
||||||
|
domain.status = "active"
|
||||||
|
register_relay_domain(domain.domain_name)
|
||||||
|
await session.flush()
|
||||||
|
return domain
|
||||||
|
|
||||||
|
|
||||||
|
async def get_account_company_domain(
|
||||||
|
session: AsyncSession,
|
||||||
|
account_id,
|
||||||
|
company_domain_name: str | None,
|
||||||
|
) -> MailDomain | None:
|
||||||
|
if not company_domain_name:
|
||||||
|
return None
|
||||||
|
return await session.scalar(
|
||||||
|
select(MailDomain)
|
||||||
|
.options(selectinload(MailDomain.dns_records))
|
||||||
|
.where(
|
||||||
|
MailDomain.domain_name == company_domain_name,
|
||||||
|
MailDomain.account_id == account_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def create_smtp_credential(
|
||||||
|
session: AsyncSession,
|
||||||
|
domain: MailDomain,
|
||||||
|
*,
|
||||||
|
label: str,
|
||||||
|
) -> tuple[SmtpCredential, str]:
|
||||||
|
username = generate_smtp_username(domain.domain_name)
|
||||||
|
plain_password = generate_smtp_password()
|
||||||
|
credential = SmtpCredential(
|
||||||
|
domain_id=domain.id,
|
||||||
|
username=username,
|
||||||
|
password_hash=password_hasher.hash(plain_password),
|
||||||
|
label=label,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
session.add(credential)
|
||||||
|
await session.flush()
|
||||||
|
return credential, plain_password
|
||||||
|
|
||||||
|
|
||||||
|
async def ensure_platform_domain(session: AsyncSession) -> MailDomain:
|
||||||
|
settings = get_settings()
|
||||||
|
domain = await get_domain_by_name(session, settings.platform_domain)
|
||||||
|
if domain:
|
||||||
|
return domain
|
||||||
|
return await provision_mail_domain(
|
||||||
|
session,
|
||||||
|
domain_name=settings.platform_domain,
|
||||||
|
domain_type="platform",
|
||||||
|
)
|
||||||
159
backend/app/services/inbound_smtp.py
Normal file
159
backend/app/services/inbound_smtp.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from aiosmtpd.smtp import SMTP
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.database import SessionFactory, mongo_db
|
||||||
|
from app.models import Account
|
||||||
|
from app.services.mime_parser import parse_mime
|
||||||
|
from app.services.raw_mime_storage import delivery_digest, get_raw_mime_storage
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def peer_is_allowed(session) -> bool:
|
||||||
|
if session is None or not getattr(session, "peer", None):
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
peer = ipaddress.ip_address(session.peer[0])
|
||||||
|
return any(
|
||||||
|
peer in ipaddress.ip_network(cidr)
|
||||||
|
for cidr in get_settings().inbound_allowed_cidrs
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class InboundHandler:
|
||||||
|
async def handle_RCPT(self, server, session, envelope, address, rcpt_options):
|
||||||
|
if not peer_is_allowed(session):
|
||||||
|
logger.warning("Rejected inbound SMTP peer %s", session.peer[0])
|
||||||
|
return "550 5.7.1 Delivery from this host is not authorized"
|
||||||
|
normalized = address.strip().lower()
|
||||||
|
async with SessionFactory() as database_session:
|
||||||
|
account = await database_session.scalar(
|
||||||
|
select(Account).where(func.lower(Account.email) == normalized)
|
||||||
|
)
|
||||||
|
if account is None:
|
||||||
|
return "550 5.1.1 Recipient address rejected: user unknown"
|
||||||
|
envelope.rcpt_tos.append(normalized)
|
||||||
|
envelope.rcpt_options.extend(rcpt_options)
|
||||||
|
if not hasattr(envelope, "account_ids"):
|
||||||
|
envelope.account_ids = {}
|
||||||
|
envelope.account_ids[normalized] = str(account.id)
|
||||||
|
return "250 2.1.5 Recipient OK"
|
||||||
|
|
||||||
|
async def handle_DATA(self, server, session, envelope):
|
||||||
|
settings = get_settings()
|
||||||
|
raw = envelope.original_content
|
||||||
|
if len(raw) > settings.inbound_max_bytes:
|
||||||
|
return "552 5.3.4 Message size exceeds fixed maximum message size"
|
||||||
|
recipients = list(dict.fromkeys(address.lower() for address in envelope.rcpt_tos))
|
||||||
|
if not recipients:
|
||||||
|
return "554 5.5.1 No valid recipients"
|
||||||
|
|
||||||
|
delivery_key = delivery_digest(raw, recipients)
|
||||||
|
try:
|
||||||
|
tombstones = getattr(mongo_db, "message_tombstones", None)
|
||||||
|
if tombstones is not None and await tombstones.find_one(
|
||||||
|
{"delivery_key": delivery_key},
|
||||||
|
{"_id": 1},
|
||||||
|
):
|
||||||
|
return f"250 2.0.0 Message previously accepted as {delivery_key}"
|
||||||
|
parsed = parse_mime(raw)
|
||||||
|
body_text = parsed.pop("body_text")
|
||||||
|
body_html = parsed.pop("body_html")
|
||||||
|
stored = await get_raw_mime_storage().put(raw, delivery_key)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
message = {
|
||||||
|
**parsed,
|
||||||
|
"direction": "inbound",
|
||||||
|
"envelope_sender": envelope.mail_from.lower(),
|
||||||
|
"envelope_recipients": recipients,
|
||||||
|
"delivery_key": delivery_key,
|
||||||
|
"raw_mime": {
|
||||||
|
"bucket": stored.bucket,
|
||||||
|
"name": stored.name,
|
||||||
|
"generation": stored.generation,
|
||||||
|
"size": stored.size,
|
||||||
|
"crc32c": stored.crc32c,
|
||||||
|
},
|
||||||
|
"received_at": now,
|
||||||
|
"created_at": now,
|
||||||
|
"has_text_body": bool(body_text),
|
||||||
|
"has_html_body": bool(body_html),
|
||||||
|
}
|
||||||
|
await mongo_db.messages.update_one(
|
||||||
|
{"delivery_key": delivery_key},
|
||||||
|
{"$setOnInsert": message},
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
persisted = await mongo_db.messages.find_one(
|
||||||
|
{"delivery_key": delivery_key},
|
||||||
|
{"_id": 1},
|
||||||
|
)
|
||||||
|
if persisted is None:
|
||||||
|
raise RuntimeError("Inbound message was not persisted.")
|
||||||
|
for recipient in recipients:
|
||||||
|
account_id = envelope.account_ids[recipient]
|
||||||
|
await mongo_db.mailbox_entries.update_one(
|
||||||
|
{
|
||||||
|
"account_id": account_id,
|
||||||
|
"message_id": persisted["_id"],
|
||||||
|
"mailbox": "inbox",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$setOnInsert": {
|
||||||
|
"account_id": account_id,
|
||||||
|
"message_id": persisted["_id"],
|
||||||
|
"mailbox": "inbox",
|
||||||
|
"direction": "inbound",
|
||||||
|
"sender": parsed["sender"],
|
||||||
|
"recipients": parsed["to"],
|
||||||
|
"subject": parsed["subject"],
|
||||||
|
"snippet": parsed["snippet"],
|
||||||
|
"attachments": parsed["attachments"],
|
||||||
|
"status": "received",
|
||||||
|
"is_read": False,
|
||||||
|
"is_starred": False,
|
||||||
|
"event_at": now,
|
||||||
|
"created_at": now,
|
||||||
|
"trashed_at": None,
|
||||||
|
"deletion_state": None,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
upsert=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Temporary failure accepting inbound message")
|
||||||
|
return "451 4.3.0 Temporary server failure; please retry"
|
||||||
|
return f"250 2.0.0 Message accepted as {delivery_key}"
|
||||||
|
|
||||||
|
|
||||||
|
class InboundSMTPServer:
|
||||||
|
def __init__(self):
|
||||||
|
self.server: asyncio.Server | None = None
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
self.server = await loop.create_server(
|
||||||
|
lambda: SMTP(
|
||||||
|
InboundHandler(),
|
||||||
|
data_size_limit=settings.inbound_max_bytes,
|
||||||
|
enable_SMTPUTF8=True,
|
||||||
|
decode_data=False,
|
||||||
|
),
|
||||||
|
host=settings.inbound_smtp_host,
|
||||||
|
port=settings.inbound_smtp_port,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
if self.server is not None:
|
||||||
|
self.server.close()
|
||||||
|
await self.server.wait_closed()
|
||||||
|
self.server = None
|
||||||
107
backend/app/services/mail_sender.py
Normal file
107
backend/app/services/mail_sender.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
import email.policy
|
||||||
|
import email.utils
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
import dkim
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.models import MailDomain
|
||||||
|
|
||||||
|
SMTP_POLICY = email.policy.SMTP.clone(max_line_length=998)
|
||||||
|
|
||||||
|
|
||||||
|
def build_message(
|
||||||
|
*,
|
||||||
|
sender: str,
|
||||||
|
recipients: list[str],
|
||||||
|
cc: list[str],
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
body_html: str | None,
|
||||||
|
) -> EmailMessage:
|
||||||
|
message = EmailMessage(policy=SMTP_POLICY)
|
||||||
|
message["From"] = sender
|
||||||
|
message["To"] = ", ".join(recipients)
|
||||||
|
if cc:
|
||||||
|
message["Cc"] = ", ".join(cc)
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["Date"] = email.utils.formatdate(localtime=True)
|
||||||
|
message["Message-ID"] = email.utils.make_msgid(domain=sender.split("@")[-1])
|
||||||
|
message.set_content(body_text)
|
||||||
|
if body_html:
|
||||||
|
message.add_alternative(body_html, subtype="html")
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
def sign_message(message: EmailMessage, domain: MailDomain) -> bytes:
|
||||||
|
raw_message = message.as_bytes(policy=SMTP_POLICY)
|
||||||
|
signed_headers = [
|
||||||
|
b"from",
|
||||||
|
b"to",
|
||||||
|
b"subject",
|
||||||
|
b"date",
|
||||||
|
b"message-id",
|
||||||
|
b"mime-version",
|
||||||
|
b"content-type",
|
||||||
|
]
|
||||||
|
if message.get("Cc"):
|
||||||
|
signed_headers.insert(3, b"cc")
|
||||||
|
signature = dkim.sign(
|
||||||
|
raw_message,
|
||||||
|
domain.dkim_selector.encode(),
|
||||||
|
domain.domain_name.encode(),
|
||||||
|
domain.dkim_private_key.encode(),
|
||||||
|
include_headers=signed_headers,
|
||||||
|
canonicalize=(b"relaxed", b"relaxed"),
|
||||||
|
)
|
||||||
|
if not signature.endswith(b"\r\n"):
|
||||||
|
signature += b"\r\n"
|
||||||
|
return signature + raw_message
|
||||||
|
|
||||||
|
|
||||||
|
async def send_via_smtp(
|
||||||
|
message_bytes: bytes,
|
||||||
|
*,
|
||||||
|
sender: str,
|
||||||
|
recipients: list[str],
|
||||||
|
) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
await aiosmtplib.send(
|
||||||
|
message_bytes,
|
||||||
|
sender=sender,
|
||||||
|
recipients=recipients,
|
||||||
|
hostname=settings.smtp_host,
|
||||||
|
port=settings.smtp_port,
|
||||||
|
username=settings.smtp_username or None,
|
||||||
|
password=settings.smtp_password or None,
|
||||||
|
start_tls=settings.smtp_use_tls,
|
||||||
|
timeout=settings.smtp_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def deliver_email(
|
||||||
|
*,
|
||||||
|
sender: str,
|
||||||
|
recipients: list[str],
|
||||||
|
cc: list[str],
|
||||||
|
bcc: list[str],
|
||||||
|
subject: str,
|
||||||
|
body_text: str,
|
||||||
|
body_html: str | None,
|
||||||
|
domain: MailDomain,
|
||||||
|
) -> None:
|
||||||
|
message = build_message(
|
||||||
|
sender=sender,
|
||||||
|
recipients=recipients,
|
||||||
|
cc=cc,
|
||||||
|
subject=subject,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
)
|
||||||
|
signed = sign_message(message, domain)
|
||||||
|
await send_via_smtp(
|
||||||
|
signed,
|
||||||
|
sender=sender,
|
||||||
|
recipients=[*recipients, *cc, *bcc],
|
||||||
|
)
|
||||||
90
backend/app/services/mime_parser.py
Normal file
90
backend/app/services/mime_parser.py
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import re
|
||||||
|
from datetime import timezone
|
||||||
|
from email import policy
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.utils import getaddresses, parsedate_to_datetime
|
||||||
|
from html import unescape
|
||||||
|
|
||||||
|
|
||||||
|
def _addresses(values: list[str]) -> list[dict[str, str]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": name.strip() or address.split("@", 1)[0],
|
||||||
|
"address": address.lower(),
|
||||||
|
}
|
||||||
|
for name, address in getaddresses(values)
|
||||||
|
if address
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _text(part) -> str:
|
||||||
|
try:
|
||||||
|
value = part.get_content()
|
||||||
|
except (LookupError, UnicodeError):
|
||||||
|
payload = part.get_payload(decode=True) or b""
|
||||||
|
value = payload.decode("utf-8", errors="replace")
|
||||||
|
return value if isinstance(value, str) else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _snippet(text: str, html: str | None, limit: int = 240) -> str:
|
||||||
|
source = text
|
||||||
|
if not source and html:
|
||||||
|
source = re.sub(r"<[^>]+>", " ", html)
|
||||||
|
source = unescape(source)
|
||||||
|
return re.sub(r"\s+", " ", source).strip()[:limit]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_mime(raw: bytes) -> dict:
|
||||||
|
message = BytesParser(policy=policy.default).parsebytes(raw)
|
||||||
|
sender = _addresses(message.get_all("from", []))
|
||||||
|
from_value = sender[0] if sender else {"name": "Unknown sender", "address": ""}
|
||||||
|
text_parts: list[str] = []
|
||||||
|
html_parts: list[str] = []
|
||||||
|
attachments: list[dict] = []
|
||||||
|
|
||||||
|
for part in message.walk():
|
||||||
|
if part.is_multipart():
|
||||||
|
continue
|
||||||
|
disposition = part.get_content_disposition()
|
||||||
|
filename = part.get_filename()
|
||||||
|
content_type = part.get_content_type()
|
||||||
|
payload = part.get_payload(decode=True) or b""
|
||||||
|
if disposition == "attachment" or filename:
|
||||||
|
attachments.append(
|
||||||
|
{
|
||||||
|
"filename": filename or "attachment",
|
||||||
|
"content_type": content_type,
|
||||||
|
"size": len(payload),
|
||||||
|
"content_id": (part.get("Content-ID") or "").strip("<>") or None,
|
||||||
|
"disposition": disposition or "attachment",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif content_type == "text/plain":
|
||||||
|
text_parts.append(_text(part))
|
||||||
|
elif content_type == "text/html":
|
||||||
|
html_parts.append(_text(part))
|
||||||
|
|
||||||
|
date = None
|
||||||
|
if message.get("Date"):
|
||||||
|
try:
|
||||||
|
date = parsedate_to_datetime(message["Date"])
|
||||||
|
if date.tzinfo is None:
|
||||||
|
date = date.replace(tzinfo=timezone.utc)
|
||||||
|
date = date.astimezone(timezone.utc)
|
||||||
|
except (TypeError, ValueError, OverflowError):
|
||||||
|
pass
|
||||||
|
body_text = "\n".join(text_parts).strip()
|
||||||
|
body_html = "\n".join(html_parts).strip() or None
|
||||||
|
return {
|
||||||
|
"message_id": (message.get("Message-ID") or "").strip() or None,
|
||||||
|
"sender": from_value,
|
||||||
|
"to": _addresses(message.get_all("to", [])),
|
||||||
|
"cc": _addresses(message.get_all("cc", [])),
|
||||||
|
"reply_to": _addresses(message.get_all("reply-to", [])),
|
||||||
|
"subject": str(message.get("Subject") or ""),
|
||||||
|
"sent_at": date,
|
||||||
|
"body_text": body_text,
|
||||||
|
"body_html": body_html,
|
||||||
|
"snippet": _snippet(body_text, body_html),
|
||||||
|
"attachments": attachments,
|
||||||
|
}
|
||||||
101
backend/app/services/raw_mime_storage.py
Normal file
101
backend/app/services/raw_mime_storage.py
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import google_crc32c
|
||||||
|
from google.api_core.exceptions import NotFound, PreconditionFailed
|
||||||
|
from google.cloud import storage
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StoredMime:
|
||||||
|
bucket: str
|
||||||
|
name: str
|
||||||
|
generation: int
|
||||||
|
size: int
|
||||||
|
crc32c: str
|
||||||
|
|
||||||
|
|
||||||
|
class RawMimeStorage:
|
||||||
|
def __init__(self, bucket: str, prefix: str = "raw", project: str | None = None):
|
||||||
|
self.bucket_name = bucket
|
||||||
|
self.prefix = prefix.strip("/")
|
||||||
|
self.client = storage.Client(project=project)
|
||||||
|
|
||||||
|
async def put(self, raw: bytes, delivery_key: str) -> StoredMime:
|
||||||
|
return await asyncio.to_thread(self._put, raw, delivery_key)
|
||||||
|
|
||||||
|
def _put(self, raw: bytes, delivery_key: str) -> StoredMime:
|
||||||
|
name = f"{self.prefix}/{delivery_key[:2]}/{delivery_key}.eml"
|
||||||
|
blob = self.client.bucket(self.bucket_name).blob(name)
|
||||||
|
checksum = google_crc32c.Checksum(raw).hexdigest().decode("ascii")
|
||||||
|
try:
|
||||||
|
blob.upload_from_string(
|
||||||
|
raw,
|
||||||
|
content_type="message/rfc822",
|
||||||
|
checksum="crc32c",
|
||||||
|
if_generation_match=0,
|
||||||
|
)
|
||||||
|
blob.reload()
|
||||||
|
except PreconditionFailed:
|
||||||
|
blob.reload()
|
||||||
|
return StoredMime(
|
||||||
|
bucket=self.bucket_name,
|
||||||
|
name=name,
|
||||||
|
generation=int(blob.generation),
|
||||||
|
size=len(raw),
|
||||||
|
crc32c=checksum,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get(self, reference: dict) -> bytes:
|
||||||
|
return await asyncio.to_thread(self._get, reference)
|
||||||
|
|
||||||
|
def _get(self, reference: dict) -> bytes:
|
||||||
|
generation = int(reference["generation"])
|
||||||
|
blob = self.client.bucket(reference["bucket"]).blob(
|
||||||
|
reference["name"],
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
return blob.download_as_bytes(if_generation_match=generation)
|
||||||
|
|
||||||
|
async def delete(self, reference: dict) -> None:
|
||||||
|
await asyncio.to_thread(self._delete, reference)
|
||||||
|
|
||||||
|
def _delete(self, reference: dict) -> None:
|
||||||
|
generation = int(reference["generation"])
|
||||||
|
blob = self.client.bucket(reference["bucket"]).blob(
|
||||||
|
reference["name"],
|
||||||
|
generation=generation,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
blob.delete(if_generation_match=generation)
|
||||||
|
except NotFound:
|
||||||
|
# A prior delete may have removed GCS before Mongo acknowledged it.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_digest(raw: bytes, recipients: list[str]) -> str:
|
||||||
|
digest = hashlib.sha256()
|
||||||
|
digest.update(raw)
|
||||||
|
digest.update(b"\0")
|
||||||
|
digest.update("\0".join(sorted(set(recipients))).encode())
|
||||||
|
return digest.hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
_storage: RawMimeStorage | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_raw_mime_storage() -> RawMimeStorage:
|
||||||
|
global _storage
|
||||||
|
if _storage is None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.gcs_raw_mime_bucket:
|
||||||
|
raise RuntimeError("GCS_RAW_MIME_BUCKET is required for inbound mail.")
|
||||||
|
_storage = RawMimeStorage(
|
||||||
|
settings.gcs_raw_mime_bucket,
|
||||||
|
settings.gcs_raw_mime_prefix,
|
||||||
|
settings.gcs_project,
|
||||||
|
)
|
||||||
|
return _storage
|
||||||
19
backend/requirements.txt
Normal file
19
backend/requirements.txt
Normal 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
|
||||||
87
backend/tests/test_company_dns.py
Normal file
87
backend/tests/test_company_dns.py
Normal 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",
|
||||||
|
]
|
||||||
76
backend/tests/test_dns_setup.py
Normal file
76
backend/tests/test_dns_setup.py
Normal 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"]
|
||||||
49
backend/tests/test_email_helpers.py
Normal file
49
backend/tests/test_email_helpers.py
Normal 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"
|
||||||
143
backend/tests/test_inbound_smtp.py
Normal file
143
backend/tests/test_inbound_smtp.py
Normal 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")
|
||||||
29
backend/tests/test_mail_sender.py
Normal file
29
backend/tests/test_mail_sender.py
Normal 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"
|
||||||
28
backend/tests/test_mime_parser.py
Normal file
28
backend/tests/test_mime_parser.py
Normal 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
|
||||||
678
deploy/app/az_2_letter_color_map.json
Normal file
678
deploy/app/az_2_letter_color_map.json
Normal 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"
|
||||||
|
}
|
||||||
132
deploy/app/docker-compose.yml
Normal file
132
deploy/app/docker-compose.yml
Normal 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
12
deploy/caddy/Caddyfile
Normal 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
|
||||||
|
}
|
||||||
28
deploy/caddy/docker-compose.yml
Normal file
28
deploy/caddy/docker-compose.yml
Normal 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:
|
||||||
43
deploy/smtp/docker-compose.yml
Normal file
43
deploy/smtp/docker-compose.yml
Normal 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
17
docker-compose.yml
Normal 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
3
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
17
frontend/Dockerfile
Normal file
17
frontend/Dockerfile
Normal 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
14
frontend/index.html
Normal 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
27
frontend/nginx.conf
Normal 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
763
frontend/package-lock.json
generated
Normal 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
19
frontend/package.json
Normal 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
27
frontend/src/App.jsx
Normal 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
106
frontend/src/api.js
Normal 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,
|
||||||
|
});
|
||||||
27
frontend/src/components/AuthLayout.jsx
Normal file
27
frontend/src/components/AuthLayout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
frontend/src/components/Brand.jsx
Normal file
11
frontend/src/components/Brand.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
139
frontend/src/components/CompanyDnsSetup.jsx
Normal file
139
frontend/src/components/CompanyDnsSetup.jsx
Normal 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:<mail-server-ip> ~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>
|
||||||
|
);
|
||||||
|
}
|
||||||
166
frontend/src/components/ComposeModal.jsx
Normal file
166
frontend/src/components/ComposeModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
frontend/src/components/DashboardPage.jsx
Normal file
213
frontend/src/components/DashboardPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
46
frontend/src/components/LoginPage.jsx
Normal file
46
frontend/src/components/LoginPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
110
frontend/src/components/MailDetail.jsx
Normal file
110
frontend/src/components/MailDetail.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
frontend/src/components/MailRow.jsx
Normal file
75
frontend/src/components/MailRow.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
304
frontend/src/components/MailboxView.jsx
Normal file
304
frontend/src/components/MailboxView.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
34
frontend/src/components/MobileLanding.jsx
Normal file
34
frontend/src/components/MobileLanding.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
23
frontend/src/components/SenderAvatar.jsx
Normal file
23
frontend/src/components/SenderAvatar.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
80
frontend/src/components/SignupPage.jsx
Normal file
80
frontend/src/components/SignupPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
271
frontend/src/components/SmtpCredentialsPage.jsx
Normal file
271
frontend/src/components/SmtpCredentialsPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
frontend/src/components/StickyNotesPanel.jsx
Normal file
283
frontend/src/components/StickyNotesPanel.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
65
frontend/src/components/WebLanding.jsx
Normal file
65
frontend/src/components/WebLanding.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
678
frontend/src/data/az_2_letter_color_map.json
Normal file
678
frontend/src/data/az_2_letter_color_map.json
Normal 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
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
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
BIN
frontend/src/data/pink.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
BIN
frontend/src/data/yellow.png
Normal file
BIN
frontend/src/data/yellow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal 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
453
frontend/src/styles.css
Normal 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; } }
|
||||||
63
frontend/src/utils/senderAvatar.js
Normal file
63
frontend/src/utils/senderAvatar.js
Normal 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
17
smtp/Dockerfile
Normal 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
142
smtp/entrypoint.sh
Normal 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
43
smtp/main.cf
Normal 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
25
smtp/master.cf
Normal 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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue