company flows

This commit is contained in:
debu-2 2026-10-04 15:35:17 +05:30
parent c04a6ce6fe
commit a3a6464a99
15 changed files with 963 additions and 60 deletions

View file

@ -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=<private bucket>
GCS_PROJECT=<gcp project id>
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 (`<VPS_IP>` = 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` | `<VPS_IP>` | Web UI |
| **A** | `@` | `<VPS_IP>` | Agmail web UI |
| **A** | `mail` | `<VPS_IP>` | 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`

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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** | `@` | `<VPS_PUBLIC_IP>` | Agmail web UI (primary) |
| **A** | `mail` | `<VPS_PUBLIC_IP>` | Mail server |
| **A** | `email` | `<VPS_PUBLIC_IP>` | 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` → `<VPS_PUBLIC_IP>` 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`.

View file

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

View file

@ -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 (
<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={() => copyText(displayValue)}
>
<Copy size={14} />
</button>
</div>
</td>
<td>{record.ttl}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function OwnershipRecord({ record }) {
if (!record) return null;
return (
<div className="company-domain-verify-grid">
<label>
Type
<input readOnly value={record.record_type} />
</label>
<label>
Host
<input readOnly value={record.host} />
</label>
<label className="company-domain-verify-value">
Value
<div className="dns-value-cell">
<input readOnly value={record.value} />
<button
type="button"
className="dns-copy"
aria-label="Copy value"
onClick={() => copyText(record.value)}
>
<Copy size={14} />
Copy
</button>
</div>
</label>
</div>
);
}
function DomainStatusBadge({ status }) {
const active = status === "active";
return (
<span className={`company-domain-status${active ? " active" : ""}`}>
{active ? <CheckCircle2 size={13} /> : <Lock size={13} />}
{active ? "Active" : "Pending DNS"}
</span>
);
}
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 (
<section className="company-domains-add">
<button type="button" className="company-domain-back" onClick={onCancel}>
<ArrowLeft size={16} /> Back to domains
</button>
<header className="company-domains-head">
<h1>Add and verify your domain</h1>
<p>Use your company domain for professional email addresses.</p>
</header>
<ol className="company-domain-stepper" aria-label="Domain setup progress">
{ADD_STEPS.map((item) => {
const complete = item.id < step || (domain?.status === "active" && item.id <= 4);
const active = item.id === step;
return (
<li
key={item.id}
className={`company-domain-step${complete ? " complete" : ""}${active ? " active" : ""}`}
>
<span className="company-domain-step-mark" aria-hidden="true">
{complete ? <Check size={12} strokeWidth={2.5} /> : item.id}
</span>
<span>{item.title}</span>
</li>
);
})}
</ol>
{step === 1 && (
<div className="company-domain-card">
<h2>Enter your domain</h2>
<form onSubmit={handleAddDomain}>
<label>
Domain name
<input
required
placeholder="acme.com"
value={domainName}
onChange={(event) => setDomainName(event.target.value)}
/>
</label>
<p className="company-domain-hint">
You&apos;ll be able to create email addresses like you@{domainName || "acme.com"}
</p>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
<div className="company-domain-card-actions">
<button
className="button button-purple"
type="submit"
disabled={status.loading}
>
{status.loading ? "Adding…" : "Continue"} <ArrowRight size={16} />
</button>
</div>
</form>
</div>
)}
{step >= 2 && domain && (
<div className="company-domain-card">
<h2>Verify domain ownership</h2>
<p className="company-domain-lead">
Add the following TXT record to your DNS provider for <strong>{domain.domain_name}</strong>.
</p>
<OwnershipRecord record={ownershipRecord} />
<p className="company-domain-help">
<Info size={14} />
Need help? View your registrar&apos;s DNS documentation.
</p>
{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 && step < 4 && (
<p className="form-error" role="alert">{status.error}</p>
)}
{step < 4 && (
<div className="company-domain-card-actions">
<button
className="button button-purple"
type="button"
disabled={status.loading}
onClick={handleVerify}
>
{status.loading ? "Checking…" : step === 2 ? "Verify domain" : "Verify DNS"}
</button>
</div>
)}
</div>
)}
{step >= 3 && domain && step < 4 && (
<div className="company-domain-card">
<h2>Configure DNS records</h2>
<p className="company-domain-lead">
Add these records to enable receiving and sending email for {domain.domain_name}.
</p>
<DnsRecordTable records={mailRecords} />
</div>
)}
{step === 4 && domain && (
<div className="company-domain-card company-domain-complete">
<CheckCircle2 size={40} />
<h2>{domain.domain_name} is ready</h2>
<p>DNS is verified. You can manage routing and security from the domain detail page.</p>
<button className="button button-purple" type="button" onClick={() => onComplete(domain)}>
View domain
</button>
</div>
)}
</section>
);
}
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 (
<section className="company-domain-detail">
<button type="button" className="company-domain-back" onClick={onBack}>
<ArrowLeft size={16} />
</button>
<header className="company-domain-detail-head">
<div>
<h1>{domain.domain_name}</h1>
{isPrimary && <span className="company-domain-primary">Primary</span>}
</div>
<DomainStatusBadge status={domain.status} />
</header>
<nav className="company-domain-tabs" aria-label="Domain sections">
{DETAIL_TABS.map((label) => (
<button
key={label}
type="button"
className={tab === label ? "active" : ""}
onClick={() => setTab(label)}
>
{label}
</button>
))}
</nav>
{tab === "Overview" && (
<div className="company-domain-panel">
<h2>Domain overview</h2>
<p className="company-domain-lead">
Mail for this domain is hosted at <strong>{domain.mail_host}</strong>.
{domain.status === "active"
? " DNS is fully configured and email can flow."
: " Finish DNS setup to start sending and receiving mail."}
</p>
<dl className="company-domain-meta">
<div><dt>Status</dt><dd>{domain.status === "active" ? "Active" : "Pending DNS"}</dd></div>
<div><dt>DKIM selector</dt><dd><code>{domain.dkim_selector}</code></dd></div>
<div><dt>Added</dt><dd>{new Date(domain.created_at).toLocaleDateString()}</dd></div>
</dl>
</div>
)}
{tab === "DNS Configuration" && (
<div className="company-domain-panel">
<h2>Configure DNS records</h2>
<p className="company-domain-lead">
Add the following DNS records to your domain provider. These records enable receiving and sending email.
</p>
<ul className="company-domain-dns-list">
{DNS_GROUPS.map((group) => {
const check = checkMap[group.purpose];
const verified = check?.verified ?? domain.status === "active";
return (
<li key={group.purpose} className="company-domain-dns-row">
<span className={`company-domain-dns-icon tone-${group.tone}`}>
<Globe size={16} />
</span>
<div className="company-domain-dns-copy">
<strong>{group.title}</strong>
<span>{group.description}</span>
</div>
<span className={`company-domain-dns-badge${verified ? " ok" : " fail"}`}>
{verified ? <CheckCircle2 size={13} /> : <Lock size={13} />}
{verified ? "Verified" : "Not configured"}
</span>
<ChevronRight size={16} className="company-domain-dns-chevron" aria-hidden="true" />
</li>
);
})}
</ul>
<details className="company-domain-dns-records">
<summary>View all DNS record values</summary>
<DnsRecordTable records={mailRecords} />
</details>
<footer className="company-domain-dns-footer">
<span>
{lastChecked ? `Last checked ${formatRelativeTime(lastChecked)}` : "Not checked yet"}
</span>
<button
type="button"
className="company-domain-recheck"
disabled={status.loading}
onClick={runVerify}
>
<RefreshCw size={14} />
{status.loading ? "Checking…" : "Recheck DNS"}
</button>
</footer>
{status.error && <p className="form-error" role="alert">{status.error}</p>}
</div>
)}
{tab === "Security" && (
<div className="company-domain-panel">
<h2>Security</h2>
<p className="company-domain-lead">
DKIM signing and DMARC policy are enforced through your DNS records.
Advanced security controls for this domain will appear here.
</p>
<div className="company-domain-placeholder">
<Shield size={20} />
<span>Domain security settings coming soon.</span>
</div>
</div>
)}
{tab === "Email Routing" && (
<div className="company-domain-panel">
<h2>Email routing</h2>
<p className="company-domain-lead">
Configure how incoming mail is routed for addresses on {domain.domain_name}.
</p>
<div className="company-domain-placeholder">
<Globe size={20} />
<span>Routing rules and catch-all settings coming soon.</span>
</div>
</div>
)}
</section>
);
}
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 (
<AddDomainWizard
onCancel={() => setView("list")}
onComplete={handleAddComplete}
/>
);
}
if (view === "detail" && selectedDomain) {
return (
<DomainDetail
domain={selectedDomain}
primaryDomainName={primaryDomainName}
onBack={() => {
setView("list");
setSelectedId(null);
setSelectedDomain(null);
loadDomains();
}}
onRefresh={() => loadDomain(selectedId)}
/>
);
}
return (
<section className="company-domains">
<header className="company-domains-head">
<div>
<h1>Domains</h1>
<p>Manage company domains, DNS, security, and email routing.</p>
</div>
<button
type="button"
className="button button-purple company-domains-add-btn"
onClick={() => setView("add")}
>
<Plus size={16} /> Add domain
</button>
</header>
{error && <p className="form-error" role="alert">{error}</p>}
{loading ? (
<p className="company-domain-hint">Loading domains…</p>
) : domains.length === 0 ? (
<div className="company-domain-empty">
<Globe size={28} />
<h2>No domains yet</h2>
<p>Add your first domain to create professional email addresses.</p>
<button
type="button"
className="button button-purple"
onClick={() => setView("add")}
>
<Plus size={16} /> Add domain
</button>
</div>
) : (
<ul className="company-domain-list">
{domains.map((domain) => (
<li key={domain.id}>
<button
type="button"
className="company-domain-list-row"
onClick={() => openDomain(domain)}
>
<span className="company-domain-list-icon">
<Globe size={18} />
</span>
<span className="company-domain-list-copy">
<strong>
{domain.domain_name}
{domain.domain_name === primaryDomainName && (
<em className="company-domain-primary">Primary</em>
)}
</strong>
<small>Mail host: {domain.mail_host}</small>
</span>
<DomainStatusBadge status={domain.status} />
<ChevronRight size={16} aria-hidden="true" />
</button>
</li>
))}
</ul>
)}
</section>
);
}

View file

@ -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() {
</aside>
<main className="dashboard-content">
{companyActive && companyActive !== "Overview" && (
{companyActive && !["Overview", "Domains"].includes(companyActive) && (
<div className="dashboard-content-heading">
<div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div>
<span className="dashboard-account-pill">{workspaceLabel}</span>
@ -416,15 +417,17 @@ export default function DashboardPage() {
}}
/>
)}
{companyActive && companyActive !== "Overview" && (
{companyActive === "Domains" && isDomainOwner && (
<CompanyDomains
account={account}
primaryDomainName={account.company_domain}
/>
)}
{companyActive && !["Overview", "Domains"].includes(companyActive) && (
<section className="dashboard-empty dashboard-company-view">
<span><Sparkles size={24} /></span>
<h2>{companyActive}</h2>
<p>
{companyActive === "Domains" && companyDomain
? `Manage DNS and delivery for ${companyDomain.domain_name}.`
: `Company ${companyActive.toLowerCase()} controls will appear here.`}
</p>
<p>Company {companyActive.toLowerCase()} controls will appear here.</p>
</section>
)}
{!companyActive && !["Inbox", "Outbox", "Trash"].includes(active) && (

View file

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