From a3a6464a99680276498f29891e31bc189e295909 Mon Sep 17 00:00:00 2001 From: debu-2 Date: Sun, 4 Oct 2026 15:35:17 +0530 Subject: [PATCH] company flows --- DEPLOYMENT.md | 35 +- backend/app/config.py | 2 +- backend/app/routers/domains.py | 149 ++++- backend/app/schemas.py | 4 + backend/app/services/domain_provisioning.py | 31 + backend/tests/test_dns_setup.py | 2 +- deploy/app/docker-compose.yml | 4 +- deploy/caddy/Caddyfile | 5 +- deploy/caddy/docker-compose.yml | 3 +- deploy/smtp/docker-compose.yml | 2 +- docs/GENTIC_EMAIL_DNS.md | 25 +- frontend/src/api.js | 12 + frontend/src/components/CompanyDomains.jsx | 661 ++++++++++++++++++++ frontend/src/components/DashboardPage.jsx | 17 +- frontend/src/styles.css | 71 +++ 15 files changed, 963 insertions(+), 60 deletions(-) create mode 100644 frontend/src/components/CompanyDomains.jsx diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index bfa4a1b..51d42cc 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -3,13 +3,12 @@ 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 `gentic.email` can stay on another host (e.g. GCP). -Only the email stack lives on the mail VPS: +The Agmail stack lives on the **mail VPS**: | Hostname | Points to | Purpose | |----------|-----------|---------| -| `gentic.email` | GCP / main site IP | Marketing / main app (unchanged) | -| `email.gentic.email` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend) | +| `gentic.email` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend, primary) | +| `email.getall.help` | **Mail VPS public IP** | Legacy alternate URL to the same web UI | | `mail.gentic.email` | **Mail VPS public IP** | Postfix :25 (inbound + outbound) | | `@gentic.email` MX | `mail.gentic.email` | Delivers mail to the VPS | @@ -18,7 +17,7 @@ User addresses remain **`user@gentic.email`**. ## Architecture ```text -Browser ──HTTPS──► email.gentic.email (Caddy :443) +Browser ──HTTPS──► gentic.email / email.getall.help (Caddy :443) └──► frontend ──► backend ──SMTP──► smtp:25 (Postfix outbound) Internet ──SMTP:25──► mail.gentic.email (Postfix) @@ -34,7 +33,7 @@ cp .env.example .env docker compose up -d --build ``` -Open **https://email.gentic.email** after DNS and TLS are ready. +Open **https://gentic.email** (or **https://email.getall.help**) after DNS and TLS are ready. | Service | Host port | Purpose | |----------|-----------|---------| @@ -61,7 +60,7 @@ GCS_RAW_MIME_BUCKET= GCS_PROJECT= GCP_CREDENTIALS_FILE=./midyear-karma-470610-i1-da5334d20acc.json CADDY_ACME_EMAIL=admin@gentic.email -CORS_ORIGINS=["https://email.gentic.email"] +CORS_ORIGINS=["https://gentic.email","https://email.getall.help"] ``` Quote values that contain `#`. @@ -78,13 +77,14 @@ Outbound uses the Docker Postfix service by default (`SMTP_HOST=smtp`, PLATFORM_DOMAIN=gentic.email # Web UI on this host (Caddy terminates TLS here) -APP_PUBLIC_HOST=email.gentic.email +APP_PUBLIC_HOST=gentic.email +CADDY_ALT_HOST=email.getall.help CADDY_ACME_EMAIL=admin@gentic.email # Postfix hostname (inbound + outbound) MAIL_HOSTNAME=mail.gentic.email MAIL_DOMAIN=gentic.email -INBOUND_DOMAINS=gentic.email +INBOUND_DOMAINS=gentic.email,getall.help SMTP_PUBLIC_HOST=mail.gentic.email SMTP_HOST=smtp SMTP_PORT=25 @@ -100,14 +100,13 @@ INBOUND_SMTP_BIND_IP=127.0.0.1 ## Namecheap DNS changes Update DNS as follows (`` = public IP of the mail server). Generate the -exact rows at **https://email.gentic.email/smtp-credentials**: +exact rows at **https://gentic.email/smtp-credentials**: ### Inbound | Type | Host | Value | Notes | |------|------|-------|-------| -| **A** | `@` | main site IP | **Keep** — marketing site | -| **A** | `email` | `` | Web UI | +| **A** | `@` | `` | Agmail web UI | | **A** | `mail` | `` | Postfix | | **MX** | `@` | `10 mail.gentic.email` | Priority 10 | @@ -163,7 +162,7 @@ printf 'EHLO test\r\nSTARTTLS\r\n' | nc mail.gentic.email 25 Caddy obtains Let's Encrypt certificates automatically when: -1. `APP_PUBLIC_HOST=email.gentic.email` resolves to this server's public IP +1. `APP_PUBLIC_HOST` and `CADDY_ALT_HOST` resolve to this server's public IP 2. Ports 80 and 443 are open 3. `CADDY_ACME_EMAIL` is set in `.env` @@ -173,7 +172,8 @@ Config: `deploy/caddy/Caddyfile` → reverse-proxies to `frontend:80`. ```bash # HTTPS UI -curl -fsSI https://email.gentic.email/health +curl -fsSI https://gentic.email/health +curl -fsSI https://email.getall.help/health # Inbound mail (account must exist) swaks \ @@ -211,7 +211,8 @@ 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.gentic.email +# APP_PUBLIC_HOST=gentic.email +# CADDY_ALT_HOST=email.getall.help # CADDY_ACME_EMAIL=you@gmail.com # 2. Check Caddy logs for ACME errors @@ -227,12 +228,12 @@ docker compose logs -f caddy Wait 30–60 seconds, then test: ```bash -curl -fsSI https://email.gentic.email/ +curl -fsSI https://gentic.email/ ``` Requirements for Let's Encrypt: -- `email.gentic.email` A record → this server's public IP +- `gentic.email` and `email.getall.help` A records → this server's public IP - Firewall allows **inbound TCP 80 and 443** from `0.0.0.0/0` - `CADDY_ACME_EMAIL` set in `.env` diff --git a/backend/app/config.py b/backend/app/config.py index a4a61e9..ada0b14 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -23,7 +23,7 @@ class Settings(BaseSettings): platform_domain: str = "gentic.email" platform_spf_include: str = "_spf.gentic.email" - app_public_host: str = "email.gentic.email" + app_public_host: str = "gentic.email" dkim_selector: str = "agmail" # Outbound via self-hosted Postfix (Docker service `smtp` by default) diff --git a/backend/app/routers/domains.py b/backend/app/routers/domains.py index a16e9bf..c00c5a9 100644 --- a/backend/app/routers/domains.py +++ b/backend/app/routers/domains.py @@ -12,6 +12,7 @@ from app.routers.auth import get_current_account from app.schemas import ( DnsVerificationResponse, DomainCreate, + DomainNameCreate, MailDomainRead, SmtpCredentialCreate, SmtpCredentialCreated, @@ -20,11 +21,14 @@ from app.schemas import ( from app.services.dns_verification import verify_company_dns from app.services.domain_policy import assert_company_domain_allowed from app.services.domain_provisioning import ( + account_is_domain_owner, activate_company_domain, create_smtp_credential, ensure_ownership_dns_record, ensure_platform_domain, get_account_company_domain, + get_owned_domain, + list_owned_domains, provision_mail_domain, validate_domain, ) @@ -32,6 +36,56 @@ from app.services.domain_provisioning import ( router = APIRouter(prefix="/domains", tags=["domains"]) +async def require_domain_owner( + account: Account, + session: AsyncSession, +) -> None: + if account.account_type != "company": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Company accounts only.", + ) + if not await account_is_domain_owner(session, account.id, account.company_domain): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Only the domain owner can manage domains.", + ) + + +async def verify_owned_domain_dns( + session: AsyncSession, + domain: MailDomain, +) -> DnsVerificationResponse: + settings = get_settings() + if domain.status == "active": + return DnsVerificationResponse( + domain=domain.domain_name, + status=domain.status, + verified=True, + checks=[], + ) + + verification_token = await ensure_ownership_dns_record(session, domain) + 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, + verification_token=verification_token, + ) + 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, + ) + + async def get_domain_or_404( domain_id: UUID, session: AsyncSession, @@ -104,25 +158,86 @@ async def verify_my_company_dns( checks=[], ) - verification_token = await ensure_ownership_dns_record(session, domain) - 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, - verification_token=verification_token, + return await verify_owned_domain_dns(session, domain) + + +@router.get("/me/domains", response_model=list[MailDomainRead]) +async def list_my_domains( + account: Account = Depends(get_current_account), + session: AsyncSession = Depends(get_postgres_session), +) -> list[MailDomain]: + await require_domain_owner(account, session) + return await list_owned_domains(session, account.id) + + +@router.post("/me/domains", response_model=MailDomainRead, status_code=status.HTTP_201_CREATED) +async def add_my_domain( + payload: DomainNameCreate, + account: Account = Depends(get_current_account), + session: AsyncSession = Depends(get_postgres_session), +) -> MailDomain: + settings = get_settings() + await require_domain_owner(account, session) + try: + normalized = assert_company_domain_allowed( + payload.domain_name, + settings.platform_domain, + ) + except ValueError as error: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(error), + ) from error + + existing = await session.scalar( + select(MailDomain).where(MailDomain.domain_name == normalized) ) - 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, + 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=account.id, ) + await session.commit() + return await get_owned_domain(session, domain.id, account.id) + + +@router.get("/me/domains/{domain_id}", response_model=MailDomainRead) +async def get_my_domain( + domain_id: UUID, + account: Account = Depends(get_current_account), + session: AsyncSession = Depends(get_postgres_session), +) -> MailDomain: + await require_domain_owner(account, session) + domain = await get_owned_domain(session, domain_id, account.id) + if domain is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Domain not found.", + ) + return domain + + +@router.post("/me/domains/{domain_id}/verify-dns", response_model=DnsVerificationResponse) +async def verify_my_domain_dns( + domain_id: UUID, + account: Account = Depends(get_current_account), + session: AsyncSession = Depends(get_postgres_session), +) -> DnsVerificationResponse: + await require_domain_owner(account, session) + domain = await get_owned_domain(session, domain_id, account.id) + if domain is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Domain not found.", + ) + return await verify_owned_domain_dns(session, domain) @router.get("/platform", response_model=MailDomainRead) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 21538af..a173765 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -143,6 +143,10 @@ class DomainCreate(BaseModel): account_id: UUID | None = None +class DomainNameCreate(BaseModel): + domain_name: str = Field(min_length=3, max_length=253) + + class DnsRecordRead(BaseModel): id: UUID record_type: str diff --git a/backend/app/services/domain_provisioning.py b/backend/app/services/domain_provisioning.py index 5b56f66..64d835d 100644 --- a/backend/app/services/domain_provisioning.py +++ b/backend/app/services/domain_provisioning.py @@ -385,6 +385,37 @@ async def account_is_domain_owner( return domain_id is not None +async def get_owned_domain( + session: AsyncSession, + domain_id, + account_id, +) -> MailDomain | None: + return await session.scalar( + select(MailDomain) + .options( + selectinload(MailDomain.dns_records), + selectinload(MailDomain.smtp_credentials), + ) + .where( + MailDomain.id == domain_id, + MailDomain.account_id == account_id, + ) + ) + + +async def list_owned_domains( + session: AsyncSession, + account_id, +) -> list[MailDomain]: + result = await session.scalars( + select(MailDomain) + .options(selectinload(MailDomain.dns_records)) + .where(MailDomain.account_id == account_id) + .order_by(MailDomain.created_at.asc()) + ) + return list(result.all()) + + async def get_account_company_domain( session: AsyncSession, account_id, diff --git a/backend/tests/test_dns_setup.py b/backend/tests/test_dns_setup.py index 962e99f..3d74acf 100644 --- a/backend/tests/test_dns_setup.py +++ b/backend/tests/test_dns_setup.py @@ -48,7 +48,7 @@ def test_deployment_sections_split_inbound_and_outbound(): mail_server_ip="203.0.113.10", dkim_selector="agmail", dkim_public_key="test-public-key", - app_public_host="email.gentic.email", + app_public_host="gentic.email", ) inbound_purposes = [record["purpose"] for record in sections["inbound"]] diff --git a/deploy/app/docker-compose.yml b/deploy/app/docker-compose.yml index 035c0c7..acf738d 100644 --- a/deploy/app/docker-compose.yml +++ b/deploy/app/docker-compose.yml @@ -63,7 +63,7 @@ services: JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET to a long random value} PLATFORM_DOMAIN: ${PLATFORM_DOMAIN:-gentic.email} PLATFORM_SPF_INCLUDE: ${PLATFORM_SPF_INCLUDE:-_spf.gentic.email} - APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.gentic.email} + APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-gentic.email} DKIM_SELECTOR: ${DKIM_SELECTOR:-agmail} SMTP_HOST: ${SMTP_HOST:-smtp} SMTP_PORT: ${SMTP_PORT:-25} @@ -81,7 +81,7 @@ services: 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"]} + CORS_ORIGINS: ${CORS_ORIGINS:-["https://gentic.email","https://email.getall.help","http://localhost:8080"]} DNS_SETUP_USERNAME: ${DNS_SETUP_USERNAME:-smtp-credentia} DNS_SETUP_PASSWORD: ${DNS_SETUP_PASSWORD:-Nishtha#01} ports: diff --git a/deploy/caddy/Caddyfile b/deploy/caddy/Caddyfile index 9b0e4d9..689637c 100644 --- a/deploy/caddy/Caddyfile +++ b/deploy/caddy/Caddyfile @@ -1,11 +1,12 @@ # Public HTTPS entrypoint for the Agmail web UI. -# APP_PUBLIC_HOST and CADDY_ACME_EMAIL are injected by docker compose. +# APP_PUBLIC_HOST (primary), CADDY_ALT_HOST (legacy alias), and CADDY_ACME_EMAIL +# are injected by docker compose. { email {$CADDY_ACME_EMAIL} } -{$APP_PUBLIC_HOST} { +{$APP_PUBLIC_HOST}, {$CADDY_ALT_HOST} { tls {$CADDY_ACME_EMAIL} encode gzip zstd reverse_proxy frontend:80 diff --git a/deploy/caddy/docker-compose.yml b/deploy/caddy/docker-compose.yml index 536c646..7ab165b 100644 --- a/deploy/caddy/docker-compose.yml +++ b/deploy/caddy/docker-compose.yml @@ -11,7 +11,8 @@ services: - "${CADDY_HTTPS_PORT:-443}:443" - "${CADDY_HTTPS_PORT:-443}:443/udp" environment: - APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-email.gentic.email} + APP_PUBLIC_HOST: ${APP_PUBLIC_HOST:-gentic.email} + CADDY_ALT_HOST: ${CADDY_ALT_HOST:-email.getall.help} CADDY_ACME_EMAIL: ${CADDY_ACME_EMAIL:?Set CADDY_ACME_EMAIL for Let's Encrypt} env_file: - ../../.env diff --git a/deploy/smtp/docker-compose.yml b/deploy/smtp/docker-compose.yml index fab72be..16f4cd1 100644 --- a/deploy/smtp/docker-compose.yml +++ b/deploy/smtp/docker-compose.yml @@ -14,7 +14,7 @@ services: environment: MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.gentic.email} MAIL_DOMAIN: ${MAIL_DOMAIN:-gentic.email} - INBOUND_DOMAINS: ${INBOUND_DOMAINS:-gentic.email} + INBOUND_DOMAINS: ${INBOUND_DOMAINS:-gentic.email,getall.help} RELAY_DOMAINS_FILE: /data/relay_domains/domains.list RELAY_DOMAINS_REFRESH: ${RELAY_DOMAINS_REFRESH:-60} BACKEND_INGEST_HOST: ${BACKEND_INGEST_HOST:-backend} diff --git a/docs/GENTIC_EMAIL_DNS.md b/docs/GENTIC_EMAIL_DNS.md index 1a243d4..2808edd 100644 --- a/docs/GENTIC_EMAIL_DNS.md +++ b/docs/GENTIC_EMAIL_DNS.md @@ -6,8 +6,8 @@ This guide covers DNS changes when moving from `getall.help` to `gentic.email`. | Hostname | Purpose | |----------|---------| -| `gentic.email` | Platform domain — individual users get `@gentic.email` addresses | -| `email.gentic.email` | Web UI (Caddy HTTPS) | +| `gentic.email` | Platform domain + primary Agmail web UI (Caddy HTTPS) | +| `email.getall.help` | Legacy alternate URL to the same web UI | | `mail.gentic.email` | Postfix SMTP (inbound :25 + outbound) | | `_spf.gentic.email` | Platform SPF include target (TXT on `gentic.email`, host `_spf`) | @@ -22,13 +22,14 @@ Copy and edit `.env` from `.env.example`: ```env PLATFORM_DOMAIN=gentic.email PLATFORM_SPF_INCLUDE=_spf.gentic.email -APP_PUBLIC_HOST=email.gentic.email +APP_PUBLIC_HOST=gentic.email +CADDY_ALT_HOST=email.getall.help CADDY_ACME_EMAIL=admin@gentic.email SMTP_PUBLIC_HOST=mail.gentic.email MAIL_HOSTNAME=mail.gentic.email MAIL_DOMAIN=gentic.email -INBOUND_DOMAINS=gentic.email -CORS_ORIGINS=["https://email.gentic.email"] +INBOUND_DOMAINS=gentic.email,getall.help +CORS_ORIGINS=["https://gentic.email","https://email.getall.help"] ``` Redeploy app + SMTP + Caddy stacks after updating env. @@ -43,8 +44,8 @@ Add or update these records at the **gentic.email** DNS provider: | Type | Host | Value | Notes | |------|------|-------|-------| +| **A** | `@` | `` | Agmail web UI (primary) | | **A** | `mail` | `` | Mail server | -| **A** | `email` | `` | Web UI | | **MX** | `@` | `10 mail.gentic.email` | Platform inbox delivery | ### Outbound authentication (platform domain) @@ -56,7 +57,9 @@ Add or update these records at the **gentic.email** DNS provider: | **TXT** | `agmail._domainkey` | *(from /smtp-credentials page after domain register)* | | **TXT** | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@gentic.email` | -Generate exact DKIM/SPF rows from **https://email.gentic.email/smtp-credentials** once DNS for `email` resolves. +Generate exact DKIM/SPF rows from **https://gentic.email/smtp-credentials** once DNS resolves. + +In the **getall.help** zone, add `email` → `` for the legacy web entry. ### Reverse DNS (PTR) @@ -83,7 +86,7 @@ Blocked domains (`google.com`, etc.) cannot be registered as company domains. 1. Lower TTL on old `getall.help` records 24h before (optional). 2. Publish new `gentic.email` A/MX/TXT records. 3. Update `.env` and redeploy Docker stacks. -4. Verify TLS: `curl -fsSI https://email.gentic.email/health` +4. Verify TLS: `curl -fsSI https://gentic.email/health` and `curl -fsSI https://email.getall.help/health` 5. Verify SMTP banner: `nc mail.gentic.email 25` 6. Send test mail to `user@gentic.email` and from the app. 7. Re-verify any active company domains (ownership TXT may be added on next verify). @@ -94,8 +97,8 @@ Blocked domains (`google.com`, etc.) cannot be registered as company domains. After cutover you can remove or repoint: -- `email.getall.help`, `mail.getall.help` A records -- `getall.help` MX if it pointed at the mail VPS +- `mail.getall.help` A record (if no longer used) +- `getall.help` MX if it pointed at the mail VPS and you've moved all mail to `@gentic.email` - `_spf.getall.help` if no longer used -Keep old records until you're confident nothing still references `getall.help`. +Keep **`email.getall.help`** while you want the legacy web URL. Remove it once all users use `https://gentic.email`. diff --git a/frontend/src/api.js b/frontend/src/api.js index 0b327ad..88d4605 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -36,6 +36,18 @@ export const getMyCompanyDomain = (token) => request("/domains/me", { token }); export const verifyCompanyDns = (token) => request("/domains/me/verify-dns", { method: "POST", token }); +export const listMyDomains = (token) => + request("/domains/me/domains", { token }); +export const addMyDomain = (domainName, token) => + request("/domains/me/domains", { + method: "POST", + payload: { domain_name: domainName }, + token, + }); +export const getMyDomain = (domainId, token) => + request(`/domains/me/domains/${domainId}`, { token }); +export const verifyMyDomainDns = (domainId, token) => + request(`/domains/me/domains/${domainId}/verify-dns`, { method: "POST", token }); export const sendEmail = (payload, token) => request("/emails", { method: "POST", payload, token }); diff --git a/frontend/src/components/CompanyDomains.jsx b/frontend/src/components/CompanyDomains.jsx new file mode 100644 index 0000000..b9b99ee --- /dev/null +++ b/frontend/src/components/CompanyDomains.jsx @@ -0,0 +1,661 @@ +import { + ArrowLeft, + ArrowRight, + Check, + CheckCircle2, + ChevronRight, + Copy, + Globe, + Info, + Lock, + Plus, + RefreshCw, + Shield, + XCircle, +} from "lucide-react"; +import { useCallback, useEffect, useState } from "react"; +import { + addMyDomain, + getMyDomain, + listMyDomains, + verifyMyDomainDns, +} from "../api"; + +const ADD_STEPS = [ + { id: 1, title: "Add domain" }, + { id: 2, title: "Verify" }, + { id: 3, title: "Configure DNS" }, + { id: 4, title: "Complete" }, +]; + +const DETAIL_TABS = ["Overview", "DNS Configuration", "Security", "Email Routing"]; + +const DNS_GROUPS = [ + { + purpose: "mx", + title: "MX Records", + description: "Directs incoming email to AGMail servers", + tone: "violet", + }, + { + purpose: "spf", + title: "SPF Record", + description: "Authorizes AGMail to send email on your behalf", + tone: "amber", + }, + { + purpose: "dkim", + title: "DKIM Record", + description: "Cryptographically signs outgoing email", + tone: "teal", + }, + { + purpose: "dmarc", + title: "DMARC Record", + description: "Tells receivers how to handle authentication failures", + tone: "coral", + }, +]; + +function formatRelativeTime(date) { + if (!date) return null; + const seconds = Math.floor((Date.now() - date.getTime()) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`; + const days = Math.floor(hours / 24); + return `${days} day${days === 1 ? "" : "s"} ago`; +} + +async function copyText(value) { + try { + await navigator.clipboard.writeText(value); + } catch { + /* clipboard may be unavailable */ + } +} + +function DnsRecordTable({ records }) { + return ( +
+ + + + + + + + + + + {records.map((record) => { + const rowKey = `${record.record_type}-${record.host}-${record.purpose}`; + const displayValue = record.record_type === "MX" + ? `10 ${record.value}` + : record.value; + return ( + + + + + + + ); + })} + +
TypeHostValueTTL
{record.record_type}{record.host} +
+ {displayValue} + +
+
{record.ttl}
+
+ ); +} + +function OwnershipRecord({ record }) { + if (!record) return null; + return ( +
+ + + +
+ ); +} + +function DomainStatusBadge({ status }) { + const active = status === "active"; + return ( + + {active ? : } + {active ? "Active" : "Pending DNS"} + + ); +} + +function AddDomainWizard({ + onCancel, + onComplete, +}) { + const token = sessionStorage.getItem("agmail_access_token"); + const [step, setStep] = useState(1); + const [domainName, setDomainName] = useState(""); + const [domain, setDomain] = useState(null); + const [checks, setChecks] = useState(null); + const [status, setStatus] = useState({ loading: false, error: "" }); + + const ownershipRecord = domain?.dns_records?.find((r) => r.purpose === "ownership"); + const mailRecords = domain?.dns_records?.filter((r) => r.purpose !== "ownership") || []; + + async function handleAddDomain(event) { + event.preventDefault(); + setStatus({ loading: true, error: "" }); + try { + const created = await addMyDomain(domainName.trim().toLowerCase(), token); + setDomain(created); + setStep(2); + setStatus({ loading: false, error: "" }); + } catch (error) { + setStatus({ loading: false, error: error.message }); + } + } + + async function handleVerify() { + if (!domain) return; + setStatus({ loading: true, error: "" }); + setChecks(null); + try { + const result = await verifyMyDomainDns(domain.id, token); + setChecks(result.checks); + if (result.verified) { + const refreshed = await getMyDomain(domain.id, token); + setDomain(refreshed); + setStep(4); + setStatus({ loading: false, error: "" }); + return; + } + const ownershipOk = result.checks?.find((c) => c.purpose === "ownership")?.verified; + if (step === 2 && ownershipOk) { + setStep(3); + } + 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 ( +
+ + +
+

Add and verify your domain

+

Use your company domain for professional email addresses.

+
+ +
    + {ADD_STEPS.map((item) => { + const complete = item.id < step || (domain?.status === "active" && item.id <= 4); + const active = item.id === step; + return ( +
  1. + + {item.title} +
  2. + ); + })} +
+ + {step === 1 && ( +
+

Enter your domain

+
+ +

+ You'll be able to create email addresses like you@{domainName || "acme.com"} +

+ {status.error &&

{status.error}

} +
+ +
+
+
+ )} + + {step >= 2 && domain && ( +
+

Verify domain ownership

+

+ Add the following TXT record to your DNS provider for {domain.domain_name}. +

+ +

+ + Need help? View your registrar's DNS documentation. +

+ {checks && checks.length > 0 && ( +
+ {checks.map((check) => ( +
+ {check.verified ? : } +
+ {check.purpose.toUpperCase()} + {check.message} +
+
+ ))} +
+ )} + {status.error && step < 4 && ( +

{status.error}

+ )} + {step < 4 && ( +
+ +
+ )} +
+ )} + + {step >= 3 && domain && step < 4 && ( +
+

Configure DNS records

+

+ Add these records to enable receiving and sending email for {domain.domain_name}. +

+ +
+ )} + + {step === 4 && domain && ( +
+ +

{domain.domain_name} is ready

+

DNS is verified. You can manage routing and security from the domain detail page.

+ +
+ )} +
+ ); +} + +function DomainDetail({ + domain, + primaryDomainName, + onBack, + onRefresh, +}) { + const token = sessionStorage.getItem("agmail_access_token"); + const [tab, setTab] = useState("DNS Configuration"); + const [checks, setChecks] = useState(null); + const [lastChecked, setLastChecked] = useState(null); + const [status, setStatus] = useState({ loading: false, error: "" }); + const isPrimary = domain.domain_name === primaryDomainName; + + const runVerify = useCallback(async () => { + setStatus({ loading: true, error: "" }); + try { + const result = await verifyMyDomainDns(domain.id, token); + setChecks(result.checks); + setLastChecked(new Date()); + if (result.verified) { + onRefresh(); + } + setStatus({ loading: false, error: "" }); + } catch (error) { + setStatus({ loading: false, error: error.message }); + } + }, [domain.id, onRefresh, token]); + + useEffect(() => { + if (domain.status === "active") { + setChecks([ + { purpose: "mx", verified: true }, + { purpose: "spf", verified: true }, + { purpose: "dkim", verified: true }, + { purpose: "dmarc", verified: true }, + ]); + return; + } + runVerify(); + }, [domain.id, domain.status, runVerify]); + + const checkMap = Object.fromEntries((checks || []).map((c) => [c.purpose, c])); + const mailRecords = domain.dns_records?.filter((r) => r.purpose !== "ownership") || []; + + return ( +
+ + +
+
+

{domain.domain_name}

+ {isPrimary && Primary} +
+ +
+ + + + {tab === "Overview" && ( +
+

Domain overview

+

+ Mail for this domain is hosted at {domain.mail_host}. + {domain.status === "active" + ? " DNS is fully configured and email can flow." + : " Finish DNS setup to start sending and receiving mail."} +

+
+
Status
{domain.status === "active" ? "Active" : "Pending DNS"}
+
DKIM selector
{domain.dkim_selector}
+
Added
{new Date(domain.created_at).toLocaleDateString()}
+
+
+ )} + + {tab === "DNS Configuration" && ( +
+

Configure DNS records

+

+ Add the following DNS records to your domain provider. These records enable receiving and sending email. +

+ +
    + {DNS_GROUPS.map((group) => { + const check = checkMap[group.purpose]; + const verified = check?.verified ?? domain.status === "active"; + return ( +
  • + + + +
    + {group.title} + {group.description} +
    + + {verified ? : } + {verified ? "Verified" : "Not configured"} + +
  • + ); + })} +
+ +
+ View all DNS record values + +
+ +
+ + {lastChecked ? `Last checked ${formatRelativeTime(lastChecked)}` : "Not checked yet"} + + +
+ {status.error &&

{status.error}

} +
+ )} + + {tab === "Security" && ( +
+

Security

+

+ DKIM signing and DMARC policy are enforced through your DNS records. + Advanced security controls for this domain will appear here. +

+
+ + Domain security settings coming soon. +
+
+ )} + + {tab === "Email Routing" && ( +
+

Email routing

+

+ Configure how incoming mail is routed for addresses on {domain.domain_name}. +

+
+ + Routing rules and catch-all settings coming soon. +
+
+ )} +
+ ); +} + +export default function CompanyDomains({ account, primaryDomainName }) { + const token = sessionStorage.getItem("agmail_access_token"); + const [domains, setDomains] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [view, setView] = useState("list"); + const [selectedId, setSelectedId] = useState(null); + const [selectedDomain, setSelectedDomain] = useState(null); + + const loadDomains = useCallback(async () => { + setLoading(true); + setError(""); + try { + const items = await listMyDomains(token); + setDomains(items); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }, [token]); + + const loadDomain = useCallback(async (domainId) => { + try { + const item = await getMyDomain(domainId, token); + setSelectedDomain(item); + return item; + } catch (err) { + setError(err.message); + return null; + } + }, [token]); + + useEffect(() => { + loadDomains(); + }, [loadDomains]); + + useEffect(() => { + if (selectedId && view === "detail") { + loadDomain(selectedId); + } + }, [selectedId, view, loadDomain]); + + function openDomain(domain) { + setSelectedId(domain.id); + setSelectedDomain(domain); + setView("detail"); + } + + function handleAddComplete(domain) { + loadDomains(); + openDomain(domain); + } + + if (view === "add") { + return ( + setView("list")} + onComplete={handleAddComplete} + /> + ); + } + + if (view === "detail" && selectedDomain) { + return ( + { + setView("list"); + setSelectedId(null); + setSelectedDomain(null); + loadDomains(); + }} + onRefresh={() => loadDomain(selectedId)} + /> + ); + } + + return ( +
+
+
+

Domains

+

Manage company domains, DNS, security, and email routing.

+
+ +
+ + {error &&

{error}

} + + {loading ? ( +

Loading domains…

+ ) : domains.length === 0 ? ( +
+ +

No domains yet

+

Add your first domain to create professional email addresses.

+ +
+ ) : ( +
    + {domains.map((domain) => ( +
  • + +
  • + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/DashboardPage.jsx b/frontend/src/components/DashboardPage.jsx index baebf1b..844c522 100644 --- a/frontend/src/components/DashboardPage.jsx +++ b/frontend/src/components/DashboardPage.jsx @@ -28,6 +28,7 @@ import { useCallback, useEffect, useState } from "react"; import { getCurrentAccount, getMyCompanyDomain } from "../api"; import Brand from "./Brand"; import CompanyDnsSetup from "./CompanyDnsSetup"; +import CompanyDomains from "./CompanyDomains"; import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup"; import ComposeModal from "./ComposeModal"; import MailboxView from "./MailboxView"; @@ -361,7 +362,7 @@ export default function DashboardPage() {
- {companyActive && companyActive !== "Overview" && ( + {companyActive && !["Overview", "Domains"].includes(companyActive) && (

{workspaceTitle}

{companyActive}

{workspaceLabel} @@ -416,15 +417,17 @@ export default function DashboardPage() { }} /> )} - {companyActive && companyActive !== "Overview" && ( + {companyActive === "Domains" && isDomainOwner && ( + + )} + {companyActive && !["Overview", "Domains"].includes(companyActive) && (

{companyActive}

-

- {companyActive === "Domains" && companyDomain - ? `Manage DNS and delivery for ${companyDomain.domain_name}.` - : `Company ${companyActive.toLowerCase()} controls will appear here.`} -

+

Company {companyActive.toLowerCase()} controls will appear here.

)} {!companyActive && !["Inbox", "Outbox", "Trash"].includes(active) && ( diff --git a/frontend/src/styles.css b/frontend/src/styles.css index d6d1251..f4bf9f3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -211,6 +211,77 @@ h1 em { color:var(--coral); font-style:normal; font-weight:500; } @media(max-width:900px) { .org-setup-layout { grid-template-columns:1fr; } } + +.company-domains,.company-domains-add,.company-domain-detail { padding:8px 0 24px; } +.company-domains-head { margin-bottom:24px; display:flex; align-items:flex-start; justify-content:space-between; gap:16px; } +.company-domains-head h1,.company-domains-add h1,.company-domain-detail-head h1 { margin:0 0 8px; font:700 clamp(28px,3vw,36px)/1.15 "DM Sans"; letter-spacing:-.6px; color:#1a1628; } +.company-domains-head p,.company-domains-add>p { margin:0; color:#77758A; font-size:14px; } +.company-domains-add-btn { flex-shrink:0; } +.company-domain-list { margin:0; padding:0; list-style:none; display:grid; gap:8px; } +.company-domain-list-row { width:100%; padding:14px 16px; display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:14px; border:1px solid #ECE9F2; border-radius:14px; background:#fff; text-align:left; cursor:pointer; transition:border-color .15s,box-shadow .15s; } +.company-domain-list-row:hover { border-color:#D8D2E8; box-shadow:0 8px 24px rgba(20,18,30,0.05); } +.company-domain-list-icon { width:40px; height:40px; display:grid; place-items:center; border-radius:50%; background:#F6F2FF; color:#7357F6; } +.company-domain-list-copy { display:grid; gap:2px; min-width:0; } +.company-domain-list-copy strong { display:flex; align-items:center; gap:8px; font-size:14px; color:#1a1628; } +.company-domain-list-copy small { color:#8d8792; font-size:12px; } +.company-domain-status { display:inline-flex; align-items:center; gap:5px; padding:4px 10px; border-radius:999px; font-size:11px; font-weight:600; white-space:nowrap; } +.company-domain-status.active { background:#EEF8F1; color:#2f6b43; } +.company-domain-status:not(.active) { background:#FFF0EB; color:#b04a38; } +.company-domain-primary { padding:2px 8px; border-radius:999px; background:#EEF8F1; color:#2f6b43; font-size:10px; font-weight:700; font-style:normal; text-transform:uppercase; letter-spacing:.4px; } +.company-domain-empty { padding:48px 24px; display:grid; justify-items:center; gap:10px; border:1px dashed #E4E1E8; border-radius:16px; background:#FAFAFC; text-align:center; color:#8d8792; } +.company-domain-empty h2 { margin:0; color:#3d3948; font-size:18px; } +.company-domain-empty p { margin:0 0 8px; font-size:13px; } +.company-domain-back { margin-bottom:16px; padding:0; display:inline-flex; align-items:center; gap:6px; border:0; background:transparent; color:#6f6c7b; font-size:13px; font-weight:600; cursor:pointer; } +.company-domain-back:hover { color:#7357F6; } +.company-domain-stepper { margin:0 0 24px; padding:0; list-style:none; display:flex; flex-wrap:wrap; gap:8px 20px; } +.company-domain-step { display:flex; align-items:center; gap:8px; color:#8d8792; font-size:12px; font-weight:600; } +.company-domain-step-mark { width:28px; height:28px; display:grid; place-items:center; border-radius:50%; border:1px solid #E4E1E8; background:#fff; font-size:12px; } +.company-domain-step.complete .company-domain-step-mark,.company-domain-step.active .company-domain-step-mark { border-color:#7357F6; background:#7357F6; color:#fff; } +.company-domain-step.active { color:#5a4fcf; } +.company-domain-card { margin-bottom:16px; padding:24px 28px; border:1px solid #ECE9F2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); } +.company-domain-card h2 { margin:0 0 12px; font:700 20px/1.2 "DM Sans"; color:#1a1628; } +.company-domain-card label { display:grid; gap:6px; font-size:12px; font-weight:600; color:#4a4652; } +.company-domain-card input { height:42px; padding:0 12px; border:1px solid #E1DFE7; border-radius:10px; background:#fff; font-size:13px; color:#1a1628; } +.company-domain-hint,.company-domain-lead { margin:8px 0 0; color:#6f6c7b; font-size:13px; line-height:1.5; } +.company-domain-card-actions { margin-top:18px; display:flex; justify-content:flex-end; } +.company-domain-verify-grid { display:grid; grid-template-columns:100px 1fr; gap:12px; } +.company-domain-verify-value { grid-column:1 / -1; } +.company-domain-verify-grid .dns-value-cell { display:flex; gap:8px; align-items:center; } +.company-domain-verify-grid .dns-value-cell input { flex:1; } +.company-domain-help { margin:14px 0 0; display:flex; align-items:center; gap:6px; color:#8d8792; font-size:12px; } +.company-domain-complete { display:grid; justify-items:center; gap:10px; text-align:center; } +.company-domain-complete svg { color:#2f6b43; } +.company-domain-detail-head { margin-bottom:18px; display:flex; align-items:center; justify-content:space-between; gap:16px; } +.company-domain-detail-head>div { display:flex; align-items:center; gap:10px; flex-wrap:wrap; } +.company-domain-tabs { margin-bottom:20px; display:flex; gap:20px; border-bottom:1px solid #ECE9F2; } +.company-domain-tabs button { padding:10px 2px 12px; border:0; border-bottom:2px solid transparent; background:transparent; color:#8d8792; font-size:13px; font-weight:600; cursor:pointer; } +.company-domain-tabs button.active { color:#e15b45; border-bottom-color:#e15b45; } +.company-domain-panel { padding:24px 28px; border:1px solid #ECE9F2; border-radius:16px; background:#fff; box-shadow:0 8px 28px rgba(20,18,30,0.04); } +.company-domain-panel h2 { margin:0 0 10px; font:700 20px/1.2 "DM Sans"; color:#1a1628; } +.company-domain-meta { margin:20px 0 0; display:grid; gap:10px; } +.company-domain-meta div { display:grid; grid-template-columns:140px 1fr; gap:12px; font-size:13px; } +.company-domain-meta dt { color:#8d8792; font-weight:600; } +.company-domain-meta dd { margin:0; color:#3d3948; } +.company-domain-dns-list { margin:18px 0 0; padding:0; list-style:none; display:grid; gap:6px; } +.company-domain-dns-row { padding:14px 16px; display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:14px; border:1px solid #F0EEF5; border-radius:12px; background:#FDFCFD; } +.company-domain-dns-icon { width:36px; height:36px; display:grid; place-items:center; border-radius:50%; } +.company-domain-dns-icon.tone-violet { background:#F0EBFF; color:#7357F6; } +.company-domain-dns-icon.tone-amber { background:#FFF4E5; color:#c4842d; } +.company-domain-dns-icon.tone-teal { background:#E8F7F3; color:#2d8a72; } +.company-domain-dns-icon.tone-coral { background:#FFF0EB; color:#e15b45; } +.company-domain-dns-copy { display:grid; gap:2px; min-width:0; } +.company-domain-dns-copy strong { font-size:13px; color:#1a1628; } +.company-domain-dns-copy span { font-size:12px; color:#8d8792; } +.company-domain-dns-badge { display:inline-flex; align-items:center; gap:5px; padding:4px 10px; border-radius:999px; font-size:11px; font-weight:600; white-space:nowrap; } +.company-domain-dns-badge.ok { background:#EEF8F1; color:#2f6b43; } +.company-domain-dns-badge.fail { background:#FFF0EB; color:#b04a38; } +.company-domain-dns-chevron { color:#c5c0ca; } +.company-domain-dns-records { margin-top:18px; } +.company-domain-dns-records summary { cursor:pointer; color:#7357F6; font-size:13px; font-weight:600; } +.company-domain-dns-footer { margin-top:18px; display:flex; align-items:center; justify-content:space-between; gap:12px; color:#8d8792; font-size:12px; } +.company-domain-recheck { padding:0; display:inline-flex; align-items:center; gap:6px; border:0; background:transparent; color:#7357F6; font-size:12px; font-weight:600; cursor:pointer; } +.company-domain-placeholder { margin-top:16px; padding:32px; display:grid; justify-items:center; gap:8px; border:1px dashed #E4E1E8; border-radius:12px; background:#FAFAFC; color:#8d8792; font-size:13px; text-align:center; } + .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; }