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
|
Agmail runs as one Docker Compose stack on a **mail VPS** (port 25 open): Caddy
|
||||||
(HTTPS), frontend, backend, PostgreSQL, MongoDB, and Postfix (inbound + outbound).
|
(HTTPS), frontend, backend, PostgreSQL, MongoDB, and Postfix (inbound + outbound).
|
||||||
|
|
||||||
The **main website** at `gentic.email` can stay on another host (e.g. GCP).
|
The Agmail stack lives on the **mail VPS**:
|
||||||
Only the email stack lives on the mail VPS:
|
|
||||||
|
|
||||||
| Hostname | Points to | Purpose |
|
| Hostname | Points to | Purpose |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| `gentic.email` | GCP / main site IP | Marketing / main app (unchanged) |
|
| `gentic.email` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend, primary) |
|
||||||
| `email.gentic.email` | **Mail VPS public IP** | Agmail web UI (Caddy → frontend) |
|
| `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) |
|
| `mail.gentic.email` | **Mail VPS public IP** | Postfix :25 (inbound + outbound) |
|
||||||
| `@gentic.email` MX | `mail.gentic.email` | Delivers mail to the VPS |
|
| `@gentic.email` MX | `mail.gentic.email` | Delivers mail to the VPS |
|
||||||
|
|
||||||
|
|
@ -18,7 +17,7 @@ User addresses remain **`user@gentic.email`**.
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Browser ──HTTPS──► email.gentic.email (Caddy :443)
|
Browser ──HTTPS──► gentic.email / email.getall.help (Caddy :443)
|
||||||
└──► frontend ──► backend ──SMTP──► smtp:25 (Postfix outbound)
|
└──► frontend ──► backend ──SMTP──► smtp:25 (Postfix outbound)
|
||||||
|
|
||||||
Internet ──SMTP:25──► mail.gentic.email (Postfix)
|
Internet ──SMTP:25──► mail.gentic.email (Postfix)
|
||||||
|
|
@ -34,7 +33,7 @@ cp .env.example .env
|
||||||
docker compose up -d --build
|
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 |
|
| Service | Host port | Purpose |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
|
|
@ -61,7 +60,7 @@ GCS_RAW_MIME_BUCKET=<private bucket>
|
||||||
GCS_PROJECT=<gcp project id>
|
GCS_PROJECT=<gcp project id>
|
||||||
GCP_CREDENTIALS_FILE=./midyear-karma-470610-i1-da5334d20acc.json
|
GCP_CREDENTIALS_FILE=./midyear-karma-470610-i1-da5334d20acc.json
|
||||||
CADDY_ACME_EMAIL=admin@gentic.email
|
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 `#`.
|
Quote values that contain `#`.
|
||||||
|
|
@ -78,13 +77,14 @@ Outbound uses the Docker Postfix service by default (`SMTP_HOST=smtp`,
|
||||||
PLATFORM_DOMAIN=gentic.email
|
PLATFORM_DOMAIN=gentic.email
|
||||||
|
|
||||||
# Web UI on this host (Caddy terminates TLS here)
|
# 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
|
CADDY_ACME_EMAIL=admin@gentic.email
|
||||||
|
|
||||||
# Postfix hostname (inbound + outbound)
|
# Postfix hostname (inbound + outbound)
|
||||||
MAIL_HOSTNAME=mail.gentic.email
|
MAIL_HOSTNAME=mail.gentic.email
|
||||||
MAIL_DOMAIN=gentic.email
|
MAIL_DOMAIN=gentic.email
|
||||||
INBOUND_DOMAINS=gentic.email
|
INBOUND_DOMAINS=gentic.email,getall.help
|
||||||
SMTP_PUBLIC_HOST=mail.gentic.email
|
SMTP_PUBLIC_HOST=mail.gentic.email
|
||||||
SMTP_HOST=smtp
|
SMTP_HOST=smtp
|
||||||
SMTP_PORT=25
|
SMTP_PORT=25
|
||||||
|
|
@ -100,14 +100,13 @@ INBOUND_SMTP_BIND_IP=127.0.0.1
|
||||||
## Namecheap DNS changes
|
## Namecheap DNS changes
|
||||||
|
|
||||||
Update DNS as follows (`<VPS_IP>` = public IP of the mail server). Generate the
|
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
|
### Inbound
|
||||||
|
|
||||||
| Type | Host | Value | Notes |
|
| Type | Host | Value | Notes |
|
||||||
|------|------|-------|-------|
|
|------|------|-------|-------|
|
||||||
| **A** | `@` | main site IP | **Keep** — marketing site |
|
| **A** | `@` | `<VPS_IP>` | Agmail web UI |
|
||||||
| **A** | `email` | `<VPS_IP>` | Web UI |
|
|
||||||
| **A** | `mail` | `<VPS_IP>` | Postfix |
|
| **A** | `mail` | `<VPS_IP>` | Postfix |
|
||||||
| **MX** | `@` | `10 mail.gentic.email` | Priority 10 |
|
| **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:
|
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
|
2. Ports 80 and 443 are open
|
||||||
3. `CADDY_ACME_EMAIL` is set in `.env`
|
3. `CADDY_ACME_EMAIL` is set in `.env`
|
||||||
|
|
||||||
|
|
@ -173,7 +172,8 @@ Config: `deploy/caddy/Caddyfile` → reverse-proxies to `frontend:80`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# HTTPS UI
|
# 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)
|
# Inbound mail (account must exist)
|
||||||
swaks \
|
swaks \
|
||||||
|
|
@ -211,7 +211,8 @@ cd /opt/apps/agentic_email
|
||||||
# 1. Confirm .env has a real contact email (use your Gmail if unsure)
|
# 1. Confirm .env has a real contact email (use your Gmail if unsure)
|
||||||
grep CADDY_ACME_EMAIL .env
|
grep CADDY_ACME_EMAIL .env
|
||||||
grep APP_PUBLIC_HOST .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
|
# CADDY_ACME_EMAIL=you@gmail.com
|
||||||
|
|
||||||
# 2. Check Caddy logs for ACME errors
|
# 2. Check Caddy logs for ACME errors
|
||||||
|
|
@ -227,12 +228,12 @@ docker compose logs -f caddy
|
||||||
Wait 30–60 seconds, then test:
|
Wait 30–60 seconds, then test:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSI https://email.gentic.email/
|
curl -fsSI https://gentic.email/
|
||||||
```
|
```
|
||||||
|
|
||||||
Requirements for Let's Encrypt:
|
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`
|
- Firewall allows **inbound TCP 80 and 443** from `0.0.0.0/0`
|
||||||
- `CADDY_ACME_EMAIL` set in `.env`
|
- `CADDY_ACME_EMAIL` set in `.env`
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
platform_domain: str = "gentic.email"
|
platform_domain: str = "gentic.email"
|
||||||
platform_spf_include: str = "_spf.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"
|
dkim_selector: str = "agmail"
|
||||||
|
|
||||||
# Outbound via self-hosted Postfix (Docker service `smtp` by default)
|
# 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 (
|
from app.schemas import (
|
||||||
DnsVerificationResponse,
|
DnsVerificationResponse,
|
||||||
DomainCreate,
|
DomainCreate,
|
||||||
|
DomainNameCreate,
|
||||||
MailDomainRead,
|
MailDomainRead,
|
||||||
SmtpCredentialCreate,
|
SmtpCredentialCreate,
|
||||||
SmtpCredentialCreated,
|
SmtpCredentialCreated,
|
||||||
|
|
@ -20,11 +21,14 @@ from app.schemas import (
|
||||||
from app.services.dns_verification import verify_company_dns
|
from app.services.dns_verification import verify_company_dns
|
||||||
from app.services.domain_policy import assert_company_domain_allowed
|
from app.services.domain_policy import assert_company_domain_allowed
|
||||||
from app.services.domain_provisioning import (
|
from app.services.domain_provisioning import (
|
||||||
|
account_is_domain_owner,
|
||||||
activate_company_domain,
|
activate_company_domain,
|
||||||
create_smtp_credential,
|
create_smtp_credential,
|
||||||
ensure_ownership_dns_record,
|
ensure_ownership_dns_record,
|
||||||
ensure_platform_domain,
|
ensure_platform_domain,
|
||||||
get_account_company_domain,
|
get_account_company_domain,
|
||||||
|
get_owned_domain,
|
||||||
|
list_owned_domains,
|
||||||
provision_mail_domain,
|
provision_mail_domain,
|
||||||
validate_domain,
|
validate_domain,
|
||||||
)
|
)
|
||||||
|
|
@ -32,6 +36,56 @@ from app.services.domain_provisioning import (
|
||||||
router = APIRouter(prefix="/domains", tags=["domains"])
|
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(
|
async def get_domain_or_404(
|
||||||
domain_id: UUID,
|
domain_id: UUID,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|
@ -104,25 +158,86 @@ async def verify_my_company_dns(
|
||||||
checks=[],
|
checks=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
verification_token = await ensure_ownership_dns_record(session, domain)
|
return await verify_owned_domain_dns(session, domain)
|
||||||
checks = verify_company_dns(
|
|
||||||
domain_name=domain.domain_name,
|
|
||||||
shared_mail_host=settings.smtp_public_host,
|
@router.get("/me/domains", response_model=list[MailDomainRead])
|
||||||
dkim_selector=domain.dkim_selector,
|
async def list_my_domains(
|
||||||
dkim_public_key=domain.dkim_public_key,
|
account: Account = Depends(get_current_account),
|
||||||
platform_spf_include=settings.platform_spf_include,
|
session: AsyncSession = Depends(get_postgres_session),
|
||||||
verification_token=verification_token,
|
) -> 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()
|
await session.commit()
|
||||||
return DnsVerificationResponse(
|
return await get_owned_domain(session, domain.id, account.id)
|
||||||
domain=domain.domain_name,
|
|
||||||
status=domain.status if not verified else "active",
|
|
||||||
verified=verified,
|
@router.get("/me/domains/{domain_id}", response_model=MailDomainRead)
|
||||||
checks=checks,
|
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)
|
@router.get("/platform", response_model=MailDomainRead)
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,10 @@ class DomainCreate(BaseModel):
|
||||||
account_id: UUID | None = None
|
account_id: UUID | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DomainNameCreate(BaseModel):
|
||||||
|
domain_name: str = Field(min_length=3, max_length=253)
|
||||||
|
|
||||||
|
|
||||||
class DnsRecordRead(BaseModel):
|
class DnsRecordRead(BaseModel):
|
||||||
id: UUID
|
id: UUID
|
||||||
record_type: str
|
record_type: str
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,37 @@ async def account_is_domain_owner(
|
||||||
return domain_id is not None
|
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(
|
async def get_account_company_domain(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
account_id,
|
account_id,
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ def test_deployment_sections_split_inbound_and_outbound():
|
||||||
mail_server_ip="203.0.113.10",
|
mail_server_ip="203.0.113.10",
|
||||||
dkim_selector="agmail",
|
dkim_selector="agmail",
|
||||||
dkim_public_key="test-public-key",
|
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"]]
|
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}
|
JWT_SECRET: ${JWT_SECRET:?Set JWT_SECRET to a long random value}
|
||||||
PLATFORM_DOMAIN: ${PLATFORM_DOMAIN:-gentic.email}
|
PLATFORM_DOMAIN: ${PLATFORM_DOMAIN:-gentic.email}
|
||||||
PLATFORM_SPF_INCLUDE: ${PLATFORM_SPF_INCLUDE:-_spf.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}
|
DKIM_SELECTOR: ${DKIM_SELECTOR:-agmail}
|
||||||
SMTP_HOST: ${SMTP_HOST:-smtp}
|
SMTP_HOST: ${SMTP_HOST:-smtp}
|
||||||
SMTP_PORT: ${SMTP_PORT:-25}
|
SMTP_PORT: ${SMTP_PORT:-25}
|
||||||
|
|
@ -81,7 +81,7 @@ services:
|
||||||
GCS_RAW_MIME_PREFIX: ${GCS_RAW_MIME_PREFIX:-raw}
|
GCS_RAW_MIME_PREFIX: ${GCS_RAW_MIME_PREFIX:-raw}
|
||||||
GCS_PROJECT: ${GCS_PROJECT:-}
|
GCS_PROJECT: ${GCS_PROJECT:-}
|
||||||
GOOGLE_APPLICATION_CREDENTIALS: /run/secrets/gcp_service_account
|
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_USERNAME: ${DNS_SETUP_USERNAME:-smtp-credentia}
|
||||||
DNS_SETUP_PASSWORD: ${DNS_SETUP_PASSWORD:-Nishtha#01}
|
DNS_SETUP_PASSWORD: ${DNS_SETUP_PASSWORD:-Nishtha#01}
|
||||||
ports:
|
ports:
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
# Public HTTPS entrypoint for the Agmail web UI.
|
# 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}
|
email {$CADDY_ACME_EMAIL}
|
||||||
}
|
}
|
||||||
|
|
||||||
{$APP_PUBLIC_HOST} {
|
{$APP_PUBLIC_HOST}, {$CADDY_ALT_HOST} {
|
||||||
tls {$CADDY_ACME_EMAIL}
|
tls {$CADDY_ACME_EMAIL}
|
||||||
encode gzip zstd
|
encode gzip zstd
|
||||||
reverse_proxy frontend:80
|
reverse_proxy frontend:80
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ services:
|
||||||
- "${CADDY_HTTPS_PORT:-443}:443"
|
- "${CADDY_HTTPS_PORT:-443}:443"
|
||||||
- "${CADDY_HTTPS_PORT:-443}:443/udp"
|
- "${CADDY_HTTPS_PORT:-443}:443/udp"
|
||||||
environment:
|
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}
|
CADDY_ACME_EMAIL: ${CADDY_ACME_EMAIL:?Set CADDY_ACME_EMAIL for Let's Encrypt}
|
||||||
env_file:
|
env_file:
|
||||||
- ../../.env
|
- ../../.env
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ services:
|
||||||
environment:
|
environment:
|
||||||
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.gentic.email}
|
MAIL_HOSTNAME: ${MAIL_HOSTNAME:-mail.gentic.email}
|
||||||
MAIL_DOMAIN: ${MAIL_DOMAIN:-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_FILE: /data/relay_domains/domains.list
|
||||||
RELAY_DOMAINS_REFRESH: ${RELAY_DOMAINS_REFRESH:-60}
|
RELAY_DOMAINS_REFRESH: ${RELAY_DOMAINS_REFRESH:-60}
|
||||||
BACKEND_INGEST_HOST: ${BACKEND_INGEST_HOST:-backend}
|
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 |
|
| Hostname | Purpose |
|
||||||
|----------|---------|
|
|----------|---------|
|
||||||
| `gentic.email` | Platform domain — individual users get `@gentic.email` addresses |
|
| `gentic.email` | Platform domain + primary Agmail web UI (Caddy HTTPS) |
|
||||||
| `email.gentic.email` | Web UI (Caddy HTTPS) |
|
| `email.getall.help` | Legacy alternate URL to the same web UI |
|
||||||
| `mail.gentic.email` | Postfix SMTP (inbound :25 + outbound) |
|
| `mail.gentic.email` | Postfix SMTP (inbound :25 + outbound) |
|
||||||
| `_spf.gentic.email` | Platform SPF include target (TXT on `gentic.email`, host `_spf`) |
|
| `_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
|
```env
|
||||||
PLATFORM_DOMAIN=gentic.email
|
PLATFORM_DOMAIN=gentic.email
|
||||||
PLATFORM_SPF_INCLUDE=_spf.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
|
CADDY_ACME_EMAIL=admin@gentic.email
|
||||||
SMTP_PUBLIC_HOST=mail.gentic.email
|
SMTP_PUBLIC_HOST=mail.gentic.email
|
||||||
MAIL_HOSTNAME=mail.gentic.email
|
MAIL_HOSTNAME=mail.gentic.email
|
||||||
MAIL_DOMAIN=gentic.email
|
MAIL_DOMAIN=gentic.email
|
||||||
INBOUND_DOMAINS=gentic.email
|
INBOUND_DOMAINS=gentic.email,getall.help
|
||||||
CORS_ORIGINS=["https://email.gentic.email"]
|
CORS_ORIGINS=["https://gentic.email","https://email.getall.help"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Redeploy app + SMTP + Caddy stacks after updating env.
|
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 |
|
| Type | Host | Value | Notes |
|
||||||
|------|------|-------|-------|
|
|------|------|-------|-------|
|
||||||
|
| **A** | `@` | `<VPS_PUBLIC_IP>` | Agmail web UI (primary) |
|
||||||
| **A** | `mail` | `<VPS_PUBLIC_IP>` | Mail server |
|
| **A** | `mail` | `<VPS_PUBLIC_IP>` | Mail server |
|
||||||
| **A** | `email` | `<VPS_PUBLIC_IP>` | Web UI |
|
|
||||||
| **MX** | `@` | `10 mail.gentic.email` | Platform inbox delivery |
|
| **MX** | `@` | `10 mail.gentic.email` | Platform inbox delivery |
|
||||||
|
|
||||||
### Outbound authentication (platform domain)
|
### 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** | `agmail._domainkey` | *(from /smtp-credentials page after domain register)* |
|
||||||
| **TXT** | `_dmarc` | `v=DMARC1; p=quarantine; rua=mailto:dmarc@gentic.email` |
|
| **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)
|
### 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).
|
1. Lower TTL on old `getall.help` records 24h before (optional).
|
||||||
2. Publish new `gentic.email` A/MX/TXT records.
|
2. Publish new `gentic.email` A/MX/TXT records.
|
||||||
3. Update `.env` and redeploy Docker stacks.
|
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`
|
5. Verify SMTP banner: `nc mail.gentic.email 25`
|
||||||
6. Send test mail to `user@gentic.email` and from the app.
|
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).
|
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:
|
After cutover you can remove or repoint:
|
||||||
|
|
||||||
- `email.getall.help`, `mail.getall.help` A records
|
- `mail.getall.help` A record (if no longer used)
|
||||||
- `getall.help` MX if it pointed at the mail VPS
|
- `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
|
- `_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 });
|
request("/domains/me", { token });
|
||||||
export const verifyCompanyDns = (token) =>
|
export const verifyCompanyDns = (token) =>
|
||||||
request("/domains/me/verify-dns", { method: "POST", 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) =>
|
export const sendEmail = (payload, token) =>
|
||||||
request("/emails", { method: "POST", 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 { getCurrentAccount, getMyCompanyDomain } from "../api";
|
||||||
import Brand from "./Brand";
|
import Brand from "./Brand";
|
||||||
import CompanyDnsSetup from "./CompanyDnsSetup";
|
import CompanyDnsSetup from "./CompanyDnsSetup";
|
||||||
|
import CompanyDomains from "./CompanyDomains";
|
||||||
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
import CompanyOverviewSetup, { isOrgSetupComplete } from "./CompanyOverviewSetup";
|
||||||
import ComposeModal from "./ComposeModal";
|
import ComposeModal from "./ComposeModal";
|
||||||
import MailboxView from "./MailboxView";
|
import MailboxView from "./MailboxView";
|
||||||
|
|
@ -361,7 +362,7 @@ export default function DashboardPage() {
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="dashboard-content">
|
<main className="dashboard-content">
|
||||||
{companyActive && companyActive !== "Overview" && (
|
{companyActive && !["Overview", "Domains"].includes(companyActive) && (
|
||||||
<div className="dashboard-content-heading">
|
<div className="dashboard-content-heading">
|
||||||
<div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div>
|
<div><p>{workspaceTitle}</p><h1>{companyActive}</h1></div>
|
||||||
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
<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">
|
<section className="dashboard-empty dashboard-company-view">
|
||||||
<span><Sparkles size={24} /></span>
|
<span><Sparkles size={24} /></span>
|
||||||
<h2>{companyActive}</h2>
|
<h2>{companyActive}</h2>
|
||||||
<p>
|
<p>Company {companyActive.toLowerCase()} controls will appear here.</p>
|
||||||
{companyActive === "Domains" && companyDomain
|
|
||||||
? `Manage DNS and delivery for ${companyDomain.domain_name}.`
|
|
||||||
: `Company ${companyActive.toLowerCase()} controls will appear here.`}
|
|
||||||
</p>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
{!companyActive && !["Inbox", "Outbox", "Trash"].includes(active) && (
|
{!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) {
|
@media(max-width:900px) {
|
||||||
.org-setup-layout { grid-template-columns:1fr; }
|
.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 { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; }
|
||||||
.dns-copy:hover { background:#fff0eb; color:var(--coral); }
|
.dns-copy:hover { background:#fff0eb; color:var(--coral); }
|
||||||
.dashboard-scrim { display:none; }
|
.dashboard-scrim { display:none; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue