company flows
This commit is contained in:
parent
c04a6ce6fe
commit
a3a6464a99
15 changed files with 963 additions and 60 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
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,
|
||||
)
|
||||
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,
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
661
frontend/src/components/CompanyDomains.jsx
Normal file
661
frontend/src/components/CompanyDomains.jsx
Normal 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'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'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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) && (
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue